@sdsrs/code-graph 0.31.0 → 0.32.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.31.0",
7
+ "version": "0.32.3",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -1,6 +1,6 @@
1
1
  {
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.3 session-init.js actively removes any legacy code-graph entries it finds there. Paths use ${CLAUDE_PLUGIN_ROOT} so they follow version directory updates automatically.",
2
+ "description": "code-graph-mcp hooks — only SessionStart is loaded by Claude Code from plugin-cache. All other event types are registered by lifecycle.js into ~/.claude/settings.json (v0.32.0+).",
3
+ "_note": "Empirical 2026-05-24: current Claude Code only loads SessionStart entries from plugin-cache hooks.json. PreToolUse / PostToolUse / UserPromptSubmit / Stop / SessionEnd entries here are SILENTLY IGNORED. lifecycle.js install/update writes those to ~/.claude/settings.json instead (pattern from claude-mem-lite). Re-adding non-SessionStart entries here would NOT make them fire they would just be dead config. See feedback_pretooluse_dark_under_green_health.md for the jsonl evidence chain.",
4
4
  "hooks": {
5
5
  "SessionStart": [
6
6
  {
@@ -13,62 +13,6 @@
13
13
  }
14
14
  ]
15
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
- "matcher": "tool == \"Bash\"",
30
- "hooks": [
31
- {
32
- "type": "command",
33
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-grep-guide.js\"",
34
- "timeout": 3
35
- }
36
- ]
37
- },
38
- {
39
- "matcher": "tool == \"Read\"",
40
- "hooks": [
41
- {
42
- "type": "command",
43
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-read-guide.js\"",
44
- "timeout": 3
45
- }
46
- ]
47
- }
48
- ],
49
- "PostToolUse": [
50
- {
51
- "matcher": "tool == \"Write\" || tool == \"Edit\"",
52
- "hooks": [
53
- {
54
- "type": "command",
55
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/incremental-index.js\"",
56
- "timeout": 10
57
- }
58
- ]
59
- }
60
- ],
61
- "UserPromptSubmit": [
62
- {
63
- "matcher": "",
64
- "hooks": [
65
- {
66
- "type": "command",
67
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-context.js\"",
68
- "timeout": 5
69
- }
70
- ]
71
- }
72
16
  ]
73
17
  }
74
18
  }
@@ -8,6 +8,7 @@ const os = require('os');
8
8
  const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
9
9
  const { clearCache: clearBinaryCache } = require('./find-binary');
10
10
  const { readBinaryVersion, isDevMode } = require('./version-utils');
11
+ const { cgTmpDir } = require('./tmp-dir');
11
12
 
12
13
  // ── Environment Checks ────────────────────────────────────
13
14
 
@@ -251,7 +252,7 @@ async function downloadAndInstall(latest) {
251
252
  return { pluginUpdated: false, binaryUpdated: false };
252
253
  }
253
254
 
254
- const tmpDir = path.join(os.tmpdir(), `code-graph-update-${Date.now()}`);
255
+ const tmpDir = path.join(cgTmpDir(), `update-${Date.now()}`);
255
256
  let pluginUpdated = false;
256
257
  let binaryUpdated = false;
257
258
 
@@ -7,8 +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
- removeHooksFromSettings, isOurHookEntry, writeJsonAtomic,
11
- settingsPath,
10
+ isOurHookEntry, settingsPath, buildSettingsHookEntries,
12
11
  } = require('./lifecycle');
13
12
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
14
13
 
@@ -173,34 +172,52 @@ function runDiagnostics() {
173
172
  }
174
173
 
175
174
  // 6. Hook paths validity
175
+ // healthCheck() auto-attempts install() when broken paths are detected and
176
+ // re-scans to verify; repaired:true is now contingent on the re-scan
177
+ // returning clean. If repaired:false despite install() running, the
178
+ // re-scan still found broken paths — surfacing 'remaining' makes that
179
+ // honest instead of telling the user we fixed nothing.
176
180
  const hookResult = healthCheck();
177
181
  if (hookResult.healthy) {
178
182
  results.push({ name: 'Hooks', status: 'ok', detail: 'all paths valid' });
183
+ } else if (hookResult.repaired) {
184
+ results.push({
185
+ name: 'Hooks',
186
+ status: 'ok',
187
+ detail: `${hookResult.issues.length} issue(s) auto-repaired`,
188
+ });
179
189
  } else {
190
+ const remainingCount = Array.isArray(hookResult.remaining)
191
+ ? hookResult.remaining.length
192
+ : hookResult.issues.length;
180
193
  results.push({
181
194
  name: 'Hooks',
182
- status: hookResult.repaired ? 'ok' : 'warn',
183
- detail: hookResult.repaired
184
- ? `${hookResult.issues.length} issue(s) auto-repaired`
185
- : `${hookResult.issues.length} invalid path(s)`,
186
- fixId: hookResult.repaired ? undefined : 'hooks-invalid',
195
+ status: 'warn',
196
+ detail: `${remainingCount} invalid path(s) — auto-repair did not resolve`,
197
+ fixId: 'hooks-invalid',
187
198
  });
188
199
  }
189
200
 
190
- // 7. Legacy hooks in settings.json — v0.8.2 and earlier wrote hooks there;
191
- // cache/<ver>/hooks/hooks.json is now authoritative. Duplicates cause
192
- // every hook to fire twice until settings.json is cleaned.
201
+ // 7. settings.json hook coverage — v0.32.0 inversion. Current Claude Code
202
+ // silently ignores plugin-cache hooks.json for PreToolUse/PostToolUse/
203
+ // UserPromptSubmit. lifecycle.js install/update is responsible for
204
+ // registering them in settings.json. "Missing" is the bug (previously
205
+ // "present" was treated as legacy debris — that was wrong).
193
206
  try {
194
207
  const settings = readJson(settingsPath()) || {};
195
- const legacyCount = countLegacyHookEntries(settings);
196
- if (legacyCount === 0) {
197
- results.push({ name: 'Legacy hooks', status: 'ok', detail: 'settings.json is clean' });
208
+ const cov = surveyHookCoverage(settings);
209
+ if (cov.missing.length === 0) {
210
+ results.push({
211
+ name: 'Hook coverage',
212
+ status: 'ok',
213
+ detail: `settings.json has all ${cov.expected.length} expected entries`,
214
+ });
198
215
  } else {
199
216
  results.push({
200
- name: 'Legacy hooks',
217
+ name: 'Hook coverage',
201
218
  status: 'warn',
202
- detail: `${legacyCount} entries in settings.json (fire twice per event)`,
203
- fixId: 'legacy-hooks-in-settings',
219
+ detail: `missing ${cov.missing.length}/${cov.expected.length} settings.json entries: ${cov.missing.join(', ')}`,
220
+ fixId: 'missing-hooks-in-settings',
204
221
  });
205
222
  }
206
223
  } catch { /* probe failed — skip */ }
@@ -208,16 +225,31 @@ function runDiagnostics() {
208
225
  return results;
209
226
  }
210
227
 
211
- function countLegacyHookEntries(settings) {
212
- if (!settings || !settings.hooks) return 0;
213
- let count = 0;
214
- for (const entries of Object.values(settings.hooks)) {
215
- if (!Array.isArray(entries)) continue;
216
- for (const entry of entries) {
217
- if (isOurHookEntry(entry)) count++;
228
+ // Inventory of (event, matcher) tuples we expect to find in settings.json after
229
+ // install. Used by doctor to detect missing entries.
230
+ function surveyHookCoverage(settings) {
231
+ const desired = buildSettingsHookEntries();
232
+ const expected = [];
233
+ for (const [event, entries] of Object.entries(desired)) {
234
+ for (const e of entries) {
235
+ expected.push(`${event}:${e.matcher || '*'}`);
236
+ }
237
+ }
238
+
239
+ const present = new Set();
240
+ if (settings && settings.hooks) {
241
+ for (const [event, entries] of Object.entries(settings.hooks)) {
242
+ if (!Array.isArray(entries)) continue;
243
+ for (const entry of entries) {
244
+ if (isOurHookEntry(entry)) {
245
+ present.add(`${event}:${entry.matcher || '*'}`);
246
+ }
247
+ }
218
248
  }
219
249
  }
220
- return count;
250
+
251
+ const missing = expected.filter(k => !present.has(k));
252
+ return { expected, present: [...present], missing };
221
253
  }
222
254
 
223
255
  // ── Report Formatting ─────────────────────────────────────
@@ -370,21 +402,20 @@ function runRepairs(results) {
370
402
  console.log('\n Repairing hooks...');
371
403
  const { install } = require('./lifecycle');
372
404
  install();
373
- console.log(' \u2705 Hooks repaired');
405
+ console.log(' \u2705 Hooks repaired \u2014 restart Claude Code to apply');
374
406
  fixed++;
375
407
  break;
376
408
  }
377
409
 
378
- case 'legacy-hooks-in-settings': {
379
- console.log('\n Removing legacy code-graph hooks from settings.json...');
380
- const settingsFile = settingsPath();
381
- const settings = readJson(settingsFile) || {};
382
- if (removeHooksFromSettings(settings)) {
383
- writeJsonAtomic(settingsFile, settings);
384
- console.log(' \u2705 settings.json cleaned — restart Claude Code to apply');
410
+ case 'missing-hooks-in-settings': {
411
+ console.log('\n Registering code-graph hooks in settings.json...');
412
+ const { install } = require('./lifecycle');
413
+ const r = install();
414
+ if (r.hooksRegistered) {
415
+ console.log(' \u2705 settings.json updated — restart Claude Code to apply');
385
416
  fixed++;
386
417
  } else {
387
- console.log(' \u2796 No legacy entries found');
418
+ console.log(' \u2796 install reported no change (settings already had entries)');
388
419
  }
389
420
  break;
390
421
  }
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ // Regression gate for v0.31.1: hooks.json matchers must be Claude Code's
8
+ // literal/regex form, NOT the expression DSL `tool == "X"`. The earlier
9
+ // matchers parsed as regex against tool names, never matched anything,
10
+ // and left every PreToolUse hook silently inert from v0.25.0 through
11
+ // v0.31.0. The bug was invisible to the existing unit tests because they
12
+ // spawn the hook scripts directly via stdin, bypassing Claude Code's
13
+ // matcher dispatch.
14
+
15
+ const HOOKS_JSON = path.resolve(__dirname, '..', 'hooks', 'hooks.json');
16
+
17
+ function loadHooks() {
18
+ const raw = fs.readFileSync(HOOKS_JSON, 'utf8');
19
+ return JSON.parse(raw);
20
+ }
21
+
22
+ function* iterMatchers(hooksByEvent) {
23
+ for (const [event, entries] of Object.entries(hooksByEvent || {})) {
24
+ if (!Array.isArray(entries)) continue;
25
+ for (let i = 0; i < entries.length; i++) {
26
+ const e = entries[i];
27
+ yield { event, idx: i, matcher: e && e.matcher };
28
+ }
29
+ }
30
+ }
31
+
32
+ test('hooks.json: file parses as JSON', () => {
33
+ assert.doesNotThrow(loadHooks);
34
+ });
35
+
36
+ test('hooks.json: every entry has a string matcher', () => {
37
+ const cfg = loadHooks();
38
+ let count = 0;
39
+ for (const { event, idx, matcher } of iterMatchers(cfg.hooks)) {
40
+ assert.equal(typeof matcher, 'string',
41
+ `hooks.${event}[${idx}].matcher should be a string, got ${typeof matcher}`);
42
+ count++;
43
+ }
44
+ assert.ok(count > 0, 'expected at least one matcher in hooks.json');
45
+ });
46
+
47
+ // The actual regression gate. Each banned token reflects a specific
48
+ // failure mode we hit and want to keep out forever.
49
+ const BANNED_TOKENS = [
50
+ // The original v0.25.0 → v0.31.0 bug: expression-style matcher treated
51
+ // as regex against tool name → never matched.
52
+ { token: '==', why: 'expression DSL (e.g. `tool == "Edit"`) is not supported; use literal tool name' },
53
+ // `tool ==` or `tool name == "X"` — same family, different spelling.
54
+ { token: 'tool ', why: 'expression DSL with `tool` variable is not supported' },
55
+ // Boolean ORs as expression operators (regex uses `|`, not `||`).
56
+ { token: '||', why: 'use `|` for pipe-list (e.g. `Write|Edit`), not `||`' },
57
+ // Boolean AND has no meaning in tool-name matching.
58
+ { token: '&&', why: '`&&` has no meaning in matchers' },
59
+ // Double-quotes inside the matcher are a strong hint of expression DSL
60
+ // (the broken syntax was `"tool == \"Edit\""`).
61
+ { token: '"', why: 'literal double-quote in matcher is almost always a copy-paste of expression DSL' },
62
+ ];
63
+
64
+ test('hooks.json: matchers avoid banned expression-DSL tokens', () => {
65
+ const cfg = loadHooks();
66
+ const offenders = [];
67
+ for (const { event, idx, matcher } of iterMatchers(cfg.hooks)) {
68
+ for (const { token, why } of BANNED_TOKENS) {
69
+ if (matcher.includes(token)) {
70
+ offenders.push(`hooks.${event}[${idx}].matcher = ${JSON.stringify(matcher)} — contains banned ${JSON.stringify(token)} (${why})`);
71
+ }
72
+ }
73
+ }
74
+ assert.deepEqual(offenders, [],
75
+ 'hooks.json matcher syntax regression — see v0.31.1 CHANGELOG:\n ' + offenders.join('\n '));
76
+ });
77
+
78
+ // v0.32.0 architecture: plugin-cache hooks.json ONLY carries SessionStart.
79
+ // PreToolUse / PostToolUse / UserPromptSubmit are registered into
80
+ // ~/.claude/settings.json by lifecycle.js (current Claude Code silently
81
+ // ignores plugin-cache hooks.json entries for those events — confirmed
82
+ // 2026-05-24 via session jsonl, see feedback_pretooluse_dark_under_green_health.md).
83
+ test('hooks.json: contains SessionStart only (v0.32.0)', () => {
84
+ const cfg = loadHooks();
85
+ assert.deepEqual(Object.keys(cfg.hooks || {}), ['SessionStart'],
86
+ 'plugin-cache hooks.json must contain only SessionStart; other events go via settings.json. ' +
87
+ 'Adding entries here for PreToolUse/PostToolUse/UserPromptSubmit would be dead config — CC does not load them.');
88
+ });
89
+
90
+ test('hooks.json: SessionStart wires session-init.js', () => {
91
+ const cfg = loadHooks();
92
+ const entries = (cfg.hooks && cfg.hooks.SessionStart) || [];
93
+ assert.ok(entries.length > 0, 'SessionStart entry missing');
94
+ const cmd = entries[0].hooks && entries[0].hooks[0] && entries[0].hooks[0].command;
95
+ assert.match(cmd || '', /session-init\.js/);
96
+ });
97
+
98
+ // Cross-validate that lifecycle.js's buildSettingsHookEntries covers the
99
+ // matchers we removed from hooks.json — keeps the migration whole. If a
100
+ // future refactor accidentally drops a matcher in one place, this fails.
101
+ test('lifecycle.buildSettingsHookEntries covers PreToolUse Edit/Bash/Read', () => {
102
+ const { buildSettingsHookEntries } = require('./lifecycle');
103
+ const desired = buildSettingsHookEntries();
104
+ const ptu = (desired.PreToolUse || []).map(e => e.matcher);
105
+ for (const tool of ['Edit', 'Bash', 'Read']) {
106
+ assert.ok(ptu.includes(tool), `lifecycle.js PreToolUse missing matcher: ${tool}; got ${JSON.stringify(ptu)}`);
107
+ }
108
+ });
109
+
110
+ test('lifecycle.buildSettingsHookEntries covers PostToolUse Write|Edit + UserPromptSubmit', () => {
111
+ const { buildSettingsHookEntries } = require('./lifecycle');
112
+ const desired = buildSettingsHookEntries();
113
+ const postMatchers = (desired.PostToolUse || []).map(e => e.matcher);
114
+ assert.ok(postMatchers.some(m => m === 'Write|Edit'),
115
+ `PostToolUse must have 'Write|Edit' matcher; got ${JSON.stringify(postMatchers)}`);
116
+ const upsMatchers = (desired.UserPromptSubmit || []).map(e => e.matcher);
117
+ assert.ok(upsMatchers.length > 0, 'UserPromptSubmit must have at least one matcher');
118
+ });
119
+
120
+ test('lifecycle.buildSettingsHookEntries: every entry carries description marker', () => {
121
+ // Description marker is the primary cleanup discriminator (immune to
122
+ // path/env pollution per feedback_plugin_env_isolation.md). If an entry
123
+ // lacks a description, isOurHookEntry falls back to path-fragment match
124
+ // which is less reliable. Force every entry to have one.
125
+ const { buildSettingsHookEntries } = require('./lifecycle');
126
+ const desired = buildSettingsHookEntries();
127
+ for (const [event, entries] of Object.entries(desired)) {
128
+ for (let i = 0; i < entries.length; i++) {
129
+ assert.ok(entries[i].description && entries[i].description.includes('[code-graph-mcp'),
130
+ `${event}[${i}] missing or malformed description marker`);
131
+ }
132
+ }
133
+ });
134
+
135
+ test('lifecycle.buildSettingsHookEntries: hook commands use absolute paths (no env vars)', () => {
136
+ // settings.json hook commands run with env pollution risk
137
+ // (feedback_plugin_env_isolation.md). Paths MUST be absolute, derived
138
+ // from __dirname, never from ${CLAUDE_PLUGIN_ROOT}.
139
+ const { buildSettingsHookEntries } = require('./lifecycle');
140
+ const desired = buildSettingsHookEntries();
141
+ for (const entries of Object.values(desired)) {
142
+ for (const e of entries) {
143
+ for (const h of e.hooks) {
144
+ assert.ok(!h.command.includes('${CLAUDE_PLUGIN_ROOT}'),
145
+ `command must not use \${CLAUDE_PLUGIN_ROOT}: ${h.command}`);
146
+ assert.ok(h.command.startsWith('node "/') || h.command.match(/node "[A-Z]:\\/),
147
+ `command path must be absolute: ${h.command}`);
148
+ }
149
+ }
150
+ }
151
+ });
@@ -245,28 +245,63 @@ function migrateOldPluginIds(settings) {
245
245
  }
246
246
 
247
247
  // --- Hook identity ---
248
- // Claude Code loads hooks from cache/<mp>/<plugin>/<ver>/hooks/hooks.json —
249
- // that file is the authoritative source. Any entries matching our hooks
250
- // inside settings.json are legacy migration debris (v0.8.2 and earlier wrote
251
- // there) and must be stripped on every install/update/session-init so events
252
- // 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
+ };
253
281
 
254
- const OUR_HOOK_SCRIPTS = ['session-init.js', 'incremental-index.js', 'user-prompt-context.js', 'pre-edit-guide.js'];
255
282
  const OUR_DESCRIPTIONS = [
283
+ // Legacy v0.7.x / 0.8.x descriptions — kept so very-old installs still get cleaned up.
256
284
  'StatusLine self-heal, lifecycle sync, project map injection',
257
285
  'Auto-inject impact analysis when editing functions with 2+ callers',
258
286
  'Auto-update code graph index after file edits',
259
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,
260
292
  ];
261
293
 
262
294
  function isOurHookEntry(entry) {
263
295
  if (!entry || !entry.hooks) return false;
264
- // Primary: match by description (legacy v0.7.x/0.8.x registrations).
296
+ // Primary: match by description (immune to path pollution).
265
297
  if (entry.description && OUR_DESCRIPTIONS.includes(entry.description)) return true;
266
- // 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.
267
302
  return entry.hooks.some(h =>
268
303
  h.command && OUR_HOOK_SCRIPTS.some(s => h.command.includes(s)) &&
269
- h.command.includes('code-graph')
304
+ h.command.includes(MARKETPLACE_NAME)
270
305
  );
271
306
  }
272
307
 
@@ -286,6 +321,62 @@ function removeHooksFromSettings(settings) {
286
321
  return changed;
287
322
  }
288
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
+
289
380
  // --- Install (idempotent) ---
290
381
 
291
382
  function install() {
@@ -324,11 +415,12 @@ function install() {
324
415
  // Register code-graph provider
325
416
  registerStatuslineProvider('code-graph', codeGraphStatuslineCommand(), false);
326
417
 
327
- // 2. Hooks — cache/<ver>/hooks/hooks.json is authoritative. Strip any legacy
328
- // entries from settings.json that v0.8.2 or earlier registered, so events
329
- // don't fire twice.
330
- const legacyHooksRemoved = removeHooksFromSettings(settings);
331
- 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;
332
424
 
333
425
  // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle.
334
426
  // Do NOT add enabledPlugins entries here — it causes phantom plugin entries
@@ -345,7 +437,7 @@ function install() {
345
437
  manifest.updatedAt = new Date().toISOString();
346
438
  writeManifest(manifest);
347
439
 
348
- return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, legacyHooksRemoved };
440
+ return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, hooksRegistered };
349
441
  }
350
442
 
351
443
  // --- Uninstall (clean all config) ---
@@ -437,10 +529,10 @@ function update() {
437
529
  // 2. Update code-graph provider in registry
438
530
  registerStatuslineProvider('code-graph', codeGraphStatuslineCommand(), false);
439
531
 
440
- // 3. Hooks — strip any legacy entries from settings.json. cache hooks.json
441
- // is the new authoritative source and always has the up-to-date paths.
442
- const legacyHooksRemoved = removeHooksFromSettings(settings);
443
- 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;
444
536
 
445
537
  // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle.
446
538
 
@@ -463,7 +555,7 @@ function update() {
463
555
  // cache dirs are inert disk clutter, not correctness risks.
464
556
  cleanupOldCacheVersions(3);
465
557
 
466
- return { oldVersion, version, settingsChanged, legacyHooksRemoved };
558
+ return { oldVersion, version, settingsChanged, hooksRegistered };
467
559
  }
468
560
 
469
561
  /**
@@ -503,9 +595,15 @@ function cleanupOldCacheVersions(keep = 3) {
503
595
 
504
596
  // --- Health Check ---
505
597
  // Validates all registered paths in settings.json point to existing scripts.
506
- // Returns { healthy, issues, repaired }.
507
-
508
- function healthCheck() {
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() {
509
607
  const settings = readJson(settingsPath()) || {};
510
608
  const issues = [];
511
609
 
@@ -543,23 +641,40 @@ function healthCheck() {
543
641
  }
544
642
  }
545
643
 
546
- // Auto-repair if issues found
547
- let repaired = false;
548
- if (issues.length > 0) {
549
- install();
550
- 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 };
551
652
  }
552
653
 
553
- 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
+ };
554
666
  }
555
667
 
556
668
  module.exports = {
557
- install, uninstall, update, healthCheck, checkScopeConflict,
669
+ install, uninstall, update, healthCheck, scanForBrokenPaths, checkScopeConflict,
558
670
  isPluginExplicitlyDisabled, isPluginInactive, cleanupDisabledStatusline,
559
671
  readManifest, readJson, writeJsonAtomic,
560
672
  readRegistry, writeRegistry,
561
673
  getPluginVersion, cleanupOldCacheVersions,
562
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
563
678
  registerStatuslineProvider, unregisterStatuslineProvider,
564
679
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
565
680
  settingsPath, installedPluginsPath, providersBackupFile, pluginsCacheDir,