@sdsrs/code-graph 0.8.1 → 0.8.3

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.
package/README.md CHANGED
@@ -146,6 +146,23 @@ Then reconnect the MCP server in Claude Code with `/mcp`.
146
146
 
147
147
  > **Note:** Auto-update is disabled in the source repo directory (dev mode). Use manual update when developing the plugin itself.
148
148
 
149
+ #### Invited-memory mode (quieter prompts)
150
+
151
+ By default, every user prompt the plugin deems code-related gets a small context injection from `code-graph` CLI output. If you'd rather rely on MEMORY.md + explicit tool calls, opt into invited-memory mode:
152
+
153
+ 1. Adopt the plugin contract into your project's memory index (idempotent, self-heals):
154
+ ```bash
155
+ code-graph-mcp adopt
156
+ ```
157
+ This writes `plugin_code_graph_mcp.md` (decision rules) into `~/.claude/projects/<slug>/memory/` and links it from `MEMORY.md` inside a sentinel block. Run `code-graph-mcp unadopt` to remove.
158
+ 2. Set the activation env var in `~/.claude/settings.json`:
159
+ ```json
160
+ {
161
+ "env": { "CODE_GRAPH_QUIET_HOOKS": "1" }
162
+ }
163
+ ```
164
+ 3. Restart Claude Code. Session startup skips the project-map injection, UserPromptSubmit stops auto-injecting context, and the MCP `instructions` become a short pointer to the MEMORY.md file.
165
+
149
166
  ### Option 2: Claude Code MCP Server Only
150
167
 
151
168
  Register as an MCP server without the plugin features:
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.8.1",
7
+ "version": "0.8.3",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -1,5 +1,54 @@
1
1
  {
2
- "description": "code-graph-mcp hooks",
3
- "_note": "Hooks are registered to ~/.claude/settings.json by scripts/lifecycle.js. This file is intentionally empty to prevent double-firingClaude Code would otherwise load hooks from both the plugin cache copy AND settings.json, causing each hook to run twice per event.",
4
- "hooks": {}
2
+ "description": "code-graph-mcp hooks — loaded directly by Claude Code from the plugin cache.",
3
+ "_note": "Authoritative source. settings.json is no longer used for hook registration as of v0.8.3session-init.js actively removes any legacy code-graph entries it finds there. Paths use ${CLAUDE_PLUGIN_ROOT} so they follow version directory updates automatically.",
4
+ "hooks": {
5
+ "SessionStart": [
6
+ {
7
+ "matcher": "startup|clear|compact",
8
+ "hooks": [
9
+ {
10
+ "type": "command",
11
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-init.js\"",
12
+ "timeout": 5
13
+ }
14
+ ]
15
+ }
16
+ ],
17
+ "PreToolUse": [
18
+ {
19
+ "matcher": "tool == \"Edit\"",
20
+ "hooks": [
21
+ {
22
+ "type": "command",
23
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-edit-guide.js\"",
24
+ "timeout": 4
25
+ }
26
+ ]
27
+ }
28
+ ],
29
+ "PostToolUse": [
30
+ {
31
+ "matcher": "tool == \"Write\" || tool == \"Edit\"",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/incremental-index.js\"",
36
+ "timeout": 10
37
+ }
38
+ ]
39
+ }
40
+ ],
41
+ "UserPromptSubmit": [
42
+ {
43
+ "matcher": "",
44
+ "hooks": [
45
+ {
46
+ "type": "command",
47
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-context.js\"",
48
+ "timeout": 5
49
+ }
50
+ ]
51
+ }
52
+ ]
53
+ }
5
54
  }
@@ -13,8 +13,11 @@ const INDEX_LINE = '- [code-graph-mcp](plugin_code_graph_mcp.md) — "谁调 X /
13
13
  const TEMPLATE_PATH = path.resolve(__dirname, '..', 'templates', 'plugin_code_graph_mcp.md');
14
14
  const TARGET_NAME = 'plugin_code_graph_mcp.md';
15
15
 
16
+ // Claude Code slug convention: every non-alphanumeric-non-hyphen char → `-`.
17
+ // `/mnt/data_ssd/dev/proj` → `-mnt-data-ssd-dev-proj`
18
+ // `/home/sds/.claude/x` → `-home-sds--claude-x` (double-dash from `/.`)
16
19
  function memoryDir(cwd = process.cwd(), home = os.homedir()) {
17
- const slug = cwd.replace(/\//g, '-');
20
+ const slug = cwd.replace(/[^a-zA-Z0-9-]/g, '-');
18
21
  return path.join(home, '.claude', 'projects', slug, 'memory');
19
22
  }
20
23
 
@@ -26,6 +26,24 @@ test('memoryDir slugifies cwd path', () => {
26
26
  assert.strictEqual(dir, '/home/alice/.claude/projects/-home-alice-proj/memory');
27
27
  });
28
28
 
29
+ test('memoryDir replaces underscores and dots (Claude Code slug convention)', () => {
30
+ // Real-world bug: /mnt/data_ssd/... needs data-ssd slug, not data_ssd
31
+ assert.strictEqual(
32
+ memoryDir('/mnt/data_ssd/dev/projects/code-graph-mcp', '/home/u'),
33
+ '/home/u/.claude/projects/-mnt-data-ssd-dev-projects-code-graph-mcp/memory'
34
+ );
35
+ // Hidden dirs: /home/sds/.claude/x → -home-sds--claude-x (double-dash)
36
+ assert.strictEqual(
37
+ memoryDir('/home/sds/.claude/x', '/home/sds'),
38
+ '/home/sds/.claude/projects/-home-sds--claude-x/memory'
39
+ );
40
+ // Preserves case and hyphens
41
+ assert.strictEqual(
42
+ memoryDir('/Users/Alice/my-Project_v2.1', '/'),
43
+ '/.claude/projects/-Users-Alice-my-Project-v2-1/memory'
44
+ );
45
+ });
46
+
29
47
  test('adopt writes template and appends sentinel block when index absent', () => {
30
48
  const sb = makeSandbox();
31
49
  try {
@@ -7,7 +7,7 @@ const os = require('os');
7
7
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
8
8
  const {
9
9
  getPluginVersion, readJson, healthCheck, CACHE_DIR,
10
- findStalePluginHooksJson, clearStalePluginCacheHooks,
10
+ removeHooksFromSettings, isOurHookEntry, writeJsonAtomic,
11
11
  } = require('./lifecycle');
12
12
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
13
13
 
@@ -166,24 +166,40 @@ function runDiagnostics() {
166
166
  });
167
167
  }
168
168
 
169
- // 7. Plugin cache hooks.json sanity non-empty copies cause every hook to fire twice
169
+ // 7. Legacy hooks in settings.json — v0.8.2 and earlier wrote hooks there;
170
+ // cache/<ver>/hooks/hooks.json is now authoritative. Duplicates cause
171
+ // every hook to fire twice until settings.json is cleaned.
170
172
  try {
171
- const stale = findStalePluginHooksJson();
172
- if (stale.length === 0) {
173
- results.push({ name: 'Plugin cache', status: 'ok', detail: 'no stale hooks.json' });
173
+ const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
174
+ const settings = readJson(SETTINGS_PATH) || {};
175
+ const legacyCount = countLegacyHookEntries(settings);
176
+ if (legacyCount === 0) {
177
+ results.push({ name: 'Legacy hooks', status: 'ok', detail: 'settings.json is clean' });
174
178
  } else {
175
179
  results.push({
176
- name: 'Plugin cache',
180
+ name: 'Legacy hooks',
177
181
  status: 'warn',
178
- detail: `${stale.length} stale hooks.json (hooks fire twice per event)`,
179
- fixId: 'hooks-cache-stale',
182
+ detail: `${legacyCount} entries in settings.json (fire twice per event)`,
183
+ fixId: 'legacy-hooks-in-settings',
180
184
  });
181
185
  }
182
- } catch { /* lifecycle probe failed — skip */ }
186
+ } catch { /* probe failed — skip */ }
183
187
 
184
188
  return results;
185
189
  }
186
190
 
191
+ function countLegacyHookEntries(settings) {
192
+ if (!settings || !settings.hooks) return 0;
193
+ let count = 0;
194
+ for (const entries of Object.values(settings.hooks)) {
195
+ if (!Array.isArray(entries)) continue;
196
+ for (const entry of entries) {
197
+ if (isOurHookEntry(entry)) count++;
198
+ }
199
+ }
200
+ return count;
201
+ }
202
+
187
203
  // ── Report Formatting ─────────────────────────────────────
188
204
 
189
205
  const STATUS_ICONS = { ok: '\u2705', warn: '\u26a0\ufe0f', error: '\u274c', skip: '\u2796' };
@@ -339,12 +355,17 @@ function runRepairs(results) {
339
355
  break;
340
356
  }
341
357
 
342
- case 'hooks-cache-stale': {
343
- console.log('\n Clearing stale plugin cache hooks.json...');
344
- const cleared = clearStalePluginCacheHooks();
345
- console.log(` \u2705 Cleared ${cleared.length} file(s) restart Claude Code to apply`);
346
- for (const p of cleared) console.log(` - ${p}`);
347
- fixed++;
358
+ case 'legacy-hooks-in-settings': {
359
+ console.log('\n Removing legacy code-graph hooks from settings.json...');
360
+ const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
361
+ const settings = readJson(SETTINGS_PATH) || {};
362
+ if (removeHooksFromSettings(settings)) {
363
+ writeJsonAtomic(SETTINGS_PATH, settings);
364
+ console.log(' \u2705 settings.json cleaned — restart Claude Code to apply');
365
+ fixed++;
366
+ } else {
367
+ console.log(' \u2796 No legacy entries found');
368
+ }
348
369
  break;
349
370
  }
350
371
 
@@ -220,92 +220,12 @@ function migrateOldPluginIds(settings) {
220
220
  return changed;
221
221
  }
222
222
 
223
- // --- Plugin-cache hooks.json guard ---
224
- // Claude Code loads hooks from TWO places: settings.json AND the plugin cache
225
- // at ~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json. If both have
226
- // our hooks, every event fires twice. We register to settings.json (reliable),
227
- // so cache copies must stay empty. Auto-updates can re-populate cache hooks.json
228
- // from the marketplace source — this scan+clear runs on every install/update and
229
- // every SessionStart (via session-init.js) as a second layer of defense.
230
-
231
- const EMPTY_HOOKS_STUB = Object.freeze({
232
- description: 'code-graph-mcp hooks',
233
- _note: 'Hooks are registered to ~/.claude/settings.json by lifecycle.js. Cleared automatically to prevent double-firing.',
234
- hooks: {},
235
- });
236
-
237
- function isOurPluginMarketplace(mpDir) {
238
- try {
239
- const meta = readJson(path.join(mpDir, '.claude-plugin', 'marketplace.json'));
240
- if (meta && meta.name === MARKETPLACE_NAME) return true;
241
- } catch { /* fallthrough */ }
242
- return path.basename(mpDir) === MARKETPLACE_NAME;
243
- }
244
-
245
- function scanPluginHooksJsonCopies() {
246
- const HOME = os.homedir();
247
- const paths = [];
248
-
249
- // Marketplace source (git-cloned by Claude Code on install)
250
- const mpRoot = path.join(HOME, '.claude', 'plugins', 'marketplaces');
251
- try {
252
- for (const name of fs.readdirSync(mpRoot)) {
253
- const mpDir = path.join(mpRoot, name);
254
- try { if (!fs.statSync(mpDir).isDirectory()) continue; } catch { continue; }
255
- if (!isOurPluginMarketplace(mpDir)) continue;
256
- const p = path.join(mpDir, 'claude-plugin', 'hooks', 'hooks.json');
257
- if (fs.existsSync(p)) paths.push(p);
258
- }
259
- } catch { /* no marketplaces dir */ }
260
-
261
- // Cache (what Claude Code actually loads at runtime), per plugin + per version
262
- const cacheRoot = path.join(HOME, '.claude', 'plugins', 'cache', MARKETPLACE_NAME);
263
- try {
264
- for (const pluginName of fs.readdirSync(cacheRoot)) {
265
- const pluginDir = path.join(cacheRoot, pluginName);
266
- try { if (!fs.statSync(pluginDir).isDirectory()) continue; } catch { continue; }
267
- for (const ver of fs.readdirSync(pluginDir)) {
268
- const verDir = path.join(pluginDir, ver);
269
- try { if (!fs.statSync(verDir).isDirectory()) continue; } catch { continue; }
270
- const p = path.join(verDir, 'hooks', 'hooks.json');
271
- if (fs.existsSync(p)) paths.push(p);
272
- }
273
- }
274
- } catch { /* no cache dir */ }
275
-
276
- return paths;
277
- }
278
-
279
- function findStalePluginHooksJson() {
280
- const stale = [];
281
- for (const p of scanPluginHooksJsonCopies()) {
282
- try {
283
- const cur = readJson(p);
284
- if (cur && cur.hooks && typeof cur.hooks === 'object' && Object.keys(cur.hooks).length > 0) {
285
- stale.push(p);
286
- }
287
- } catch { /* unreadable — skip */ }
288
- }
289
- return stale;
290
- }
291
-
292
- function clearStalePluginCacheHooks() {
293
- const cleared = [];
294
- const stamp = new Date().toISOString();
295
- for (const p of findStalePluginHooksJson()) {
296
- try {
297
- const stub = { ...EMPTY_HOOKS_STUB, _note: `${EMPTY_HOOKS_STUB._note} (cleared ${stamp})` };
298
- writeJsonAtomic(p, stub);
299
- cleared.push(p);
300
- } catch { /* write failure — skip */ }
301
- }
302
- return cleared;
303
- }
304
-
305
- // --- Hook Registration ---
306
- // Plugin system's hooks.json auto-loading is unreliable (observed across GSD,
307
- // superpowers, code-graph-mcp). Write hooks directly to settings.json instead.
308
- // Same strategy as claude-mem-lite. hooks.json is kept empty to prevent double-firing.
223
+ // --- Hook identity ---
224
+ // Claude Code loads hooks from cache/<mp>/<plugin>/<ver>/hooks/hooks.json
225
+ // that file is the authoritative source. Any entries matching our hooks
226
+ // inside settings.json are legacy migration debris (v0.8.2 and earlier wrote
227
+ // there) and must be stripped on every install/update/session-init so events
228
+ // don't fire twice.
309
229
 
310
230
  const OUR_HOOK_SCRIPTS = ['session-init.js', 'incremental-index.js', 'user-prompt-context.js', 'pre-edit-guide.js'];
311
231
  const OUR_DESCRIPTIONS = [
@@ -317,69 +237,15 @@ const OUR_DESCRIPTIONS = [
317
237
 
318
238
  function isOurHookEntry(entry) {
319
239
  if (!entry || !entry.hooks) return false;
320
- // Primary: match by description (immune to path pollution)
240
+ // Primary: match by description (legacy v0.7.x/0.8.x registrations).
321
241
  if (entry.description && OUR_DESCRIPTIONS.includes(entry.description)) return true;
322
- // Fallback: match by script name + 'code-graph' in path
242
+ // Fallback: match by script name + 'code-graph' in path.
323
243
  return entry.hooks.some(h =>
324
244
  h.command && OUR_HOOK_SCRIPTS.some(s => h.command.includes(s)) &&
325
245
  h.command.includes('code-graph')
326
246
  );
327
247
  }
328
248
 
329
- function hookCommand(scriptName) {
330
- return `node ${JSON.stringify(path.join(PLUGIN_ROOT, 'scripts', scriptName))}`;
331
- }
332
-
333
- function getHookDefinitions() {
334
- return {
335
- SessionStart: [{
336
- matcher: 'startup|clear|compact',
337
- hooks: [{ type: 'command', command: hookCommand('session-init.js'), timeout: 5 }],
338
- description: 'StatusLine self-heal, lifecycle sync, project map injection',
339
- }],
340
- PreToolUse: [{
341
- matcher: 'tool == "Edit"',
342
- hooks: [{ type: 'command', command: hookCommand('pre-edit-guide.js'), timeout: 4 }],
343
- description: 'Auto-inject impact analysis when editing functions with 2+ callers',
344
- }],
345
- PostToolUse: [{
346
- matcher: 'tool == "Write" || tool == "Edit"',
347
- hooks: [{ type: 'command', command: hookCommand('incremental-index.js'), timeout: 10 }],
348
- description: 'Auto-update code graph index after file edits',
349
- }],
350
- UserPromptSubmit: [{
351
- matcher: '',
352
- hooks: [{ type: 'command', command: hookCommand('user-prompt-context.js'), timeout: 5 }],
353
- description: 'Inject code-graph structural context based on user intent',
354
- }],
355
- };
356
- }
357
-
358
- function registerHooksToSettings(settings) {
359
- if (!settings.hooks) settings.hooks = {};
360
- const defs = getHookDefinitions();
361
- let changed = false;
362
-
363
- for (const [event, newEntries] of Object.entries(defs)) {
364
- if (!settings.hooks[event]) settings.hooks[event] = [];
365
-
366
- // First, remove ALL existing entries that match ours (cleans up duplicates
367
- // from prior PLUGIN_ROOT pollution where isOurHookEntry couldn't match,
368
- // causing infinite re-adds each session).
369
- const beforeLen = settings.hooks[event].length;
370
- settings.hooks[event] = settings.hooks[event].filter(e => !isOurHookEntry(e));
371
- if (settings.hooks[event].length !== beforeLen) changed = true;
372
-
373
- // Then add our entries fresh with correct paths
374
- for (const newEntry of newEntries) {
375
- settings.hooks[event].push(newEntry);
376
- changed = true;
377
- }
378
- }
379
-
380
- return changed;
381
- }
382
-
383
249
  function removeHooksFromSettings(settings) {
384
250
  if (!settings.hooks) return false;
385
251
  let changed = false;
@@ -434,10 +300,11 @@ function install() {
434
300
  // Register code-graph provider
435
301
  registerStatuslineProvider('code-graph', codeGraphStatuslineCommand(), false);
436
302
 
437
- // 2. Hooks — register to settings.json (hooks.json auto-loading unreliable)
438
- if (registerHooksToSettings(settings)) {
439
- settingsChanged = true;
440
- }
303
+ // 2. Hooks — cache/<ver>/hooks/hooks.json is authoritative. Strip any legacy
304
+ // entries from settings.json that v0.8.2 or earlier registered, so events
305
+ // don't fire twice.
306
+ const legacyHooksRemoved = removeHooksFromSettings(settings);
307
+ if (legacyHooksRemoved) settingsChanged = true;
441
308
 
442
309
  // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle.
443
310
  // Do NOT add enabledPlugins entries here — it causes phantom plugin entries
@@ -448,17 +315,13 @@ function install() {
448
315
  writeJsonAtomic(SETTINGS_PATH, settings);
449
316
  }
450
317
 
451
- // 3b. Clear cache/marketplace hooks.json copies after settings.json is authoritative,
452
- // so next session only fires hooks from settings.json (no double-firing).
453
- const clearedHookCopies = clearStalePluginCacheHooks();
454
-
455
318
  // 4. Write manifest with version
456
319
  manifest.version = version;
457
320
  manifest.installedAt = manifest.installedAt || new Date().toISOString();
458
321
  manifest.updatedAt = new Date().toISOString();
459
322
  writeManifest(manifest);
460
323
 
461
- return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, clearedHookCopies };
324
+ return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, legacyHooksRemoved };
462
325
  }
463
326
 
464
327
  // --- Uninstall (clean all config) ---
@@ -549,10 +412,10 @@ function update() {
549
412
  // 2. Update code-graph provider in registry
550
413
  registerStatuslineProvider('code-graph', codeGraphStatuslineCommand(), false);
551
414
 
552
- // 3. Hooks — update command paths
553
- if (registerHooksToSettings(settings)) {
554
- settingsChanged = true;
555
- }
415
+ // 3. Hooks — strip any legacy entries from settings.json. cache hooks.json
416
+ // is the new authoritative source and always has the up-to-date paths.
417
+ const legacyHooksRemoved = removeHooksFromSettings(settings);
418
+ if (legacyHooksRemoved) settingsChanged = true;
556
419
 
557
420
  // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle.
558
421
 
@@ -561,10 +424,6 @@ function update() {
561
424
  writeJsonAtomic(SETTINGS_PATH, settings);
562
425
  }
563
426
 
564
- // 4b. Clear cache/marketplace hooks.json copies after settings.json is updated.
565
- // Auto-update can re-populate cache from marketplace source; stamp it out.
566
- const clearedHookCopies = clearStalePluginCacheHooks();
567
-
568
427
  // 5. Clear update-check cache (force re-check after update)
569
428
  const updateCache = path.join(CACHE_DIR, 'update-check');
570
429
  try { fs.unlinkSync(updateCache); } catch { /* ok */ }
@@ -574,10 +433,12 @@ function update() {
574
433
  manifest.updatedAt = new Date().toISOString();
575
434
  writeManifest(manifest);
576
435
 
577
- // 7. Clean up old cached versions (keep latest 3)
436
+ // 7. Clean up old cached versions (keep latest 3). Claude Code only fires
437
+ // hooks from the active version (per installed_plugins.json), so older
438
+ // cache dirs are inert disk clutter, not correctness risks.
578
439
  cleanupOldCacheVersions(3);
579
440
 
580
- return { oldVersion, version, settingsChanged, clearedHookCopies };
441
+ return { oldVersion, version, settingsChanged, legacyHooksRemoved };
581
442
  }
582
443
 
583
444
  /**
@@ -673,8 +534,7 @@ module.exports = {
673
534
  readManifest, readJson, writeJsonAtomic,
674
535
  readRegistry, writeRegistry,
675
536
  getPluginVersion, cleanupOldCacheVersions,
676
- registerHooksToSettings, removeHooksFromSettings, getHookDefinitions,
677
- scanPluginHooksJsonCopies, findStalePluginHooksJson, clearStalePluginCacheHooks,
537
+ removeHooksFromSettings, isOurHookEntry,
678
538
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
679
539
  };
680
540
 
@@ -687,6 +547,7 @@ if (require.main === module) {
687
547
  } else if (cmd === 'uninstall') {
688
548
  const r = uninstall();
689
549
  console.log(`Uninstalled | settings cleaned=${r.settingsChanged}`);
550
+ console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
690
551
  } else if (cmd === 'update') {
691
552
  const r = update();
692
553
  console.log(`Updated ${r.oldVersion} → ${r.version} | settings=${r.settingsChanged}`);
@@ -96,80 +96,102 @@ test('cleanupDisabledStatusline also heals orphaned statusline after uninstall',
96
96
  assert.equal(fs.existsSync(registryPath), false);
97
97
  });
98
98
 
99
- function nonEmptyHooksJson() {
99
+ function legacyHooksFromPlugin() {
100
100
  return {
101
- hooks: {
102
- SessionStart: [{
103
- matcher: 'startup',
104
- hooks: [{ type: 'command', command: 'node "/plugin/session-init.js"' }],
105
- }],
106
- },
101
+ SessionStart: [{
102
+ matcher: 'startup|clear|compact',
103
+ description: 'StatusLine self-heal, lifecycle sync, project map injection',
104
+ hooks: [{ type: 'command', command: 'node "/stale/cache/0.8.2/claude-plugin/scripts/session-init.js"', timeout: 5 }],
105
+ }],
106
+ PostToolUse: [{
107
+ matcher: 'tool == "Write" || tool == "Edit"',
108
+ description: 'Auto-update code graph index after file edits',
109
+ hooks: [{ type: 'command', command: 'node "/stale/code-graph/incremental-index.js"', timeout: 10 }],
110
+ }],
107
111
  };
108
112
  }
109
113
 
110
- test('findStalePluginHooksJson detects non-empty cache and marketplace copies', () => {
111
- const homeDir = mkHome();
112
- const mpHooks = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'code-graph-mcp', 'claude-plugin', 'hooks', 'hooks.json');
113
- const mpManifest = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'code-graph-mcp', '.claude-plugin', 'marketplace.json');
114
- const cacheHooks = path.join(homeDir, '.claude', 'plugins', 'cache', 'code-graph-mcp', 'code-graph-mcp', '0.7.17', 'hooks', 'hooks.json');
115
-
116
- writeJson(mpManifest, { name: 'code-graph-mcp' });
117
- writeJson(mpHooks, nonEmptyHooksJson());
118
- writeJson(cacheHooks, nonEmptyHooksJson());
119
-
114
+ test('isOurHookEntry matches legacy description-tagged entries', () => {
115
+ const entry = legacyHooksFromPlugin().SessionStart[0];
120
116
  const out = execFileSync(process.execPath, ['-e', `
121
- const { findStalePluginHooksJson } = require(${JSON.stringify(lifecyclePath)});
122
- process.stdout.write(JSON.stringify(findStalePluginHooksJson()));
123
- `], { env: { ...process.env, HOME: homeDir } }).toString();
124
-
125
- const stale = JSON.parse(out).sort();
126
- assert.equal(stale.length, 2);
127
- assert.ok(stale.some(p => p === mpHooks));
128
- assert.ok(stale.some(p => p === cacheHooks));
117
+ const { isOurHookEntry } = require(${JSON.stringify(lifecyclePath)});
118
+ process.stdout.write(JSON.stringify(isOurHookEntry(${JSON.stringify(entry)})));
119
+ `]).toString();
120
+ assert.equal(JSON.parse(out), true);
129
121
  });
130
122
 
131
- test('clearStalePluginCacheHooks empties non-empty hooks.json copies', () => {
132
- const homeDir = mkHome();
133
- const cacheHooks = path.join(homeDir, '.claude', 'plugins', 'cache', 'code-graph-mcp', 'code-graph-mcp', '0.7.17', 'hooks', 'hooks.json');
134
- writeJson(cacheHooks, nonEmptyHooksJson());
135
-
123
+ test('isOurHookEntry matches script-name + path fallback (missing description)', () => {
124
+ const entry = {
125
+ matcher: 'tool == "Edit"',
126
+ hooks: [{ type: 'command', command: 'node "/cache/code-graph-mcp/scripts/pre-edit-guide.js"' }],
127
+ };
136
128
  const out = execFileSync(process.execPath, ['-e', `
137
- const { clearStalePluginCacheHooks } = require(${JSON.stringify(lifecyclePath)});
138
- process.stdout.write(JSON.stringify(clearStalePluginCacheHooks()));
139
- `], { env: { ...process.env, HOME: homeDir } }).toString();
140
-
141
- const cleared = JSON.parse(out);
142
- assert.deepEqual(cleared, [cacheHooks]);
129
+ const { isOurHookEntry } = require(${JSON.stringify(lifecyclePath)});
130
+ process.stdout.write(JSON.stringify(isOurHookEntry(${JSON.stringify(entry)})));
131
+ `]).toString();
132
+ assert.equal(JSON.parse(out), true);
133
+ });
143
134
 
144
- const payload = JSON.parse(fs.readFileSync(cacheHooks, 'utf8'));
145
- assert.deepEqual(payload.hooks, {});
146
- assert.ok(payload._note && payload._note.includes('cleared'));
135
+ test('isOurHookEntry leaves unrelated entries alone', () => {
136
+ const entry = {
137
+ matcher: 'startup',
138
+ description: 'some other plugin hook',
139
+ hooks: [{ type: 'command', command: 'node /some/other/script.js' }],
140
+ };
141
+ const out = execFileSync(process.execPath, ['-e', `
142
+ const { isOurHookEntry } = require(${JSON.stringify(lifecyclePath)});
143
+ process.stdout.write(JSON.stringify(isOurHookEntry(${JSON.stringify(entry)})));
144
+ `]).toString();
145
+ assert.equal(JSON.parse(out), false);
147
146
  });
148
147
 
149
- test('clearStalePluginCacheHooks is idempotent and skips already-empty copies', () => {
150
- const homeDir = mkHome();
151
- const cacheHooks = path.join(homeDir, '.claude', 'plugins', 'cache', 'code-graph-mcp', 'code-graph-mcp', '0.7.17', 'hooks', 'hooks.json');
152
- writeJson(cacheHooks, { hooks: {} });
148
+ test('removeHooksFromSettings strips our entries but keeps unrelated hooks', () => {
149
+ const settings = {
150
+ hooks: {
151
+ SessionStart: [
152
+ legacyHooksFromPlugin().SessionStart[0],
153
+ {
154
+ matcher: 'startup',
155
+ description: 'some other plugin hook',
156
+ hooks: [{ type: 'command', command: 'node /some/other/script.js' }],
157
+ },
158
+ ],
159
+ PostToolUse: [legacyHooksFromPlugin().PostToolUse[0]],
160
+ },
161
+ };
153
162
 
154
163
  const out = execFileSync(process.execPath, ['-e', `
155
- const { clearStalePluginCacheHooks } = require(${JSON.stringify(lifecyclePath)});
156
- process.stdout.write(JSON.stringify(clearStalePluginCacheHooks()));
157
- `], { env: { ...process.env, HOME: homeDir } }).toString();
158
-
159
- assert.deepEqual(JSON.parse(out), []);
164
+ const { removeHooksFromSettings } = require(${JSON.stringify(lifecyclePath)});
165
+ const s = ${JSON.stringify(settings)};
166
+ const changed = removeHooksFromSettings(s);
167
+ process.stdout.write(JSON.stringify({ changed, s }));
168
+ `]).toString();
169
+
170
+ const { changed, s } = JSON.parse(out);
171
+ assert.equal(changed, true);
172
+ // Only the unrelated SessionStart entry remains; PostToolUse removed entirely.
173
+ assert.equal(s.hooks.SessionStart.length, 1);
174
+ assert.equal(s.hooks.SessionStart[0].description, 'some other plugin hook');
175
+ assert.ok(!s.hooks.PostToolUse, 'empty event key should be deleted');
160
176
  });
161
177
 
162
- test('scanPluginHooksJsonCopies ignores unrelated marketplaces', () => {
178
+ test('install() removes legacy code-graph hooks from settings.json without re-registering', () => {
163
179
  const homeDir = mkHome();
164
- const otherMp = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'some-other-plugin', 'claude-plugin', 'hooks', 'hooks.json');
165
- const otherManifest = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'some-other-plugin', '.claude-plugin', 'marketplace.json');
166
- writeJson(otherManifest, { name: 'some-other-plugin' });
167
- writeJson(otherMp, nonEmptyHooksJson());
180
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
181
+ writeJson(settingsPath, {
182
+ statusLine: { type: 'command', command: 'echo previous-status' },
183
+ hooks: legacyHooksFromPlugin(),
184
+ });
168
185
 
169
- const out = execFileSync(process.execPath, ['-e', `
170
- const { scanPluginHooksJsonCopies } = require(${JSON.stringify(lifecyclePath)});
171
- process.stdout.write(JSON.stringify(scanPluginHooksJsonCopies()));
172
- `], { env: { ...process.env, HOME: homeDir } }).toString();
186
+ execFileSync(process.execPath, [lifecyclePath, 'install'], {
187
+ env: { ...process.env, HOME: homeDir },
188
+ });
173
189
 
174
- assert.deepEqual(JSON.parse(out), []);
190
+ const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
191
+ // No code-graph hook entries should remain — cache hooks.json is authoritative now.
192
+ const serialized = JSON.stringify(after.hooks || {});
193
+ assert.ok(!serialized.includes('code-graph'), 'settings.json must not retain code-graph hook entries');
194
+ assert.ok(!serialized.includes('session-init.js'), 'settings.json must not retain session-init.js paths');
195
+ // StatusLine composite is still registered (only channel available).
196
+ assert.match(after.statusLine.command, /statusline-composite/);
175
197
  });
@@ -7,7 +7,6 @@ const fs = require('fs');
7
7
  const {
8
8
  install, update, readManifest, getPluginVersion, checkScopeConflict,
9
9
  cleanupDisabledStatusline, isPluginInactive, readJson, CACHE_DIR,
10
- clearStalePluginCacheHooks, findStalePluginHooksJson,
11
10
  } = require('./lifecycle');
12
11
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
13
12
 
@@ -227,30 +226,6 @@ function consistencyCheck(binary) {
227
226
  return issues;
228
227
  }
229
228
 
230
- /**
231
- * Self-heal: Claude Code auto-update can re-populate cache hooks.json from the
232
- * marketplace source, which would double-fire every hook we registered to
233
- * settings.json. If our hooks are already in settings.json (install has run),
234
- * any non-empty cache/marketplace hooks.json is stale — clear it.
235
- * Gated on settings.json registration so pure plugin-only users (no install
236
- * script run; cache hooks.json is their only registration) are not broken.
237
- */
238
- function healStaleCacheHooks() {
239
- try {
240
- const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')) || {};
241
- const manifest = readManifest();
242
- if (!manifest || !manifest.version) return { checked: false, cleared: 0 };
243
- const serialized = JSON.stringify(settings.hooks || {});
244
- if (!serialized.includes('code-graph')) return { checked: false, cleared: 0 };
245
- const stale = findStalePluginHooksJson();
246
- if (stale.length === 0) return { checked: true, cleared: 0 };
247
- const cleared = clearStalePluginCacheHooks();
248
- return { checked: true, cleared: cleared.length };
249
- } catch {
250
- return { checked: false, cleared: 0 };
251
- }
252
- }
253
-
254
229
  function runSessionInit() {
255
230
  if (isPluginInactive()) {
256
231
  cleanupDisabledStatusline();
@@ -267,11 +242,6 @@ function runSessionInit() {
267
242
 
268
243
  const lifecycle = syncLifecycleConfig();
269
244
 
270
- // Self-heal stale plugin cache hooks.json (prevents double-firing after auto-update).
271
- // syncLifecycleConfig's install/update path already clears; this catches the
272
- // 'noop' case where version matches but cache was re-populated externally.
273
- const cacheHookHeal = healStaleCacheHooks();
274
-
275
245
  // Verify binary availability — catch issues early with actionable diagnostics
276
246
  const binaryCheck = verifyBinary();
277
247
 
@@ -285,7 +255,7 @@ function runSessionInit() {
285
255
  ? consistencyCheck(binaryCheck.binary)
286
256
  : [];
287
257
  return {
288
- inactive: false, lifecycle, cacheHookHeal,
258
+ inactive: false, lifecycle,
289
259
  autoUpdateLaunched, indexFreshness, mapInjected, binaryCheck, consistencyIssues,
290
260
  quietHooks,
291
261
  };
@@ -327,7 +297,6 @@ module.exports = {
327
297
  injectProjectMap,
328
298
  verifyBinary,
329
299
  consistencyCheck,
330
- healStaleCacheHooks,
331
300
  runSessionInit,
332
301
  };
333
302
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,10 +34,10 @@
34
34
  "node": ">=16"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@sdsrs/code-graph-linux-x64": "0.8.1",
38
- "@sdsrs/code-graph-linux-arm64": "0.8.1",
39
- "@sdsrs/code-graph-darwin-x64": "0.8.1",
40
- "@sdsrs/code-graph-darwin-arm64": "0.8.1",
41
- "@sdsrs/code-graph-win32-x64": "0.8.1"
37
+ "@sdsrs/code-graph-linux-x64": "0.8.3",
38
+ "@sdsrs/code-graph-linux-arm64": "0.8.3",
39
+ "@sdsrs/code-graph-darwin-x64": "0.8.3",
40
+ "@sdsrs/code-graph-darwin-arm64": "0.8.3",
41
+ "@sdsrs/code-graph-win32-x64": "0.8.3"
42
42
  }
43
43
  }