@sdsrs/code-graph 0.30.0 → 0.32.2

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.
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const { claudeHome } = require('./claude-config');
6
7
 
7
8
  const PLUGIN_ID = 'code-graph-mcp@code-graph-mcp';
8
9
  const OLD_PLUGIN_IDS = [
@@ -16,13 +17,18 @@ const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
16
17
  // to its own marketplace path, polluting all subsequent settings.json hook processes).
17
18
  const PLUGIN_ROOT = path.resolve(__dirname, '..');
18
19
  const MANIFEST_FILE = path.join(CACHE_DIR, 'install-manifest.json');
19
- const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
20
- const INSTALLED_PLUGINS_PATH = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
21
20
  const REGISTRY_FILE = path.join(CACHE_DIR, 'statusline-registry.json');
21
+
22
+ // Lazy resolvers — Claude Code's config dir can be overridden by CLAUDE_CONFIG_DIR
23
+ // (multi-account isolation). Re-read every call so test subprocesses with a
24
+ // different env see the right path.
25
+ function settingsPath() { return path.join(claudeHome(), 'settings.json'); }
26
+ function installedPluginsPath() { return path.join(claudeHome(), 'plugins', 'installed_plugins.json'); }
22
27
  // Durable mirror outside ~/.cache/ — survives cache cleanup. Captures the
23
28
  // `_previous` snapshot (pre-install statusline) and any third-party providers
24
29
  // (GSD, etc.). readRegistry() self-heals from this file when primary is missing.
25
- const PROVIDERS_BACKUP_FILE = path.join(os.homedir(), '.claude', 'statusline-providers.json');
30
+ function providersBackupFile() { return path.join(claudeHome(), 'statusline-providers.json'); }
31
+ function pluginsCacheDir() { return path.join(claudeHome(), 'plugins', 'cache'); }
26
32
 
27
33
  // --- Helpers ---
28
34
 
@@ -65,7 +71,7 @@ function hasOwn(obj, key) {
65
71
  }
66
72
 
67
73
  function hasInstalledPluginRecord() {
68
- const installed = readJson(INSTALLED_PLUGINS_PATH);
74
+ const installed = readJson(installedPluginsPath());
69
75
  return !!(installed && installed.plugins && Array.isArray(installed.plugins[PLUGIN_ID]) && installed.plugins[PLUGIN_ID].length > 0);
70
76
  }
71
77
 
@@ -83,7 +89,7 @@ function readRegistry() {
83
89
  if (primary && Array.isArray(primary) && primary.length > 0) return primary;
84
90
  // Self-heal: primary missing or empty (e.g. user cleaned ~/.cache/code-graph/).
85
91
  // Durable backup in ~/.claude/ retains `_previous` + third-party providers.
86
- const backup = readJson(PROVIDERS_BACKUP_FILE);
92
+ const backup = readJson(providersBackupFile());
87
93
  if (backup && Array.isArray(backup) && backup.length > 0) {
88
94
  try { writeJsonAtomic(REGISTRY_FILE, backup); } catch { /* ok */ }
89
95
  return backup;
@@ -94,13 +100,13 @@ function readRegistry() {
94
100
  function writeRegistry(registry) {
95
101
  if (!registry || registry.length === 0) {
96
102
  try { fs.unlinkSync(REGISTRY_FILE); } catch { /* ok */ }
97
- try { fs.unlinkSync(PROVIDERS_BACKUP_FILE); } catch { /* ok */ }
103
+ try { fs.unlinkSync(providersBackupFile()); } catch { /* ok */ }
98
104
  return;
99
105
  }
100
106
  writeJsonAtomic(REGISTRY_FILE, registry);
101
107
  // Mirror to durable location so cache cleanup doesn't strand `_previous`
102
108
  // or third-party provider entries.
103
- try { writeJsonAtomic(PROVIDERS_BACKUP_FILE, registry); } catch { /* ok */ }
109
+ try { writeJsonAtomic(providersBackupFile(), registry); } catch { /* ok */ }
104
110
  }
105
111
 
106
112
  function registerStatuslineProvider(id, command, needsStdin) {
@@ -126,18 +132,18 @@ function unregisterStatuslineProvider(id) {
126
132
  return true;
127
133
  }
128
134
 
129
- function isPluginExplicitlyDisabled(settings = readJson(SETTINGS_PATH) || {}) {
135
+ function isPluginExplicitlyDisabled(settings = readJson(settingsPath()) || {}) {
130
136
  return hasOwn(settings.enabledPlugins, PLUGIN_ID) && settings.enabledPlugins[PLUGIN_ID] === false;
131
137
  }
132
138
 
133
- function isPluginInactive(settings = readJson(SETTINGS_PATH) || {}) {
139
+ function isPluginInactive(settings = readJson(settingsPath()) || {}) {
134
140
  if (isPluginExplicitlyDisabled(settings)) return true;
135
141
 
136
142
  const hasComposite = isOurComposite(settings);
137
143
  const hasCodeGraphRegistry = readRegistry().some((provider) => provider.id === 'code-graph');
138
144
  if (!hasComposite && !hasCodeGraphRegistry) return false;
139
145
 
140
- const installed = readJson(INSTALLED_PLUGINS_PATH);
146
+ const installed = readJson(installedPluginsPath());
141
147
  if (!installed || !installed.plugins) return false;
142
148
  return !hasInstalledPluginRecord();
143
149
  }
@@ -165,7 +171,7 @@ function detachStatuslineIntegration(settings) {
165
171
  }
166
172
 
167
173
  function cleanupDisabledStatusline() {
168
- const settings = readJson(SETTINGS_PATH);
174
+ const settings = readJson(settingsPath());
169
175
  if (!settings || !isPluginInactive(settings)) {
170
176
  return { cleaned: false, settingsChanged: false };
171
177
  }
@@ -173,7 +179,7 @@ function cleanupDisabledStatusline() {
173
179
  let settingsChanged = detachStatuslineIntegration(settings);
174
180
  if (removeHooksFromSettings(settings)) settingsChanged = true;
175
181
  if (settingsChanged) {
176
- writeJsonAtomic(SETTINGS_PATH, settings);
182
+ writeJsonAtomic(settingsPath(), settings);
177
183
  }
178
184
 
179
185
  return { cleaned: true, settingsChanged };
@@ -182,7 +188,7 @@ function cleanupDisabledStatusline() {
182
188
  // --- Scope Conflict Detection ---
183
189
 
184
190
  function checkScopeConflict() {
185
- const installed = readJson(INSTALLED_PLUGINS_PATH);
191
+ const installed = readJson(installedPluginsPath());
186
192
  if (!installed || !installed.plugins) return null;
187
193
  for (const [key, entries] of Object.entries(installed.plugins)) {
188
194
  if (key === PLUGIN_ID) continue;
@@ -207,10 +213,10 @@ function migrateOldPluginIds(settings) {
207
213
  }
208
214
 
209
215
  // Clean old ID from installed_plugins.json
210
- const installed = readJson(INSTALLED_PLUGINS_PATH);
216
+ const installed = readJson(installedPluginsPath());
211
217
  if (installed && installed.plugins && oldId in installed.plugins) {
212
218
  delete installed.plugins[oldId];
213
- writeJsonAtomic(INSTALLED_PLUGINS_PATH, installed);
219
+ writeJsonAtomic(installedPluginsPath(), installed);
214
220
  }
215
221
  }
216
222
 
@@ -225,10 +231,11 @@ function migrateOldPluginIds(settings) {
225
231
  }
226
232
 
227
233
  // Clean old cache paths
234
+ const cacheRoot = pluginsCacheDir();
228
235
  const oldCacheDirs = [
229
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'code-graph'),
230
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss-code-graph', 'code-graph'),
231
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss-code-graph'),
236
+ path.join(cacheRoot, 'sdsrss', 'code-graph'),
237
+ path.join(cacheRoot, 'sdsrss-code-graph', 'code-graph'),
238
+ path.join(cacheRoot, 'sdsrss-code-graph'),
232
239
  ];
233
240
  for (const dir of oldCacheDirs) {
234
241
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
@@ -238,28 +245,63 @@ function migrateOldPluginIds(settings) {
238
245
  }
239
246
 
240
247
  // --- Hook identity ---
241
- // Claude Code loads hooks from cache/<mp>/<plugin>/<ver>/hooks/hooks.json —
242
- // that file is the authoritative source. Any entries matching our hooks
243
- // inside settings.json are legacy migration debris (v0.8.2 and earlier wrote
244
- // there) and must be stripped on every install/update/session-init so events
245
- // don't fire twice.
248
+ //
249
+ // v0.32.0 ARCHITECTURE CORRECTION (see project_hooks_settings.md / feedback_pretooluse_dark_under_green_health.md):
250
+ //
251
+ // Empirical finding 2026-05-24: current Claude Code only loads SessionStart
252
+ // hooks from cache/<mp>/<plugin>/<ver>/hooks/hooks.json. PreToolUse, PostToolUse,
253
+ // UserPromptSubmit, Stop, SessionEnd entries in plugin-cache hooks.json are
254
+ // SILENTLY IGNORED. Only ~/.claude/settings.json entries reach CC for those events.
255
+ //
256
+ // Therefore lifecycle.js now ACTIVELY WRITES non-SessionStart hook entries to
257
+ // settings.json (with description markers for cleanup), and the shipped
258
+ // claude-plugin/hooks/hooks.json carries only SessionStart. SessionStart entries
259
+ // in claude-plugin/hooks/hooks.json continue to be CC-loaded as before.
260
+ //
261
+ // Pattern mirrors claude-mem-lite's install.mjs (cache hooks.json cleared
262
+ // to prevent duplicate registration).
263
+
264
+ const OUR_HOOK_SCRIPTS = [
265
+ 'session-init.js',
266
+ 'incremental-index.js',
267
+ 'user-prompt-context.js',
268
+ 'pre-edit-guide.js',
269
+ 'pre-grep-guide.js', // v0.32.0 — was in plugin-cache only, never fired
270
+ 'pre-read-guide.js', // v0.32.0 — was in plugin-cache only, never fired
271
+ ];
272
+
273
+ // Description markers — primary cleanup discriminator (immune to env/path
274
+ // pollution per feedback_plugin_env_isolation.md). New v0.32.0 markers carry
275
+ // the version so older lifecycle.js still recognizes them as ours.
276
+ const SETTINGS_HOOK_DESC = {
277
+ preToolUse: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)',
278
+ postToolUseEdit: '[code-graph-mcp v0.32+] PostToolUse Write|Edit incremental-index update',
279
+ userPromptSubmit: '[code-graph-mcp v0.32+] UserPromptSubmit context push',
280
+ };
246
281
 
247
- const OUR_HOOK_SCRIPTS = ['session-init.js', 'incremental-index.js', 'user-prompt-context.js', 'pre-edit-guide.js'];
248
282
  const OUR_DESCRIPTIONS = [
283
+ // Legacy v0.7.x / 0.8.x descriptions — kept so very-old installs still get cleaned up.
249
284
  'StatusLine self-heal, lifecycle sync, project map injection',
250
285
  'Auto-inject impact analysis when editing functions with 2+ callers',
251
286
  'Auto-update code graph index after file edits',
252
287
  'Inject code-graph structural context based on user intent',
288
+ // v0.32.0 — new re-route markers
289
+ SETTINGS_HOOK_DESC.preToolUse,
290
+ SETTINGS_HOOK_DESC.postToolUseEdit,
291
+ SETTINGS_HOOK_DESC.userPromptSubmit,
253
292
  ];
254
293
 
255
294
  function isOurHookEntry(entry) {
256
295
  if (!entry || !entry.hooks) return false;
257
- // Primary: match by description (legacy v0.7.x/0.8.x registrations).
296
+ // Primary: match by description (immune to path pollution).
258
297
  if (entry.description && OUR_DESCRIPTIONS.includes(entry.description)) return true;
259
- // Fallback: match by script name + 'code-graph' in path.
298
+ // Fallback: script name + MARKETPLACE_NAME in path. v0.32.1: tightened from
299
+ // bare 'code-graph' (which would claim a user's own ~/code-graph/foo.js) to
300
+ // the actual marketplace dir name 'code-graph-mcp' — Requirement 3 says
301
+ // foreign-entry strip is unacceptable, so be conservative.
260
302
  return entry.hooks.some(h =>
261
303
  h.command && OUR_HOOK_SCRIPTS.some(s => h.command.includes(s)) &&
262
- h.command.includes('code-graph')
304
+ h.command.includes(MARKETPLACE_NAME)
263
305
  );
264
306
  }
265
307
 
@@ -279,12 +321,68 @@ function removeHooksFromSettings(settings) {
279
321
  return changed;
280
322
  }
281
323
 
324
+ // --- v0.32.0: settings.json hook registration ---
325
+
326
+ // PLUGIN_ROOT (module-level, line 18) is the canonical __dirname-derived
327
+ // absolute path — never CLAUDE_PLUGIN_ROOT env (env leaks across plugins
328
+ // in settings.json hook execution context per feedback_plugin_env_isolation.md).
329
+
330
+ function buildSettingsHookEntries() {
331
+ const root = PLUGIN_ROOT;
332
+ const scriptCmd = (name, timeout) => ({
333
+ type: 'command',
334
+ command: `node "${path.join(root, 'scripts', name)}"`,
335
+ timeout,
336
+ });
337
+
338
+ return {
339
+ PreToolUse: [
340
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit', hooks: [scriptCmd('pre-edit-guide.js', 4)] },
341
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Bash', hooks: [scriptCmd('pre-grep-guide.js', 3)] },
342
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Read', hooks: [scriptCmd('pre-read-guide.js', 3)] },
343
+ ],
344
+ PostToolUse: [
345
+ { description: SETTINGS_HOOK_DESC.postToolUseEdit, matcher: 'Write|Edit', hooks: [scriptCmd('incremental-index.js', 10)] },
346
+ ],
347
+ UserPromptSubmit: [
348
+ { description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js', 5)] },
349
+ ],
350
+ };
351
+ }
352
+
353
+ // Idempotent two-pass: (1) evict ALL our entries (legacy v0.7+/0.8+ markers
354
+ // AND v0.32+ markers) across EVERY event — catches legacy SessionStart/
355
+ // PostToolUse entries in settings.json pointing to stale plugin-cache paths;
356
+ // (2) write fresh v0.32+ entries for the events we own. SessionStart stays
357
+ // in plugin-cache hooks.json (it's still loaded from there), so we don't
358
+ // re-write it to settings.json.
359
+ function registerHooksToSettings(settings) {
360
+ settings.hooks = settings.hooks || {};
361
+ const before = JSON.stringify(settings.hooks);
362
+
363
+ // Pass 1: evict our entries across every event.
364
+ for (const event of Object.keys(settings.hooks)) {
365
+ if (!Array.isArray(settings.hooks[event])) continue;
366
+ settings.hooks[event] = settings.hooks[event].filter(e => !isOurHookEntry(e));
367
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
368
+ }
369
+
370
+ // Pass 2: write fresh entries for our desired events.
371
+ const desired = buildSettingsHookEntries();
372
+ for (const [event, desiredEntries] of Object.entries(desired)) {
373
+ const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
374
+ settings.hooks[event] = [...existing, ...desiredEntries];
375
+ }
376
+
377
+ return before !== JSON.stringify(settings.hooks);
378
+ }
379
+
282
380
  // --- Install (idempotent) ---
283
381
 
284
382
  function install() {
285
383
  const version = getPluginVersion();
286
384
  const manifest = readManifest();
287
- const settings = readJson(SETTINGS_PATH) || {};
385
+ const settings = readJson(settingsPath()) || {};
288
386
  let settingsChanged = false;
289
387
 
290
388
  // 0. Migrate from old plugin IDs
@@ -317,11 +415,12 @@ function install() {
317
415
  // Register code-graph provider
318
416
  registerStatuslineProvider('code-graph', codeGraphStatuslineCommand(), false);
319
417
 
320
- // 2. Hooks — cache/<ver>/hooks/hooks.json is authoritative. Strip any legacy
321
- // entries from settings.json that v0.8.2 or earlier registered, so events
322
- // don't fire twice.
323
- const legacyHooksRemoved = removeHooksFromSettings(settings);
324
- if (legacyHooksRemoved) settingsChanged = true;
418
+ // 2. Hooks — v0.32.0: actively write PreToolUse/PostToolUse/UserPromptSubmit
419
+ // to settings.json. Plugin-cache hooks.json is silently ignored by current
420
+ // Claude Code for these events (SessionStart still loads from cache).
421
+ // registerHooksToSettings is idempotent: strips priors then appends fresh.
422
+ const hooksRegistered = registerHooksToSettings(settings);
423
+ if (hooksRegistered) settingsChanged = true;
325
424
 
326
425
  // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle.
327
426
  // Do NOT add enabledPlugins entries here — it causes phantom plugin entries
@@ -329,7 +428,7 @@ function install() {
329
428
 
330
429
  // 3. Write settings atomically if changed
331
430
  if (settingsChanged) {
332
- writeJsonAtomic(SETTINGS_PATH, settings);
431
+ writeJsonAtomic(settingsPath(), settings);
333
432
  }
334
433
 
335
434
  // 4. Write manifest with version
@@ -338,13 +437,13 @@ function install() {
338
437
  manifest.updatedAt = new Date().toISOString();
339
438
  writeManifest(manifest);
340
439
 
341
- return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, legacyHooksRemoved };
440
+ return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, hooksRegistered };
342
441
  }
343
442
 
344
443
  // --- Uninstall (clean all config) ---
345
444
 
346
445
  function uninstall() {
347
- const settings = readJson(SETTINGS_PATH);
446
+ const settings = readJson(settingsPath());
348
447
  let settingsChanged = false;
349
448
 
350
449
  if (settings) {
@@ -370,12 +469,12 @@ function uninstall() {
370
469
 
371
470
  // 4. Write settings if changed
372
471
  if (settingsChanged) {
373
- writeJsonAtomic(SETTINGS_PATH, settings);
472
+ writeJsonAtomic(settingsPath(), settings);
374
473
  }
375
474
  }
376
475
 
377
476
  // 5. Remove all known IDs from installed_plugins.json
378
- const installedPlugins = readJson(INSTALLED_PLUGINS_PATH);
477
+ const installedPlugins = readJson(installedPluginsPath());
379
478
  if (installedPlugins && installedPlugins.plugins) {
380
479
  let ipChanged = false;
381
480
  for (const id of [PLUGIN_ID, ...OLD_PLUGIN_IDS]) {
@@ -384,17 +483,18 @@ function uninstall() {
384
483
  ipChanged = true;
385
484
  }
386
485
  }
387
- if (ipChanged) writeJsonAtomic(INSTALLED_PLUGINS_PATH, installedPlugins);
486
+ if (ipChanged) writeJsonAtomic(installedPluginsPath(), installedPlugins);
388
487
  }
389
488
 
390
489
  // 6. Remove cache directory
391
490
  try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); } catch { /* ok */ }
392
491
 
393
492
  // 7. Remove plugin files from cache (all known paths, including parent dirs)
493
+ const cacheRoot = pluginsCacheDir();
394
494
  const pluginCacheDirs = [
395
- path.join(os.homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_NAME),
396
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss-code-graph'),
397
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'code-graph'),
495
+ path.join(cacheRoot, MARKETPLACE_NAME),
496
+ path.join(cacheRoot, 'sdsrss-code-graph'),
497
+ path.join(cacheRoot, 'sdsrss', 'code-graph'),
398
498
  ];
399
499
  for (const dir of pluginCacheDirs) {
400
500
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
@@ -409,7 +509,7 @@ function update() {
409
509
  const version = getPluginVersion();
410
510
  const manifest = readManifest();
411
511
  const oldVersion = manifest.version;
412
- const settings = readJson(SETTINGS_PATH) || {};
512
+ const settings = readJson(settingsPath()) || {};
413
513
  let settingsChanged = false;
414
514
 
415
515
  // 0. Migrate from old plugin IDs
@@ -429,16 +529,16 @@ function update() {
429
529
  // 2. Update code-graph provider in registry
430
530
  registerStatuslineProvider('code-graph', codeGraphStatuslineCommand(), false);
431
531
 
432
- // 3. Hooks — strip any legacy entries from settings.json. cache hooks.json
433
- // is the new authoritative source and always has the up-to-date paths.
434
- const legacyHooksRemoved = removeHooksFromSettings(settings);
435
- if (legacyHooksRemoved) settingsChanged = true;
532
+ // 3. Hooks — v0.32.0: register PreToolUse/PostToolUse/UserPromptSubmit in
533
+ // settings.json (idempotent; absolute paths re-anchor on every update).
534
+ const hooksRegistered = registerHooksToSettings(settings);
535
+ if (hooksRegistered) settingsChanged = true;
436
536
 
437
537
  // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle.
438
538
 
439
539
  // 4. Write settings if changed
440
540
  if (settingsChanged) {
441
- writeJsonAtomic(SETTINGS_PATH, settings);
541
+ writeJsonAtomic(settingsPath(), settings);
442
542
  }
443
543
 
444
544
  // 5. Clear update-check cache (force re-check after update)
@@ -455,7 +555,7 @@ function update() {
455
555
  // cache dirs are inert disk clutter, not correctness risks.
456
556
  cleanupOldCacheVersions(3);
457
557
 
458
- return { oldVersion, version, settingsChanged, legacyHooksRemoved };
558
+ return { oldVersion, version, settingsChanged, hooksRegistered };
459
559
  }
460
560
 
461
561
  /**
@@ -463,7 +563,7 @@ function update() {
463
563
  * Cache layout: ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/
464
564
  */
465
565
  function cleanupOldCacheVersions(keep = 3) {
466
- const cacheParent = path.join(os.homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_NAME);
566
+ const cacheParent = path.join(pluginsCacheDir(), MARKETPLACE_NAME);
467
567
  try {
468
568
  // List all subdirectories under the marketplace cache
469
569
  const entries = fs.readdirSync(cacheParent, { withFileTypes: true });
@@ -495,10 +595,16 @@ function cleanupOldCacheVersions(keep = 3) {
495
595
 
496
596
  // --- Health Check ---
497
597
  // Validates all registered paths in settings.json point to existing scripts.
498
- // Returns { healthy, issues, repaired }.
499
-
500
- function healthCheck() {
501
- const settings = readJson(SETTINGS_PATH) || {};
598
+ // Returns { healthy, issues, repaired, remaining }.
599
+ // issues: pre-repair detection list (what was wrong on entry)
600
+ // repaired: true only after a post-repair re-scan returned zero issues
601
+ // (was previously set blindly to true whenever install() ran,
602
+ // which would lie if install() couldn't actually fix something)
603
+ // remaining: post-repair detection list — present iff install() was invoked;
604
+ // empty array means repair succeeded
605
+
606
+ function scanForBrokenPaths() {
607
+ const settings = readJson(settingsPath()) || {};
502
608
  const issues = [];
503
609
 
504
610
  // Check statusLine path
@@ -535,26 +641,43 @@ function healthCheck() {
535
641
  }
536
642
  }
537
643
 
538
- // Auto-repair if issues found
539
- let repaired = false;
540
- if (issues.length > 0) {
541
- install();
542
- repaired = true;
644
+ return issues;
645
+ }
646
+
647
+ function healthCheck() {
648
+ const issues = scanForBrokenPaths();
649
+
650
+ if (issues.length === 0) {
651
+ return { healthy: true, issues, repaired: false };
543
652
  }
544
653
 
545
- return { healthy: issues.length === 0, issues, repaired };
654
+ // Attempt auto-repair, then re-scan to confirm the issues actually went
655
+ // away. install() may legitimately fail to resolve a problem (binary path
656
+ // permanently gone, registry corrupted, etc.) and the previous code lied
657
+ // by always returning repaired:true.
658
+ install();
659
+ const remaining = scanForBrokenPaths();
660
+ return {
661
+ healthy: false,
662
+ issues,
663
+ repaired: remaining.length === 0,
664
+ remaining,
665
+ };
546
666
  }
547
667
 
548
668
  module.exports = {
549
- install, uninstall, update, healthCheck, checkScopeConflict,
669
+ install, uninstall, update, healthCheck, scanForBrokenPaths, checkScopeConflict,
550
670
  isPluginExplicitlyDisabled, isPluginInactive, cleanupDisabledStatusline,
551
671
  readManifest, readJson, writeJsonAtomic,
552
672
  readRegistry, writeRegistry,
553
673
  getPluginVersion, cleanupOldCacheVersions,
554
674
  removeHooksFromSettings, isOurHookEntry,
675
+ registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
676
+ SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
677
+ PLUGIN_ROOT, // v0.32.1 — for tests / consumers
555
678
  registerStatuslineProvider, unregisterStatuslineProvider,
556
679
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
557
- PROVIDERS_BACKUP_FILE,
680
+ settingsPath, installedPluginsPath, providersBackupFile, pluginsCacheDir,
558
681
  };
559
682
 
560
683
  // CLI: node lifecycle.js <install|uninstall|update|health>