@sdsrs/code-graph 0.49.0 → 0.50.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.49.0",
7
+ "version": "0.50.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -6,6 +6,7 @@ const https = require('https');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
8
  const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
9
+ const { claudeHome } = require('./claude-config');
9
10
  const { clearCache: clearBinaryCache } = require('./find-binary');
10
11
  const { readBinaryVersion, isDevMode } = require('./version-utils');
11
12
  const { cgTmpDir } = require('./tmp-dir');
@@ -277,9 +278,41 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion) {
277
278
  }
278
279
  }
279
280
 
281
+ // ── Marketplace clone refresh ──────────────────────────────
282
+
283
+ function marketplaceCloneDir() {
284
+ return path.join(claudeHome(), 'plugins', 'marketplaces', MARKETPLACE_NAME);
285
+ }
286
+
287
+ /**
288
+ * Fast-forward the Claude Code marketplace clone after a plugin update.
289
+ *
290
+ * Auto-update writes the plugin cache + installed_plugins.json directly and
291
+ * never touched the marketplace clone, so its marketplace.json stayed pinned
292
+ * at the version present when the user last ran a /plugin command (observed
293
+ * live: clone at 0.48.0 four days after 0.49.0 shipped). A stale clone makes
294
+ * the /plugin UI report the old version and lets Claude Code re-install the
295
+ * old plugin files from it. --ff-only + silent failure: a dirty or diverged
296
+ * clone is Claude Code's property — never force anything there.
297
+ */
298
+ function refreshMarketplaceClone({ dir = marketplaceCloneDir(), exec = execFileSync, timeoutMs = 15000 } = {}) {
299
+ try {
300
+ if (!fs.existsSync(path.join(dir, '.git'))) return false;
301
+ if (!commandExists('git')) return false;
302
+ exec('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], { timeout: timeoutMs, stdio: 'pipe' });
303
+ return true;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+
280
309
  // ── Download & Install ─────────────────────────────────────
281
310
 
282
- async function downloadAndInstall(latest) {
311
+ async function downloadAndInstall(latest, {
312
+ exec = execFileSync,
313
+ downloadBin = downloadBinary,
314
+ refreshMarketplace = refreshMarketplaceClone,
315
+ } = {}) {
283
316
  // Pre-flight: check required CLI tools before attempting any download
284
317
  const missingTools = ['curl', 'tar'].filter(cmd => !commandExists(cmd));
285
318
  if (missingTools.length > 0) {
@@ -290,19 +323,20 @@ async function downloadAndInstall(latest) {
290
323
  const tmpDir = path.join(cgTmpDir(), `update-${Date.now()}`);
291
324
  let pluginUpdated = false;
292
325
  let binaryUpdated = false;
326
+ let marketplaceRefreshed = false;
293
327
 
294
328
  try {
295
329
  fs.mkdirSync(tmpDir, { recursive: true });
296
330
 
297
331
  // ── Step 1: Download and install plugin files from tarball ──
298
332
  const tarballPath = path.join(tmpDir, 'release.tar.gz');
299
- execFileSync('curl', [
333
+ exec('curl', [
300
334
  '-sL', '-o', tarballPath,
301
335
  '-H', 'Accept: application/vnd.github+json',
302
336
  latest.tarballUrl,
303
337
  ], { timeout: 30000, stdio: 'pipe' });
304
338
 
305
- execFileSync('tar', [
339
+ exec('tar', [
306
340
  'xzf', tarballPath, '-C', tmpDir, '--strip-components=1',
307
341
  ], { timeout: 15000, stdio: 'pipe' });
308
342
 
@@ -344,22 +378,28 @@ async function downloadAndInstall(latest) {
344
378
  try {
345
379
  const newLifecycle = path.join(pluginDst, 'scripts', 'lifecycle.js');
346
380
  if (fs.existsSync(newLifecycle)) {
347
- execFileSync(process.execPath, [newLifecycle, 'update'], {
381
+ exec(process.execPath, [newLifecycle, 'update'], {
348
382
  timeout: 5000, stdio: 'pipe',
349
383
  });
350
384
  }
351
385
  } catch { /* not fatal — syncLifecycleConfig will self-heal on next session */ }
352
386
  }
353
387
 
388
+ // ── Step 1.5: Fast-forward the marketplace clone so /plugin UI and any
389
+ // Claude-Code-side reinstall see the version we just installed.
390
+ if (pluginUpdated) {
391
+ marketplaceRefreshed = refreshMarketplace();
392
+ }
393
+
354
394
  // ── Step 2: Download platform binary directly from GitHub release ──
355
- if (await downloadBinary(latest)) {
395
+ if (await downloadBin(latest)) {
356
396
  binaryUpdated = true;
357
397
  }
358
398
 
359
- return { pluginUpdated, binaryUpdated };
399
+ return { pluginUpdated, binaryUpdated, marketplaceRefreshed };
360
400
  } catch (e) {
361
401
  console.error(`[code-graph] Plugin download/extract failed: ${e.message}`);
362
- return { pluginUpdated: false, binaryUpdated: false };
402
+ return { pluginUpdated: false, binaryUpdated: false, marketplaceRefreshed };
363
403
  } finally {
364
404
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
365
405
  }
@@ -431,6 +471,7 @@ async function checkForUpdate({ installMissing = false } = {}) {
431
471
  lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
432
472
  rateLimited: false,
433
473
  binaryUpdated: result.binaryUpdated,
474
+ marketplaceRefreshed: result.marketplaceRefreshed,
434
475
  };
435
476
  saveState(newState);
436
477
 
@@ -474,6 +515,7 @@ module.exports = {
474
515
  requestJson, parseLatestRelease, fetchLatestRelease,
475
516
  downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
476
517
  selfHealStaleBinary,
518
+ downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
477
519
  };
478
520
 
479
521
  // CLI: node auto-update.js [check|status] [--silent] [--install-missing]
@@ -247,4 +247,96 @@ test('fetchLatestRelease parses JSON without relying on global fetch', async ()
247
247
 
248
248
  assert.equal(latest.version, '2.0.0');
249
249
  assert.equal(latest.tarballUrl, 'https://example.com/release.tgz');
250
- });
250
+ });
251
+ // ── refreshMarketplaceClone (v0.49.1 marketplace-staleness fix) ────────────
252
+
253
+ const { execFileSync: execGit } = require('child_process');
254
+ const { refreshMarketplaceClone, downloadAndInstall } = require('./auto-update');
255
+
256
+ function git(cwd, ...args) {
257
+ return execGit('git', ['-C', cwd, '-c', 'user.email=t@t', '-c', 'user.name=t', ...args],
258
+ { stdio: 'pipe', encoding: 'utf8' });
259
+ }
260
+
261
+ test('refreshMarketplaceClone fast-forwards a stale clone', (t) => {
262
+ const root = mkDir(t, 'code-graph-mp-');
263
+ const remote = path.join(root, 'remote');
264
+ const clone = path.join(root, 'clone');
265
+
266
+ fs.mkdirSync(remote);
267
+ git(remote, 'init', '-q', '-b', 'main');
268
+ fs.writeFileSync(path.join(remote, 'marketplace.json'), '{"version":"0.48.0"}');
269
+ git(remote, 'add', '.');
270
+ git(remote, 'commit', '-q', '-m', 'v0.48.0');
271
+ execGit('git', ['clone', '-q', remote, clone], { stdio: 'pipe' });
272
+
273
+ // Remote advances (a release bumped marketplace.json) — clone is now stale.
274
+ fs.writeFileSync(path.join(remote, 'marketplace.json'), '{"version":"0.49.0"}');
275
+ git(remote, 'commit', '-q', '-am', 'v0.49.0');
276
+
277
+ assert.equal(refreshMarketplaceClone({ dir: clone }), true);
278
+ assert.match(fs.readFileSync(path.join(clone, 'marketplace.json'), 'utf8'), /0\.49\.0/);
279
+ });
280
+
281
+ test('refreshMarketplaceClone is a safe no-op on non-git dirs and pull failures', (t) => {
282
+ const root = mkDir(t, 'code-graph-mp-');
283
+ // Not a git repo → false, no throw.
284
+ assert.equal(refreshMarketplaceClone({ dir: root }), false);
285
+ // Missing dir → false, no throw.
286
+ assert.equal(refreshMarketplaceClone({ dir: path.join(root, 'nope') }), false);
287
+ // exec throws (diverged / dirty clone) → false, no throw.
288
+ const fakeGitDir = path.join(root, 'repo');
289
+ fs.mkdirSync(path.join(fakeGitDir, '.git'), { recursive: true });
290
+ assert.equal(refreshMarketplaceClone({
291
+ dir: fakeGitDir,
292
+ exec: () => { throw new Error('not a fast-forward'); },
293
+ }), false);
294
+ });
295
+
296
+ test('downloadAndInstall wires the marketplace refresh + binary download (orchestration glue)', async (t) => {
297
+ // In-process with all side-effectful deps injected would still write the
298
+ // manifest into the REAL ~/.cache (CACHE_DIR is bound at module load), so
299
+ // run in a subprocess with a sandboxed HOME — same pattern as install-e2e.
300
+ const sandboxHome = mkDir(t, 'code-graph-dai-');
301
+ const script = `
302
+ const fs = require('fs');
303
+ const path = require('path');
304
+ const { downloadAndInstall } = require(${JSON.stringify(path.join(__dirname, 'auto-update.js'))});
305
+ const latest = { version: '9.9.9', tarballUrl: 'https://example/tar', binaryUrl: null };
306
+ const calls = [];
307
+ const exec = (cmd, args) => {
308
+ calls.push(cmd);
309
+ if (cmd === 'tar') {
310
+ // Simulate extraction: produce claude-plugin/ with a matching version.
311
+ const tmpDir = args[args.indexOf('-C') + 1];
312
+ const mDir = path.join(tmpDir, 'claude-plugin', '.claude-plugin');
313
+ fs.mkdirSync(mDir, { recursive: true });
314
+ fs.writeFileSync(path.join(mDir, 'plugin.json'), JSON.stringify({ version: '9.9.9' }));
315
+ }
316
+ };
317
+ (async () => {
318
+ let refreshed = 0;
319
+ let binDownloads = 0;
320
+ const result = await downloadAndInstall(latest, {
321
+ exec,
322
+ refreshMarketplace: () => { refreshed++; return true; },
323
+ downloadBin: async () => { binDownloads++; return true; },
324
+ });
325
+ console.log(JSON.stringify({ result, refreshed, binDownloads, calls }));
326
+ })();
327
+ `;
328
+ const out = execGit(process.execPath, ['-e', script], {
329
+ env: { ...process.env, HOME: sandboxHome },
330
+ encoding: 'utf8',
331
+ });
332
+ const { result, refreshed, binDownloads } = JSON.parse(out.trim().split('\n').pop());
333
+ assert.equal(result.pluginUpdated, true, 'plugin files must install from the extracted tarball');
334
+ assert.equal(refreshed, 1, 'marketplace refresh must run exactly once after a plugin update');
335
+ assert.equal(result.marketplaceRefreshed, true);
336
+ assert.equal(binDownloads, 1, 'binary download must run');
337
+ assert.equal(result.binaryUpdated, true);
338
+ // Plugin landed in the sandboxed cache, not the real one.
339
+ const dst = path.join(sandboxHome, '.claude', 'plugins', 'cache',
340
+ 'code-graph-mcp', 'code-graph-mcp', '9.9.9', '.claude-plugin', 'plugin.json');
341
+ assert.equal(fs.existsSync(dst), true, 'plugin copied into sandbox plugins cache');
342
+ });
@@ -114,7 +114,16 @@ function runGrepAnswer(opts = {}) {
114
114
  // The CLI skips its recommendations.jsonl `use` record when this is set.
115
115
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
116
116
  });
117
- if (res.error || res.signal || res.status !== 0) {
117
+ if (res.error || res.signal) {
118
+ return { status: 'unavailable' };
119
+ }
120
+ // v0.50 grep-parity exit codes: 0 = matched, 1 = no match, 2 = error.
121
+ // Older binaries exit 0 on no-match with the NO_MATCH_PREFIX on stderr
122
+ // (stdout empty) — both shapes resolve to 'no-hits' below.
123
+ if (res.status === 1) {
124
+ return { status: 'no-hits' };
125
+ }
126
+ if (res.status !== 0) {
118
127
  return { status: 'unavailable' };
119
128
  }
120
129
  const out = (res.stdout || '').trim();
@@ -21,6 +21,9 @@ if (pattern === 'HangForever') { setTimeout(() => {}, 60000); }
21
21
  else if (pattern === 'ExplodePlease') { process.exit(3); }
22
22
  else if (pattern === 'NothingHere') {
23
23
  process.stdout.write('[code-graph] No matches for: NothingHere\\n');
24
+ } else if (pattern === 'NothingHereExit1') {
25
+ // v0.50 grep-parity binary: no match → empty stdout + exit 1
26
+ process.exit(1);
24
27
  } else {
25
28
  process.stdout.write(
26
29
  'src/storage/db.rs:42 fn ' + pattern + '() {\\n' +
@@ -81,7 +84,13 @@ test('runGrepAnswer: CLI "[code-graph] No matches" → status no-hits', () => {
81
84
  assert.equal(r.status, 'no-hits');
82
85
  });
83
86
 
84
- test('runGrepAnswer: nonzero exit → unavailable', () => {
87
+ test('runGrepAnswer: exit 1 (v0.50 grep-parity no-match) status no-hits', () => {
88
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'NothingHereExit1', binary: stubBinary() });
89
+ assert.equal(r.status, 'no-hits',
90
+ 'grep-parity exit 1 means no match, not a failed binary');
91
+ });
92
+
93
+ test('runGrepAnswer: exit >1 → unavailable', () => {
85
94
  const r = runGrepAnswer({ cwd: stubDir, pattern: 'ExplodePlease', binary: stubBinary() });
86
95
  assert.equal(r.status, 'unavailable');
87
96
  });
@@ -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
- isOurHookEntry, settingsPath, buildSettingsHookEntries,
10
+ settingsPath, surveyHookCoverage,
11
11
  } = require('./lifecycle');
12
12
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
13
13
 
@@ -234,49 +234,6 @@ function runDiagnostics() {
234
234
  return results;
235
235
  }
236
236
 
237
- // Inventory of (event, matcher) tuples we expect to find in settings.json after
238
- // install. Used by doctor to detect missing entries.
239
- function surveyHookCoverage(settings) {
240
- const desired = buildSettingsHookEntries();
241
- const expected = [];
242
- const desiredCmd = {}; // key -> command string we would write now
243
- for (const [event, entries] of Object.entries(desired)) {
244
- for (const e of entries) {
245
- const key = `${event}:${e.matcher || '*'}`;
246
- expected.push(key);
247
- desiredCmd[key] = e.hooks && e.hooks[0] && e.hooks[0].command;
248
- }
249
- }
250
-
251
- const present = new Set();
252
- const presentCmd = {}; // key -> command currently registered
253
- if (settings && settings.hooks) {
254
- for (const [event, entries] of Object.entries(settings.hooks)) {
255
- if (!Array.isArray(entries)) continue;
256
- for (const entry of entries) {
257
- if (isOurHookEntry(entry)) {
258
- const key = `${event}:${entry.matcher || '*'}`;
259
- present.add(key);
260
- if (entry.hooks && entry.hooks[0] && entry.hooks[0].command) {
261
- presentCmd[key] = entry.hooks[0].command;
262
- }
263
- }
264
- }
265
- }
266
- }
267
-
268
- const missing = expected.filter(k => !present.has(k));
269
- // Stale = present but the registered command no longer matches what we'd write
270
- // now (points at an old plugin-cache version dir / moved path). A stale path can
271
- // run pre-recordRecommendation hook code, so the hook fires but the conversion
272
- // metric stays dark — invisible to a present/absent check. This is the
273
- // 0.45.1-registered-while-0.45.4-active case the RCA surfaced.
274
- const stale = expected.filter(k =>
275
- present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
276
- );
277
- return { expected, present: [...present], missing, stale };
278
- }
279
-
280
237
  // ── Report Formatting ─────────────────────────────────────
281
238
 
282
239
  const STATUS_ICONS = { ok: '\u2705', warn: '\u26a0\ufe0f', error: '\u274c', skip: '\u2796' };
@@ -306,6 +263,26 @@ function formatReport(results) {
306
263
 
307
264
  // ── Repair Actions ────────────────────────────────────────
308
265
 
266
+ /**
267
+ * v0.50.0: settings-writing repairs get the same stale-relic guard as
268
+ * session-init. A doctor launched from an old plugin-cache version dir would
269
+ * otherwise install() and re-anchor manifest + settings.json hook paths to the
270
+ * relic — the exact downgrade war the guard exists for, just user-triggered.
271
+ * Returns true (and prints redirection) when this copy must NOT write config.
272
+ * `relic` is injectable for tests.
273
+ */
274
+ function relicRepairGuard({ log = console.log, relic = undefined } = {}) {
275
+ const { isStaleRelicContext, activeInstallPath } = require('./lifecycle');
276
+ const isRelic = relic !== undefined ? relic : isStaleRelicContext();
277
+ if (!isRelic) return false;
278
+ const active = activeInstallPath();
279
+ log(' ⚠ This doctor copy is not the active install (installed_plugins.json points elsewhere) — skipping settings repair.');
280
+ if (active) {
281
+ log(` Run the active copy instead: node "${path.join(active, 'scripts', 'doctor.js')}"`);
282
+ }
283
+ return true;
284
+ }
285
+
309
286
  function runRepairs(results) {
310
287
  const fixable = results.filter(r => r.fixId);
311
288
  if (fixable.length === 0) return 0;
@@ -425,6 +402,7 @@ function runRepairs(results) {
425
402
 
426
403
  case 'hooks-invalid': {
427
404
  console.log('\n Repairing hooks...');
405
+ if (relicRepairGuard()) break;
428
406
  const { install } = require('./lifecycle');
429
407
  install();
430
408
  console.log(' \u2705 Hooks repaired \u2014 restart Claude Code to apply');
@@ -434,6 +412,7 @@ function runRepairs(results) {
434
412
 
435
413
  case 'missing-hooks-in-settings': {
436
414
  console.log('\n Registering code-graph hooks in settings.json...');
415
+ if (relicRepairGuard()) break;
437
416
  const { install } = require('./lifecycle');
438
417
  const r = install();
439
418
  if (r.hooksRegistered) {
@@ -474,7 +453,7 @@ function runDoctor(opts = {}) {
474
453
  return { results, issueCount: issues.length };
475
454
  }
476
455
 
477
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage };
456
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage, relicRepairGuard };
478
457
 
479
458
  if (require.main === module) {
480
459
  const args = process.argv.slice(2);
@@ -80,3 +80,16 @@ test('surveyHookCoverage flags missing entries when settings empty', () => {
80
80
  assert.ok(cov.missing.length === cov.expected.length, 'all expected entries missing');
81
81
  assert.equal(cov.stale.length, 0, 'nothing present to be stale');
82
82
  });
83
+
84
+ // ── relicRepairGuard (v0.50.0 — doctor twin of the session-init relic guard) ──
85
+
86
+ test('relicRepairGuard blocks settings repair from a relic copy and redirects', () => {
87
+ const { relicRepairGuard } = require('./doctor');
88
+ const lines = [];
89
+ // Relic context → guard fires, prints the redirect, returns true (skip install).
90
+ assert.equal(relicRepairGuard({ relic: true, log: (s) => lines.push(s) }), true);
91
+ assert.ok(lines.some(l => l.includes('not the active install')),
92
+ `guard must explain why repair is skipped, got: ${lines.join(' | ')}`);
93
+ // Active (or dev/npm) context → repair proceeds.
94
+ assert.equal(relicRepairGuard({ relic: false, log: () => {} }), false);
95
+ });
@@ -75,6 +75,48 @@ function hasInstalledPluginRecord() {
75
75
  return !!(installed && installed.plugins && Array.isArray(installed.plugins[PLUGIN_ID]) && installed.plugins[PLUGIN_ID].length > 0);
76
76
  }
77
77
 
78
+ /** The installPath Claude Code currently considers active (installed_plugins.json), or null. */
79
+ function activeInstallPath() {
80
+ const installed = readJson(installedPluginsPath());
81
+ const recs = installed && installed.plugins && installed.plugins[PLUGIN_ID];
82
+ if (!Array.isArray(recs) || !recs[0] || typeof recs[0].installPath !== 'string') return null;
83
+ return recs[0].installPath;
84
+ }
85
+
86
+ /**
87
+ * Stale-relic detection: is THIS script running from an old plugin-cache
88
+ * version dir while installed_plugins.json points at a different (active)
89
+ * install that exists on disk?
90
+ *
91
+ * Why: a still-running Claude Code process keeps firing SessionStart from the
92
+ * install path it loaded at startup. After auto-update installs vN+1 and
93
+ * re-anchors manifest + settings.json, the next SessionStart in that old
94
+ * process runs the vN scripts, whose syncLifecycleConfig sees
95
+ * `manifest.version !== currentVersion` and — direction-blind — calls
96
+ * update(), dragging manifest and every settings.json hook path back to the
97
+ * vN dir. The two versions then ping-pong (observed live 2026-06-12: manifest
98
+ * 0.49.0 → rewritten 0.48.0 fifteen minutes after a successful update).
99
+ *
100
+ * The authority is installed_plugins.json, not version direction: a deliberate
101
+ * downgrade via /plugin lands installPath == the old dir, so the old scripts
102
+ * keep full self-heal rights. Dev checkouts and npm installs are exempt
103
+ * (pluginRoot not under the plugins cache).
104
+ */
105
+ function isStaleRelicContext({
106
+ pluginRoot = PLUGIN_ROOT,
107
+ cacheRoot = pluginsCacheDir(),
108
+ activePath = activeInstallPath(),
109
+ existsSync = fs.existsSync,
110
+ } = {}) {
111
+ if (!activePath) return false;
112
+ const root = path.resolve(pluginRoot);
113
+ const cache = path.resolve(cacheRoot);
114
+ if (root !== cache && !root.startsWith(cache + path.sep)) return false;
115
+ const active = path.resolve(activePath);
116
+ if (active === root) return false;
117
+ return existsSync(path.join(active, 'scripts', 'lifecycle.js'));
118
+ }
119
+
78
120
  function isOurComposite(settings) {
79
121
  return settings.statusLine &&
80
122
  settings.statusLine.command &&
@@ -377,6 +419,50 @@ function registerHooksToSettings(settings) {
377
419
  return before !== JSON.stringify(settings.hooks);
378
420
  }
379
421
 
422
+ // Inventory of (event, matcher) tuples we expect to find in settings.json after
423
+ // install. Consumed by doctor (report + fix) and session-init (self-heal):
424
+ // `missing` = entry absent; `stale` = present but the registered command no
425
+ // longer matches what we'd write now (points at an old plugin-cache version
426
+ // dir / moved path). A stale path can run pre-recordRecommendation hook code,
427
+ // so the hook fires but the conversion metric stays dark — invisible to a
428
+ // present/absent check. This is the 0.45.1-registered-while-0.45.4-active
429
+ // case the RCA surfaced.
430
+ function surveyHookCoverage(settings) {
431
+ const desired = buildSettingsHookEntries();
432
+ const expected = [];
433
+ const desiredCmd = {}; // key -> command string we would write now
434
+ for (const [event, entries] of Object.entries(desired)) {
435
+ for (const e of entries) {
436
+ const key = `${event}:${e.matcher || '*'}`;
437
+ expected.push(key);
438
+ desiredCmd[key] = e.hooks && e.hooks[0] && e.hooks[0].command;
439
+ }
440
+ }
441
+
442
+ const present = new Set();
443
+ const presentCmd = {}; // key -> command currently registered
444
+ if (settings && settings.hooks) {
445
+ for (const [event, entries] of Object.entries(settings.hooks)) {
446
+ if (!Array.isArray(entries)) continue;
447
+ for (const entry of entries) {
448
+ if (isOurHookEntry(entry)) {
449
+ const key = `${event}:${entry.matcher || '*'}`;
450
+ present.add(key);
451
+ if (entry.hooks && entry.hooks[0] && entry.hooks[0].command) {
452
+ presentCmd[key] = entry.hooks[0].command;
453
+ }
454
+ }
455
+ }
456
+ }
457
+ }
458
+
459
+ const missing = expected.filter(k => !present.has(k));
460
+ const stale = expected.filter(k =>
461
+ present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
462
+ );
463
+ return { expected, present: [...present], missing, stale };
464
+ }
465
+
380
466
  // --- Install (idempotent) ---
381
467
 
382
468
  function install() {
@@ -673,6 +759,8 @@ module.exports = {
673
759
  getPluginVersion, cleanupOldCacheVersions,
674
760
  removeHooksFromSettings, isOurHookEntry,
675
761
  registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
762
+ surveyHookCoverage, compositeCommand, // v0.49.1 — version-aware self-heal
763
+ activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
676
764
  SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
677
765
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers
678
766
  registerStatuslineProvider, unregisterStatuslineProvider,
@@ -660,4 +660,43 @@ test('scanForBrokenPaths is exported and returns the issue structure', (t) => {
660
660
  assert.ok(Array.isArray(issues));
661
661
  assert.ok(issues.some(i => i.type === 'hook' && i.event === 'PreToolUse' && i.path.includes('/nonexistent/')),
662
662
  'scanForBrokenPaths must report the seeded broken hook entry');
663
- });
663
+ });
664
+ // ── isStaleRelicContext (v0.49.1 downgrade-war guard) ──────────────────────
665
+
666
+ test('isStaleRelicContext: relic in plugins cache defers to a different active install', (t) => {
667
+ const { isStaleRelicContext } = require('./lifecycle');
668
+ const cacheRoot = '/home/u/.claude/plugins/cache';
669
+ const relicRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.48.0`;
670
+ const activeRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.49.0`;
671
+
672
+ // The downgrade-war case: running from old cache dir, active points elsewhere.
673
+ assert.equal(isStaleRelicContext({
674
+ pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
675
+ existsSync: () => true,
676
+ }), true);
677
+
678
+ // Running FROM the active install → full self-heal rights.
679
+ assert.equal(isStaleRelicContext({
680
+ pluginRoot: activeRoot, cacheRoot, activePath: activeRoot,
681
+ existsSync: () => true,
682
+ }), false);
683
+
684
+ // Dev checkout / npm install (pluginRoot outside the plugins cache) → exempt.
685
+ assert.equal(isStaleRelicContext({
686
+ pluginRoot: '/repo/code-graph-mcp/claude-plugin', cacheRoot, activePath: activeRoot,
687
+ existsSync: () => true,
688
+ }), false);
689
+
690
+ // No installed_plugins record → exempt (nothing authoritative to defer to).
691
+ assert.equal(isStaleRelicContext({
692
+ pluginRoot: relicRoot, cacheRoot, activePath: null,
693
+ existsSync: () => true,
694
+ }), false);
695
+
696
+ // Active path recorded but its lifecycle.js is gone (cache wiped) → the
697
+ // relic is the only working copy left; keep self-heal rights.
698
+ assert.equal(isStaleRelicContext({
699
+ pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
700
+ existsSync: () => false,
701
+ }), false);
702
+ });
@@ -242,6 +242,44 @@ function extractSedReadTargets(cmd) {
242
242
  return out;
243
243
  }
244
244
 
245
+ // v0.50 — a compound command (`grep …; sed -n 1,60p f` / `grep … && wc`) is
246
+ // denied WHOLE, but the answer covers only the grep. The 2026-06-13 mem-project
247
+ // deny swallowed a `; sed` read while the copy said "use these results directly
248
+ // instead of re-running" — the tail's intent was silently dropped. Extract the
249
+ // first top-level `;`/`&&` tail (quote-aware) so the deny can flag it for
250
+ // re-issue. `||` tails are skipped: the answer delivered hits, so the on-failure
251
+ // branch would not have run anyway. Pipes/redirects are the same pipeline.
252
+ function extractUnansweredTail(cmd) {
253
+ if (!cmd || typeof cmd !== 'string' || cmd.length > 2000) return null;
254
+ let quote = null;
255
+ for (let i = 0; i < cmd.length; i++) {
256
+ const c = cmd[i];
257
+ if (quote) {
258
+ if (c === quote) quote = null;
259
+ continue;
260
+ }
261
+ if (c === '"' || c === "'") { quote = c; continue; }
262
+ if (c === ';' || (c === '&' && cmd[i + 1] === '&')) {
263
+ const tail = cmd.slice(i + (c === ';' ? 1 : 2)).trim();
264
+ return tail || null;
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+
270
+ // Shared deny-copy footer for the unanswered compound tail. Budget-conscious:
271
+ // two lines, verbatim command so the model can re-issue without thinking.
272
+ // Wording is path-neutral — it must stay true for BOTH the answered deny
273
+ // ("grep half answered, tail wasn't") and the static fallback (nothing was).
274
+ function appendUnansweredTailNote(lines, tail) {
275
+ if (!tail) return;
276
+ const shown = tail.length > 300 ? tail.slice(0, 300) + '…' : tail;
277
+ lines.push(
278
+ 'NOTE: the rest of this compound command (after the grep) did NOT run — re-issue it separately:',
279
+ `$ ${shown}`,
280
+ );
281
+ }
282
+
245
283
  // v0.47.0 — pull the first source-tree path token out of the denied command so
246
284
  // the inline answer can scope its search the same way the raw grep would have.
247
285
  function extractSearchPath(cmd) {
@@ -282,7 +320,7 @@ function buildHint() {
282
320
  // Terse, no banner spam. Single message budget ~600 bytes.
283
321
  return [
284
322
  '[code-graph] Raw `grep`/`rg` on indexed source — consider AST-aware equivalents:',
285
- ' • code-graph-mcp grep "<pat>" <path> # grep + containing fn/module per hit',
323
+ ' • code-graph-mcp grep "<pat>" [paths...] # grep + containing fn/module per hit (-F literal, -i, -w, -l, -C N)',
286
324
  ' • code-graph-mcp ast-search "<pat>" --type fn # filter by type/returns/params',
287
325
  ' • code-graph-mcp callgraph SYMBOL # callers + callees, repo-wide',
288
326
  ' • code-graph-mcp show SYMBOL # one symbol: signature + source',
@@ -290,20 +328,22 @@ function buildHint() {
290
328
  ].join('\n');
291
329
  }
292
330
 
293
- function buildBlockReason() {
331
+ function buildBlockReason(unansweredTail) {
294
332
  // Shown to Claude via PreToolUse `decision: block` reason. Must give a
295
333
  // concrete alternate command Claude can re-issue without further thinking.
296
334
  // v0.49 — NO escape-hatch line anywhere in deny copy: the daagu 2026-06-12
297
335
  // night proved even the "THIS command only" scoping reads as a teachable
298
336
  // permanent prefix (adopted in 8s, reused 11×, incl. on the exact identifier
299
337
  // searches this hook targets). The env opt-out stays documented in README.
300
- return [
338
+ const lines = [
301
339
  '[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
302
340
  'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
303
- ' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
341
+ ' code-graph-mcp grep "<pattern>" [paths...] # AST context per hit; -F literal, -i, -w, -l, -C N, --max-count 0',
304
342
  ' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
305
343
  ' code-graph-mcp callgraph SYMBOL # callers + callees',
306
- ].join('\n');
344
+ ];
345
+ appendUnansweredTailNote(lines, unansweredTail);
346
+ return lines.join('\n');
307
347
  }
308
348
 
309
349
  // v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
@@ -313,7 +353,7 @@ function buildBlockReason() {
313
353
  // advertising the bypass taught it a permanent prefix within 5 seconds (daagu
314
354
  // 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
315
355
  // escape because there we give no answer.
316
- function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
356
+ function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail) {
317
357
  const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
318
358
  const lines = [
319
359
  '[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:',
@@ -326,6 +366,7 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
326
366
  lines.push(
327
367
  'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
328
368
  );
369
+ appendUnansweredTailNote(lines, unansweredTail);
329
370
  return lines.join('\n');
330
371
  }
331
372
 
@@ -333,7 +374,7 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
333
374
  // context flags (-A/-B/-C = "show me the body"); the answer IS the bodies,
334
375
  // fetched via `code-graph-mcp show`. answer.text already carries per-symbol
335
376
  // `$ code-graph-mcp show <sym>` headers.
336
- function buildShowDenyReason(answer) {
377
+ function buildShowDenyReason(answer, unansweredTail) {
337
378
  const lines = [
338
379
  '[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:',
339
380
  answer.text,
@@ -342,6 +383,7 @@ function buildShowDenyReason(answer) {
342
383
  lines.push('(truncated — re-run the `code-graph-mcp show <symbol>` command above for full source)');
343
384
  }
344
385
  lines.push('Use these directly instead of re-running the search.');
386
+ appendUnansweredTailNote(lines, unansweredTail);
345
387
  return lines.join('\n');
346
388
  }
347
389
 
@@ -364,7 +406,7 @@ function translateBreToRg(cmd, pattern) {
364
406
  // ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead.
365
407
  // Let the raw grep through with an honest one-liner.
366
408
  function buildNoHitsFyi(pattern) {
367
- return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding.`;
409
+ return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding. (Regex-metachar patterns: \`code-graph-mcp grep -F\` searches literally.)`;
368
410
  }
369
411
 
370
412
  // --- Main execution (only when run directly) ---
@@ -483,20 +525,26 @@ function runMain() {
483
525
  // is the documented modern path. Exit 0 — this is a routing decision, not
484
526
  // a hook failure (exit 2 would mark the tool call as "hook errored").
485
527
  const answered = answer.status === 'hits';
528
+ // v0.50 — flag the unanswered `;`/`&&` tail. Extracted from rawCmd: its
529
+ // paths are valid in the model's shell cwd, where the re-issue will run.
530
+ const unansweredTail = extractUnansweredTail(rawCmd);
486
531
  recordRecommendation(root, {
487
532
  hook: 'grep', action: 'deny', answered,
488
533
  // mode segments which answer type converts (show=bodies, grep=hits).
489
534
  ...(answered ? { mode: answeredMode } : {}),
535
+ // tail segments compound-command denies — lets the funnel compare
536
+ // re-issue behavior for denies that carried a tail note.
537
+ ...(unansweredTail ? { tail: true } : {}),
490
538
  });
491
539
  process.stdout.write(JSON.stringify({
492
540
  hookSpecificOutput: {
493
541
  hookEventName: 'PreToolUse',
494
542
  permissionDecision: 'deny',
495
543
  permissionDecisionReason: !answered
496
- ? buildBlockReason()
544
+ ? buildBlockReason(unansweredTail)
497
545
  : answeredMode === 'show'
498
- ? buildShowDenyReason(answer)
499
- : buildBlockReasonWithAnswer(pattern, searchPath, answer),
546
+ ? buildShowDenyReason(answer, unansweredTail)
547
+ : buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail),
500
548
  },
501
549
  }) + '\n');
502
550
  return;
@@ -518,6 +566,7 @@ module.exports = {
518
566
  translateBreToRg, // v0.49 — BRE→rust-regex dialect bridge
519
567
  buildShowDenyReason, // v0.49 — show-mode deny copy
520
568
  extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
569
+ extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
521
570
  extractPatterns, // v0.32.1 — exposed for tests
522
571
  extractSearchPath, // v0.47.0 — deny-with-answer
523
572
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
@@ -9,6 +9,7 @@ const {
9
9
  translateBreToRg,
10
10
  buildShowDenyReason,
11
11
  extractSedReadTargets,
12
+ extractUnansweredTail,
12
13
  extractPatterns,
13
14
  extractSearchPath,
14
15
  normalizeCommandPaths,
@@ -741,6 +742,78 @@ test('buildBlockReasonWithAnswer: truncated flag adds marker', () => {
741
742
  assert.match(reason, /truncated/);
742
743
  });
743
744
 
745
+ // ── v0.50 compound-command tail: deny answers the grep, NOT the rest ─
746
+
747
+ test('extractUnansweredTail: `; sed` tail after piped grep (2026-06-13 real deny shape)', () => {
748
+ assert.equal(
749
+ extractUnansweredTail(
750
+ 'grep -n "mem_update\\|registerTool" tests/server.test.mjs | head -20; sed -n \'1,60p\' tests/server.test.mjs'),
751
+ "sed -n '1,60p' tests/server.test.mjs");
752
+ });
753
+
754
+ test('extractUnansweredTail: && tail is unanswered (would have run on grep success)', () => {
755
+ assert.equal(
756
+ extractUnansweredTail('grep -rn "fts5_search" src/ && wc -l src/storage/db.rs'),
757
+ 'wc -l src/storage/db.rs');
758
+ });
759
+
760
+ test('extractUnansweredTail: quoted separators are pattern text, not a tail', () => {
761
+ assert.equal(extractUnansweredTail('grep -rn "a;b" src/'), null);
762
+ assert.equal(extractUnansweredTail("grep -rn 'a && b' src/"), null);
763
+ });
764
+
765
+ test('extractUnansweredTail: pipes and redirects are the same pipeline, not a tail', () => {
766
+ assert.equal(extractUnansweredTail('grep -rn "Foo" src/ 2>&1 | head -10'), null);
767
+ });
768
+
769
+ test('extractUnansweredTail: || branch would NOT have run on hits — no tail', () => {
770
+ assert.equal(extractUnansweredTail('grep -rn "Foo" src/ || echo none'), null);
771
+ });
772
+
773
+ test('extractUnansweredTail: trailing separator with nothing after → no tail', () => {
774
+ assert.equal(extractUnansweredTail('grep -rn "Foo" src/;'), null);
775
+ });
776
+
777
+ test('buildBlockReasonWithAnswer: compound tail → note says the rest did NOT run', () => {
778
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
779
+ status: 'hits', text: 'hit', truncated: false,
780
+ }, "sed -n '1,60p' tests/server.test.mjs");
781
+ assert.match(reason, /did NOT run/);
782
+ assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
783
+ });
784
+
785
+ test('buildBlockReasonWithAnswer: no tail → no compound note', () => {
786
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
787
+ status: 'hits', text: 'hit', truncated: false,
788
+ });
789
+ assert.doesNotMatch(reason, /did NOT run/);
790
+ });
791
+
792
+ test('buildShowDenyReason: compound tail → note says the rest did NOT run', () => {
793
+ const reason = buildShowDenyReason(
794
+ { status: 'hits', text: 'fn body', truncated: false },
795
+ 'cargo test -q');
796
+ assert.match(reason, /did NOT run/);
797
+ assert.match(reason, /cargo test -q/);
798
+ });
799
+
800
+ test('buildShowDenyReason: no tail → no compound note', () => {
801
+ const reason = buildShowDenyReason({ status: 'hits', text: 'fn body', truncated: false });
802
+ assert.doesNotMatch(reason, /did NOT run/);
803
+ });
804
+
805
+ test('buildBlockReason: compound tail → static deny also flags the unanswered tail', () => {
806
+ const reason = buildBlockReason("sed -n '1,60p' tests/server.test.mjs");
807
+ assert.match(reason, /did NOT run/);
808
+ assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
809
+ });
810
+
811
+ test('buildBlockReason: no tail → unchanged static deny', () => {
812
+ const reason = buildBlockReason();
813
+ assert.match(reason, /denied by code-graph hook/);
814
+ assert.doesNotMatch(reason, /did NOT run/);
815
+ });
816
+
744
817
  test('buildNoHitsFyi: names the pattern and says raw grep proceeds', () => {
745
818
  const fyi = buildNoHitsFyi('GhostSymbol');
746
819
  assert.match(fyi, /GhostSymbol/);
@@ -903,6 +976,75 @@ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would h
903
976
  }
904
977
  });
905
978
 
979
+ test('e2e: compound `grep …; sed` → deny answers grep AND flags the unanswered sed tail', () => {
980
+ const uniq = `StubTail${Date.now()}`;
981
+ const fixture = e2eFixture(
982
+ `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
983
+ const cmd = `grep -n "${uniq}" src/foo.rs | head -20; sed -n '100,160p' src/foo.rs`;
984
+ try {
985
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
986
+ fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
987
+ const res = runHook(cmd, fixture);
988
+ assert.equal(res.status, 0);
989
+ const out = JSON.parse(res.stdout);
990
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
991
+ const reason = out.hookSpecificOutput.permissionDecisionReason;
992
+ assert.match(reason, /src\/foo\.rs:7/); // grep half answered
993
+ assert.match(reason, /did NOT run/); // tail flagged honestly
994
+ assert.match(reason, /sed -n '100,160p' src\/foo\.rs/); // verbatim re-issue line
995
+ // funnel: the deny record marks that a tail note was carried
996
+ const recs = fsE2e.readFileSync(
997
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
998
+ const rec = JSON.parse(recs.trim().split('\n').pop());
999
+ assert.equal(rec.action, 'deny');
1000
+ assert.equal(rec.tail, true);
1001
+ } finally {
1002
+ cleanupFixture(fixture, cmd);
1003
+ }
1004
+ });
1005
+
1006
+ test('e2e: compound cmd + answer failure → STATIC deny still flags tail + records tail:true', () => {
1007
+ const uniq = `StubTailBoom${Date.now()}`;
1008
+ const fixture = e2eFixture(`process.exit(3);`);
1009
+ const cmd = `grep -n "${uniq}" src/foo.rs && cargo test -q`;
1010
+ try {
1011
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
1012
+ fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
1013
+ const res = runHook(cmd, fixture);
1014
+ assert.equal(res.status, 0);
1015
+ const out = JSON.parse(res.stdout);
1016
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1017
+ const reason = out.hookSpecificOutput.permissionDecisionReason;
1018
+ assert.match(reason, /denied by code-graph hook/); // static fallback path
1019
+ assert.match(reason, /did NOT run/);
1020
+ assert.match(reason, /cargo test -q/);
1021
+ const rec = JSON.parse(fsE2e.readFileSync(
1022
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
1023
+ assert.equal(rec.answered, false);
1024
+ assert.equal(rec.tail, true);
1025
+ } finally {
1026
+ cleanupFixture(fixture, cmd);
1027
+ }
1028
+ });
1029
+
1030
+ test('e2e: simple (non-compound) denied grep → no tail field in the deny record', () => {
1031
+ const uniq = `StubNoTail${Date.now()}`;
1032
+ const fixture = e2eFixture(
1033
+ `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
1034
+ const cmd = `grep -rn "${uniq}" src/`;
1035
+ try {
1036
+ const res = runHook(cmd, fixture);
1037
+ const out = JSON.parse(res.stdout);
1038
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1039
+ const rec = JSON.parse(fsE2e.readFileSync(
1040
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
1041
+ assert.equal(rec.action, 'deny');
1042
+ assert.equal('tail' in rec, false);
1043
+ } finally {
1044
+ cleanupFixture(fixture, cmd);
1045
+ }
1046
+ });
1047
+
906
1048
  // ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
907
1049
  // daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
908
1050
  // head-greps for the rest of the night — gate 5 checked process.cwd() only.
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const {
7
7
  install, update, readManifest, getPluginVersion, checkScopeConflict,
8
8
  cleanupDisabledStatusline, isPluginInactive, readJson, CACHE_DIR,
9
- settingsPath,
9
+ settingsPath, isStaleRelicContext,
10
10
  } = require('./lifecycle');
11
11
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
12
12
  const { maybeAutoAdopt, isAdopted } = require('./adopt');
@@ -55,6 +55,15 @@ function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env) {
55
55
  }
56
56
 
57
57
  function syncLifecycleConfig() {
58
+ // v0.49.1: stale-relic guard. A still-running Claude Code process fires
59
+ // SessionStart from the plugin-cache dir it loaded at startup; after
60
+ // auto-update installs a newer version, those old scripts would see
61
+ // `manifest.version !== currentVersion` below and — direction-blind —
62
+ // call update(), dragging manifest + every settings.json hook path back
63
+ // to the old dir (upgrade↔downgrade ping-pong, observed live 2026-06-12).
64
+ // installed_plugins.json is the authority on which install may self-heal.
65
+ if (isStaleRelicContext()) return 'deferred-to-active-install';
66
+
58
67
  const manifest = readManifest();
59
68
  const currentVersion = getPluginVersion();
60
69
 
@@ -81,6 +90,14 @@ function syncLifecycleConfig() {
81
90
  install();
82
91
  return 'self-healed-bad-path';
83
92
  }
93
+ // v0.49.1: also self-heal when the composite path exists but is not the one
94
+ // we'd write now (old plugin-cache version dir that still exists on disk —
95
+ // invisible to the existence check above; same fault class as the binary pin).
96
+ const { compositeCommand } = require('./lifecycle');
97
+ if (settings.statusLine.command !== compositeCommand()) {
98
+ install();
99
+ return 'self-healed-stale-statusline';
100
+ }
84
101
  // Self-heal if any hook command points to a non-existent script (path pollution)
85
102
  if (settings.hooks) {
86
103
  for (const entries of Object.values(settings.hooks)) {
@@ -101,18 +118,20 @@ function syncLifecycleConfig() {
101
118
  // (e.g. user manually edited settings.json, or settings.json got rewritten
102
119
  // by another tool that didn't preserve our entries). Without this, the
103
120
  // user silently loses PreToolUse/PostToolUse hooks until next plugin update.
104
- const { isOurHookEntry, buildSettingsHookEntries } = require('./lifecycle');
105
- const desired = buildSettingsHookEntries();
106
- for (const [event, desiredEntries] of Object.entries(desired)) {
107
- const presentMatchers = new Set(
108
- (settings.hooks?.[event] || []).filter(isOurHookEntry).map(e => e.matcher || '*')
109
- );
110
- for (const e of desiredEntries) {
111
- if (!presentMatchers.has(e.matcher || '*')) {
112
- install();
113
- return 'self-healed-missing-settings-hook';
114
- }
115
- }
121
+ // v0.49.1: upgraded from matcher-presence to surveyHookCoverage so a
122
+ // present-but-stale command path (old plugin-cache version dir that still
123
+ // exists) also heals. Previously only doctor checked staleness, so if the
124
+ // auto-update re-register step failed silently, users kept running old hook
125
+ // code indefinitely — the settings.json sibling of the binary-pin bug.
126
+ const { surveyHookCoverage } = require('./lifecycle');
127
+ const cov = surveyHookCoverage(settings);
128
+ if (cov.missing.length > 0) {
129
+ install();
130
+ return 'self-healed-missing-settings-hook';
131
+ }
132
+ if (cov.stale.length > 0) {
133
+ install();
134
+ return 'self-healed-stale-settings-hook';
116
135
  }
117
136
  return 'noop';
118
137
  }
@@ -298,6 +317,11 @@ function runSessionInit() {
298
317
  }
299
318
 
300
319
  const lifecycle = syncLifecycleConfig();
320
+ // v0.49.1: a stale relic (see isStaleRelicContext) must not write ANY
321
+ // versioned state — that includes the adoption template: maybeAutoAdopt's
322
+ // drift-refresh would "refresh" MEMORY.md back to the relic's OLD shipped
323
+ // template, the adoption-surface twin of the settings.json downgrade war.
324
+ const isRelic = lifecycle === 'deferred-to-active-install';
301
325
 
302
326
  // Verify binary availability — catch issues early with actionable diagnostics
303
327
  const binaryCheck = verifyBinary();
@@ -308,7 +332,7 @@ function runSessionInit() {
308
332
  // v0.9.0 C' 上下文感知默认:插件模式下首次 SessionStart 自动 adopt。
309
333
  // v0.11.0: 已 adopt 的项目如果 shipped template 漂移也会触发一次刷新。
310
334
  // 两种情况都发一次 stderr 提示,让用户知道发生了什么 + 如何回退。
311
- const autoAdopt = maybeAutoAdopt({ scriptPath: __dirname });
335
+ const autoAdopt = isRelic ? { attempted: false, result: null } : maybeAutoAdopt({ scriptPath: __dirname });
312
336
  if (autoAdopt.attempted && autoAdopt.result && autoAdopt.result.ok) {
313
337
  if (autoAdopt.reason === 'refreshed') {
314
338
  process.stderr.write(
@@ -42,6 +42,9 @@ type: reference
42
42
  > ToolSearch 加载),而 Bash 永远在线——真实编程夜(2026-06-12)观测到的全部
43
43
  > 转化都是 CLI 调用。结构化查询的最快路径是 Bash 直呼
44
44
  > `code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`。
45
+ > `grep` 是 drop-in 替代:`-F` 字面 / `-i` / `-w` / `-l` / `-A/-B/-C N` 上下文 /
46
+ > 多路径 / `--max-count 0`,退出码兼容 grep(0/1/2),召回达 git-grep 级
47
+ > (tracked-but-gitignored 也能搜到),每条命中标注所属 fn/class。
45
48
  >
46
49
  > v0.10.0 起:tools/list 默认只暴露 7 个核心工具;下表"进阶 5"中的工具
47
50
  > 已从 tools/list 隐藏以节省 session 启动 tokens。**Claude Code 里请走 CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
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": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.49.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.49.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.49.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.49.0",
42
- "@sdsrs/code-graph-win32-x64": "0.49.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.50.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.50.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.50.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.50.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.50.0"
43
43
  }
44
44
  }