@sdsrs/code-graph 0.163.0 → 0.164.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.
package/README.md CHANGED
@@ -231,7 +231,7 @@ and `code-graph-mcp doctor` prints the manual update command.
231
231
 
232
232
  #### Invited-memory mode (quieter prompts)
233
233
 
234
- By default, every user prompt the plugin deems code-related gets a small context injection from `code-graph` CLI output. If you'd rather rely on MEMORY.md + explicit tool calls, opt into invited-memory mode:
234
+ By default, every user prompt the plugin deems code-related gets a small context injection from `code-graph` CLI output. If you'd rather rely on explicit tool calls, opt into invited-memory mode:
235
235
 
236
236
  1. Adopt the plugin contract into your project (idempotent, self-heals):
237
237
  ```bash
@@ -244,7 +244,7 @@ By default, every user prompt the plugin deems code-related gets a small context
244
244
  "env": { "CODE_GRAPH_QUIET_HOOKS": "1" }
245
245
  }
246
246
  ```
247
- 3. Restart Claude Code. Session startup skips the project-map injection, UserPromptSubmit stops auto-injecting context, and the MCP `instructions` become a short pointer to the MEMORY.md file.
247
+ 3. Restart Claude Code. Session startup skips the project-map injection, UserPromptSubmit stops auto-injecting context, and the MCP `instructions` shrink to a one-line command pointer.
248
248
 
249
249
  ### Option 2: Claude Code MCP Server Only
250
250
 
@@ -545,14 +545,13 @@ defaults are what you get by doing nothing.
545
545
  | Variable | Effect |
546
546
  |---|---|
547
547
  | `CODE_GRAPH_NO_AUTO_UPDATE=1` | Never check GitHub for a new release. |
548
- | `CODE_GRAPH_NO_AUTO_ADOPT=1` | Do not write the steering block into a project's `CLAUDE.md` on SessionStart. |
549
- | `CODE_GRAPH_NO_TEMPLATE_REFRESH=1` | Keep hand edits to the generated steering block — it is otherwise refreshed to the current template. |
550
- | `CODE_GRAPH_NO_GITIGNORE=1` | Never touch `.gitignore` — not even to add the `.code-graph/` entry. |
548
+ | `CODE_GRAPH_NO_AUTO_ADOPT=1` | Skip SessionStart's adoption check: no cleanup of this plugin's legacy memory-dir files and no notice about an out-of-date steering block. SessionStart never writes `CLAUDE.md` itself; `code-graph-mcp adopt` does. |
549
+ | `CODE_GRAPH_NO_TEMPLATE_REFRESH=1` | Do not report a steering block that has drifted from the current template (SessionStart only reports it; `code-graph-mcp adopt` refreshes it). |
550
+ | `CODE_GRAPH_NO_GITIGNORE=1` | Do not write the `.code-graph/` ignore rule. It goes to the repository's local `.git/info/exclude`, never to the tracked `.gitignore`, and only when neither file names the directory already. |
551
551
  | `CODE_GRAPH_QUIET_HOOKS=1` | Hooks inject a one-line pointer instead of the full decision table. |
552
552
  | `CODE_GRAPH_VERBOSE_HOOKS=1` | The opposite: opt into the noisy form. |
553
553
  | `CODE_GRAPH_NO_BLOCK_GREP=1` | Never turn a `grep` hint into a block — prefix a single command with it to get past one. |
554
554
  | `CODE_GRAPH_NO_INJECT=1` | No post-tool AST context injection. |
555
- | `CODE_GRAPH_NO_RECENT_IMPACT=1` | Skip the recent-impact section of the SessionStart briefing. |
556
555
  | `CODE_GRAPH_HOOK_INDEX=on\|off` | Force the incremental-index hook on or off instead of letting it decide. |
557
556
  | `CODE_GRAPH_MODEL_DIR=<dir>` | Load the embedding model from here (air-gapped installs). |
558
557
  | `CODE_GRAPH_DISABLE_MODEL_DOWNLOAD=1` | Never fetch the model; fail instead. |
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.163.0",
7
+ "version": "0.164.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -1,26 +1,29 @@
1
1
  ---
2
2
  name: code-explorer
3
3
  description: Deep code understanding expert using AST knowledge graph. Use when exploring unfamiliar code, tracing complex relationships, or understanding module architecture.
4
- tools: ["Read", "Grep", "Glob", "Bash", "mcp__code-graph__semantic_code_search", "mcp__code-graph__get_call_graph", "mcp__code-graph__get_ast_node", "mcp__code-graph__project_map", "mcp__code-graph__module_overview", "mcp__code-graph__ast_search", "mcp__code-graph__find_references", "mcp__plugin_code-graph-mcp_code-graph__semantic_code_search", "mcp__plugin_code-graph-mcp_code-graph__get_call_graph", "mcp__plugin_code-graph-mcp_code-graph__get_ast_node", "mcp__plugin_code-graph-mcp_code-graph__project_map", "mcp__plugin_code-graph-mcp_code-graph__module_overview", "mcp__plugin_code-graph-mcp_code-graph__ast_search", "mcp__plugin_code-graph-mcp_code-graph__find_references"]
4
+ tools: ["Read", "Grep", "Glob", "Bash", "mcp__plugin_code-graph-mcp_code-graph__semantic_code_search", "mcp__plugin_code-graph-mcp_code-graph__get_call_graph", "mcp__plugin_code-graph-mcp_code-graph__get_ast_node", "mcp__plugin_code-graph-mcp_code-graph__project_map", "mcp__plugin_code-graph-mcp_code-graph__module_overview", "mcp__plugin_code-graph-mcp_code-graph__ast_search", "mcp__plugin_code-graph-mcp_code-graph__find_references"]
5
5
  model: sonnet
6
6
  ---
7
7
 
8
8
  You are a code exploration specialist with access to an AST knowledge graph.
9
9
 
10
- <!-- The tool allowlist carries both MCP namespace spellings on purpose (DOC-07);
11
- rationale in CHANGELOG. Delete at most one half, never both. -->
10
+ <!-- The tool allowlist names only the plugin-hosted MCP spelling
11
+ (`mcp__plugin_code-graph-mcp_code-graph__*`): that is what a live plugin
12
+ install exposes (checked 2026-09-28 on Claude Code 2.1.284). The bare
13
+ `mcp__code-graph__*` half DOC-07 kept as a bet matched no tool. -->
12
14
 
13
15
  ## Strategy
14
16
 
15
- 1. **Start with semantic_code_search** to locate relevant code by meaning, or **module_overview** / **project_map** to map an unfamiliar directory or the whole repo
16
- 2. **Use get_call_graph** to understand function relationships and call chains (pass `route_path='GET /api/x'` to trace an HTTP handler downstream)
17
- 3. **Use get_ast_node** to get symbol metadata, source, and callers/callees (`context_lines` for surrounding source, `include_impact` for blast radius)
18
- 4. **Use find_references** for rename/remove audits and **ast_search** to enumerate symbols by type / return / params
19
- 5. **Fall back to Grep/Read** only when code-graph tools lack coverage (e.g., config files, non-code assets)
17
+ The CLI answers from Bash with no tool loading; the MCP tools return the same data.
18
+
19
+ 1. **Map first**: `code-graph-mcp overview <dir>` (MCP `module_overview`) or `code-graph-mcp map --compact` (`project_map`) for an unfamiliar area.
20
+ 2. **Locate**: `code-graph-mcp show X` for a named symbol; `code-graph-mcp search "words"` or MCP `semantic_code_search` when you only know what the code does.
21
+ 3. **Relate**: `code-graph-mcp callgraph X` (`get_call_graph`; `route_path='GET /api/x'` traces an HTTP handler) and `code-graph-mcp impact X` (`get_ast_node include_impact=true`).
22
+ 4. **Audit uses**: `code-graph-mcp refs X` (`find_references`), then grep for what the graph cannot see; `ast_search` enumerates symbols by type / return / params.
23
+ 5. **Read** the files the answer points to; use Grep for literal strings and non-code files.
20
24
 
21
25
  ## Rules
22
26
 
23
- - Always prefer structured graph queries over raw text search
24
- - Return structured findings: name, file, line, relationships
25
- - When reporting call chains, include depth and direction
26
- - Estimate token cost: if Read would require >3 files, prefer code-graph tools
27
+ - Calls through dynamic dispatch, reflection or unresolved imports are not in the graph: an empty caller list is not proof of no callers.
28
+ - Return structured findings: name, file, line, relationships.
29
+ - When reporting call chains, include depth and direction.
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "code-graph-mcp hooks — only SessionStart is loaded by Claude Code from plugin-cache; PreToolUse / PostToolUse / UserPromptSubmit / Stop / SessionEnd entries here are SILENTLY IGNORED (empirical 2026-05-24). lifecycle.js install/update registers those into ~/.claude/settings.json instead (v0.32.0+), so re-adding them here would be dead config, not a second chance — pinned by the SessionStart-only guard in hooks.test.js. Keep this file to the keys Claude Code's schema allows (description, hooks, modules, surface); anything else is warned about at startup and dropped.",
2
+ "description": "code-graph-mcp hooks, loaded by Claude Code from the plugin: SessionStart plus the tool, prompt, subagent and stop hooks. Since 0.164 they all live here; SessionStart removes the copies earlier versions wrote into ~/.claude/settings.json (a plugin's hooks.json is loaded for every event by current Claude Code). Keep this file to the keys Claude Code's schema allows (description, hooks, modules, surface); maintainer notes go in hooks.test.js.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -12,6 +12,96 @@
12
12
  }
13
13
  ]
14
14
  }
15
+ ],
16
+ "PreToolUse": [
17
+ {
18
+ "matcher": "Edit|Write",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-edit-guide.js\"",
23
+ "timeout": 4
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ "matcher": "Bash",
29
+ "hooks": [
30
+ {
31
+ "type": "command",
32
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-grep-guide.js\"",
33
+ "timeout": 3
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "matcher": "Read",
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-read-guide.js\"",
43
+ "timeout": 3
44
+ }
45
+ ]
46
+ }
47
+ ],
48
+ "PostToolUse": [
49
+ {
50
+ "matcher": "Write|Edit",
51
+ "hooks": [
52
+ {
53
+ "type": "command",
54
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/incremental-index.js\"",
55
+ "timeout": 10
56
+ }
57
+ ]
58
+ },
59
+ {
60
+ "matcher": "Bash",
61
+ "hooks": [
62
+ {
63
+ "type": "command",
64
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-grep-inject.js\"",
65
+ "timeout": 5
66
+ }
67
+ ]
68
+ }
69
+ ],
70
+ "UserPromptSubmit": [
71
+ {
72
+ "matcher": "",
73
+ "hooks": [
74
+ {
75
+ "type": "command",
76
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-context.js\"",
77
+ "timeout": 5
78
+ }
79
+ ]
80
+ }
81
+ ],
82
+ "SubagentStart": [
83
+ {
84
+ "matcher": "Explore|Plan|general-purpose",
85
+ "hooks": [
86
+ {
87
+ "type": "command",
88
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-start.js\"",
89
+ "timeout": 3
90
+ }
91
+ ]
92
+ }
93
+ ],
94
+ "Stop": [
95
+ {
96
+ "matcher": "",
97
+ "hooks": [
98
+ {
99
+ "type": "command",
100
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-impact.js\"",
101
+ "timeout": 5
102
+ }
103
+ ]
104
+ }
15
105
  ]
16
106
  }
17
107
  }
@@ -69,29 +69,26 @@ function writeFileAtomic(filePath, data, { followLink = false } = {}) {
69
69
  // The managed block written into <cwd>/CLAUDE.md. Concise + always-loaded: a
70
70
  // scannable trigger table that primes the right tool, ending with a pointer to
71
71
  // the full table at .claude/plugin_code_graph_mcp.md (opened on demand, never
72
- // auto-loaded). Project-type tailoring swaps a couple of rows (web → HTTP-route
73
- // tracing; frontend → reference audits) — body of the detail doc is unchanged.
72
+ // auto-loaded). Project-type tailoring adds a row for web projects (HTTP-route
73
+ // tracing) — body of the detail doc is unchanged.
74
74
  //
75
- // Every row spends the bare name `code-graph-mcp`, and a plugin-only install has
76
- // it nowhere on PATH: findBinary() resolves the plugin's own download at
77
- // ~/.cache/code-graph/bin (auto-update.js `cachedBinaryPath`), and the global-npm
78
- // tiers below it exist only for users who ran `npm i -g` themselves. Issue #41 is
79
- // what that reads like from the other side — a table of commands the user's shell
80
- // answers with "command not found". Hence the fallback line below the table.
75
+ // Every row spends the bare name `code-graph-mcp`. Issue #41 was a plugin-only
76
+ // install whose shell answered it with "command not found"; Claude Code now puts
77
+ // the plugin's `bin/` launcher on the Bash PATH, so the `~/.cache/code-graph/bin`
78
+ // fallback line this block used to carry is gone (it also could not run
79
+ // `adopt`/`doctor`, which are JS-dispatched).
81
80
  //
82
- // It names a path SHAPE, never a resolved one. This file goes into a git-tracked
83
- // CLAUDE.md (the adopt output says so two screens down), so a resolved
84
- // /home/<user>/… is right on one machine and wrong on every teammate's — and
85
- // buildBlock's byte-determinism is exactly what needsRefresh diffs, so
86
- // machine-varying content would rewrite the block on every clone's next
87
- // SessionStart. No `.exe` variant for the same reason it is safe not to have one:
88
- // platformGuard() refuses adopt on win32, so this block never exists there.
81
+ // The block is byte-deterministic and machine-independent: it goes into a
82
+ // CLAUDE.md that may be git-tracked, and needsRefresh diffs it bytewise, so a
83
+ // resolved /home/<user>/… path would be right on one machine only.
89
84
  const BLOCK_HEADING = '## Code Graph (repo-wide AST index)';
90
85
 
91
86
  function buildTriggerRows(projectType = 'generic') {
92
87
  const base = [
93
88
  ['Who calls X / what X calls', '`code-graph-mcp callgraph X`'],
94
- ['Impact before editing a fn', '`code-graph-mcp impact X`'],
89
+ ['Impact before changing a signature', '`code-graph-mcp impact X`'],
90
+ ['Tests to re-run after changing files', '`code-graph-mcp affected <files>`'],
91
+ ['Rename / remove audit', '`code-graph-mcp refs X`, then `grep -w X`'],
95
92
  ['Unfamiliar dir / module', '`code-graph-mcp overview <dir>`'],
96
93
  ['Symbol source / signature', '`code-graph-mcp show X`'],
97
94
  ['Concept search (no exact name)', '`code-graph-mcp search "…"` (vector: MCP `semantic_code_search`)'],
@@ -106,11 +103,6 @@ function buildTriggerRows(projectType = 'generic') {
106
103
  return [base[0], base[1],
107
104
  ['HTTP route → handler chain', '`code-graph-mcp trace "GET /api/x"`'],
108
105
  ...base.slice(2)];
109
- case 'frontend':
110
- // Rename/refactor audits dominate; surface find-references explicitly.
111
- return [base[0],
112
- ['Rename / refactor audit (refs)', '`code-graph-mcp refs X`'],
113
- ...base.slice(1)];
114
106
  default:
115
107
  return base;
116
108
  }
@@ -126,14 +118,13 @@ function buildBlock(projectType = 'generic') {
126
118
  const body = [
127
119
  BLOCK_HEADING,
128
120
  '',
129
- 'AST + FTS + vector index of the whole repo — prefer over multi-round Grep/Read for',
130
- 'structural queries (LSP only sees open files; this sees everything). Fastest path = Bash CLI:',
121
+ 'Parsed index of the whole repo. For structural questions — who calls X, what a',
122
+ 'change breaks, every use of a symbol — one call replaces rounds of Grep + Read:',
131
123
  '',
132
124
  table,
133
125
  '',
134
- 'Not on PATH? A plugin-only install keeps its own copy — same commands, run',
135
- '`~/.cache/code-graph/bin/code-graph-mcp` (or `npm i -g @sdsrs/code-graph` once).',
136
- '',
126
+ 'Unresolved calls (dynamic dispatch, reflection, unresolved imports) leave no edge, so',
127
+ 'an empty answer is not proof: confirm with grep before deleting or renaming.',
137
128
  "Still use Grep for literal strings/regex in non-code files; still Read files you'll edit.",
138
129
  'Full command + MCP-tool table: `.claude/plugin_code_graph_mcp.md`',
139
130
  ].join('\n');
@@ -491,6 +482,17 @@ function readAdoptedProjects(home) {
491
482
  return readAdoptedResult(home).list;
492
483
  }
493
484
 
485
+ // A8 (2026-09-29 usage evaluation): the registry only ever grew — 7 of 16
486
+ // entries on the evaluation machine named deleted temp dirs. A directory that
487
+ // no longer exists holds no managed block, so each write drops those; an entry
488
+ // whose check fails any other way (EACCES, a stale mount) stays, since its
489
+ // block may still be there.
490
+ function withoutGoneDirs(list) {
491
+ return list.filter((p) => {
492
+ try { fs.statSync(p); return true; } catch (err) { return !(err && err.code === 'ENOENT'); }
493
+ });
494
+ }
495
+
494
496
  /** @returns {boolean} true when the project is recorded (or already was). */
495
497
  function recordAdopted(projectDir, home) {
496
498
  const res = readAdoptedResult(home);
@@ -498,9 +500,11 @@ function recordAdopted(projectDir, home) {
498
500
  try {
499
501
  const file = adoptedRegistryFile(home);
500
502
  const abs = path.resolve(projectDir);
501
- if (res.list.includes(abs)) return true;
503
+ const kept = withoutGoneDirs(res.list);
504
+ const next = kept.includes(abs) ? kept : [...kept, abs];
505
+ if (next.length === res.list.length && next.every((p, i) => p === res.list[i])) return true;
502
506
  fs.mkdirSync(path.dirname(file), { recursive: true });
503
- writeFileAtomic(file, JSON.stringify([...res.list, abs], null, 2) + '\n');
507
+ writeFileAtomic(file, JSON.stringify(next, null, 2) + '\n');
504
508
  return true;
505
509
  } catch { return false; } // best-effort: registry loss only degrades guidance
506
510
  }
@@ -511,7 +515,7 @@ function removeAdopted(projectDir, home) {
511
515
  if (res.unusable) return false;
512
516
  try {
513
517
  const abs = path.resolve(projectDir);
514
- const next = res.list.filter((p) => p !== abs);
518
+ const next = withoutGoneDirs(res.list).filter((p) => p !== abs);
515
519
  if (next.length === res.list.length) return true;
516
520
  writeFileAtomic(adoptedRegistryFile(home), JSON.stringify(next, null, 2) + '\n');
517
521
  return true;
@@ -736,19 +740,27 @@ function maybeAutoAdopt({ cwd, home, env, scriptPath } = {}) {
736
740
  if (!isPluginModeInstall(scriptPath || __dirname)) {
737
741
  return { attempted: false, reason: 'not-plugin-mode', migrated: noMigration };
738
742
  }
739
- // Clean legacy memory-dir artifacts before installing the new CLAUDE.md scheme.
743
+ // Clean up this plugin's own legacy memory-dir artifacts.
740
744
  const migrated = migrateLegacyMemoryDir({ cwd, home });
745
+ // Never writes CLAUDE.md or .claude/ (decision D4, 2026-09-28 usage
746
+ // evaluation). Creating them on the first session made Claude stop to
747
+ // explain the unexpected changes in 12 of 15 coding runs (0 of 15 without
748
+ // the plugin), and the MCP instructions already carry 10 of the block's 11
749
+ // guidance points. An adopted project keeps its block as it is; one that has
750
+ // drifted from the shipped template is reported as `stale`, so SessionStart
751
+ // can tell the user how to refresh (`adopt`) or remove (`unadopt`) it. The
752
+ // name is kept: SessionStart, tests and the uninstall paths all call it.
741
753
  if (isAdopted({ cwd })) {
742
- // shipped template / 管理块 漂移时重跑 adopt 对齐。
743
- // opt-out: CODE_GRAPH_NO_TEMPLATE_REFRESH=1(锁定手动编辑)。
744
754
  if (env.CODE_GRAPH_NO_TEMPLATE_REFRESH !== '1' && needsRefresh({ cwd })) {
745
- const result = adopt({ cwd, home });
746
- return { attempted: true, reason: 'refreshed', result, migrated };
755
+ return { attempted: false, reason: 'stale', migrated };
747
756
  }
748
757
  return { attempted: false, reason: 'already-adopted', migrated };
749
758
  }
750
- const result = adopt({ cwd, home });
751
- return { attempted: true, reason: 'adopted', result, migrated };
759
+ return { attempted: false, reason: 'not-adopted', migrated };
760
+ }
761
+
762
+ function adoptCommand() {
763
+ return `node ${shellQuote(__filename)} adopt`;
752
764
  }
753
765
 
754
766
  function unadopt({ cwd, home } = {}) {
@@ -978,7 +990,7 @@ if (require.main === module) {
978
990
  module.exports = {
979
991
  adopt, unadopt, memoryDir, formatResult, unadoptCommand, shellQuote, stripSentinelBlock,
980
992
  readAdoptedProjects, readAdoptedResult, recordAdopted, removeAdopted, adoptedRegistryFile,
981
- isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
993
+ isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot, adoptCommand,
982
994
  detectProjectType, buildBlock, buildTriggerRows, migrateLegacyMemoryDir,
983
995
  claudeMdPath, detailDir, detailPath,
984
996
  extractCargoRuntimeDeps, extractPyRuntimeDeps, extractGoDirectRequires,
@@ -545,6 +545,13 @@ function runCallgraphAnswer(opts = {}) {
545
545
  !(out.includes('← called by') || out.includes('→ calls'))) {
546
546
  return { status: 'no-hits' };
547
547
  }
548
+ // `callgraph` promotes a unique fuzzy match when the name has no edges of
549
+ // its own (`task` → `run_startup_tasks`) and says so only on stderr, which
550
+ // this run discards. A tree rooted at another symbol is not an answer about
551
+ // the grepped one: real sessions got up to 27 of 98 injections this way.
552
+ if (out.split('\n', 1)[0].split(' ', 1)[0] !== symbol) {
553
+ return { status: 'no-hits' };
554
+ }
548
555
  const { text, truncated } = truncateAtLine(out, maxBytes);
549
556
  return { status: 'hits', text, truncated };
550
557
  } catch {
@@ -56,8 +56,7 @@ function formatCoveringTests(testCallers, editedFile) {
56
56
  }
57
57
 
58
58
  // High fan-out: editing a widely-tested symbol. A long targeted command is noise
59
- // — point at the suite instead (mirrors the blast-size scaling in
60
- // session-init.js formatRecentImpact).
59
+ // — point at the suite instead.
61
60
  let out = ` Covering tests: ${n} — editing a widely-tested symbol\n`;
62
61
  const suite = suiteCommand(runner);
63
62
  if (suite) out += ` → Run the suite after editing: ${suite}\n`;
@@ -7,7 +7,7 @@ const os = require('os');
7
7
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
8
8
  const {
9
9
  getPluginVersion, readJson, readJsonResult, healthCheck, scanForBrokenPaths,
10
- settingsPath, surveyHookCoverage,
10
+ settingsPath, surveyHookCoverage, hooksFromPluginManifest,
11
11
  installedGlobalPkgs, GLOBAL_INSTALL_MARKER, SHELL_PKG,
12
12
  } = require('./lifecycle');
13
13
  const { UPDATE_STATE_FILE, uninstallTombstoneActive } = require('./cache-paths');
@@ -537,6 +537,25 @@ function runDiagnostics({ checkOnly = false } = {}) {
537
537
  status: 'warn',
538
538
  detail: 'not determinable — settings.json could not be read or parsed',
539
539
  });
540
+ } else if (hooksFromPluginManifest(settings)) {
541
+ // Decision D2: the installed plugin's hooks.json carries every hook, so an
542
+ // empty settings.json is the healthy state and our entries in it — left
543
+ // by a pre-0.164 install — fire each hook a second time.
544
+ if (cov.present.length === 0) {
545
+ results.push({
546
+ name: 'Hook coverage',
547
+ status: 'ok',
548
+ detail: "hooks come from the plugin's hooks.json; settings.json holds none of ours",
549
+ });
550
+ } else {
551
+ results.push({
552
+ name: 'Hook coverage',
553
+ status: 'warn',
554
+ detail: `${cov.present.length} code-graph hook entr${cov.present.length === 1 ? 'y' : 'ies'} in settings.json ` +
555
+ `run next to the plugin's hooks.json, so each fires twice: ${cov.present.join(', ')}`,
556
+ fixId: 'legacy-hooks-in-settings',
557
+ });
558
+ }
540
559
  } else if (cov.missing.length === 0 && cov.stale.length === 0) {
541
560
  results.push({
542
561
  name: 'Hook coverage',
@@ -1247,6 +1266,26 @@ function runRepairs(results, {
1247
1266
  break;
1248
1267
  }
1249
1268
 
1269
+ case 'legacy-hooks-in-settings': {
1270
+ console.log("\n Removing code-graph hooks from settings.json (the plugin's hooks.json carries them)...");
1271
+ if (relicRepairGuard()) break;
1272
+ if (teardownRepairGuard()) break;
1273
+ const { install } = require('./lifecycle');
1274
+ const r = install({ clearTombstone: true });
1275
+ if (r.settingsUnwritable) {
1276
+ console.log(' \u274c settings.json is not writable \u2014 entries NOT removed');
1277
+ console.log(' Fix the permissions on it (or on ~/.claude) and re-run; see the error above.');
1278
+ } else if (r.settingsUnreadable) {
1279
+ console.log(' \u274c settings.json could not be read or parsed \u2014 entries NOT removed');
1280
+ } else if (r.hooksRegistered) {
1281
+ console.log(' \u2705 settings.json updated — restart Claude Code to apply');
1282
+ fixed++;
1283
+ } else {
1284
+ console.log(' \u2796 install reported no change');
1285
+ }
1286
+ break;
1287
+ }
1288
+
1250
1289
  case 'missing-hooks-in-settings': {
1251
1290
  console.log('\n Registering code-graph hooks in settings.json...');
1252
1291
  if (relicRepairGuard()) break;
@@ -60,8 +60,11 @@ function runMain() {
60
60
  const budget = childBudgetMs();
61
61
  if (budget === null) return; // exhausted — the index waits for the next edit
62
62
 
63
+ // --no-embed: structure is what the next query needs, and the vector
64
+ // backfill made every edit wait out the whole budget (8,048 ms per edit,
65
+ // hook audit 2026-09-28). Vectors are filled in by the MCP server.
63
66
  try {
64
- execFileSync(bin, ['incremental-index', '--quiet'], hidden({
67
+ execFileSync(bin, ['incremental-index', '--quiet', '--no-embed'], hidden({
65
68
  timeout: budget,
66
69
  stdio: ['pipe', 'pipe', 'pipe']
67
70
  }));
@@ -348,7 +348,7 @@ function tryWriteSettings(settings) {
348
348
  } catch (err) {
349
349
  console.error(
350
350
  `[code-graph] cannot write ${settingsPath()} (${err.code || err.name}: ${err.message}). ` +
351
- `Nothing was changed. The plugin stays inactive until the file is writable.`
351
+ `Nothing was changed; run \`code-graph-mcp doctor\` once the file is writable.`
352
352
  );
353
353
  return err;
354
354
  }
@@ -1019,7 +1019,7 @@ function buildSettingsHookEntries() {
1019
1019
 
1020
1020
  return {
1021
1021
  PreToolUse: [
1022
- { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit', hooks: [scriptCmd('pre-edit-guide.js')] },
1022
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit|Write', hooks: [scriptCmd('pre-edit-guide.js')] },
1023
1023
  { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Bash', hooks: [scriptCmd('pre-grep-guide.js')] },
1024
1024
  { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Read', hooks: [scriptCmd('pre-read-guide.js')] },
1025
1025
  ],
@@ -1101,6 +1101,42 @@ function registerHooksToSettings(settings) {
1101
1101
  return before !== JSON.stringify(settings.hooks);
1102
1102
  }
1103
1103
 
1104
+ // --- Where the hooks live (decision D2, 2026-09-28 usage evaluation) ---
1105
+ //
1106
+ // A plugin install declares its hooks in the plugin's own hooks/hooks.json, and
1107
+ // current Claude Code loads that file for every event — verified on 2.1.284:
1108
+ // PreToolUse / PostToolUse / UserPromptSubmit / Stop entries in a plugin's
1109
+ // hooks.json all fired, and the marketplace-installed claude-mem-lite runs every
1110
+ // hook it has that way. The 2026-05-24 observation behind the v0.32.0 re-route
1111
+ // (only SessionStart loaded from there) no longer holds, and writing into the
1112
+ // user-global settings.json cost a doctor that re-added hooks after uninstall,
1113
+ // version-pinned paths and the ping-pong between delivery surfaces.
1114
+ //
1115
+ // settings.json registration stays for the one surface hooks.json does not
1116
+ // reach: an npm-global or dev CLI with no plugin installed. It never runs next
1117
+ // to an installed, enabled plugin — both copies would fire every hook twice.
1118
+ function hooksFromPluginManifest(settings = readJson(settingsPath()) || {}, { pluginRoot = PLUGIN_ROOT, env = process.env } = {}) {
1119
+ // Claude Code is running this copy as a plugin right now: SessionStart comes
1120
+ // from its hooks.json with CLAUDE_PLUGIN_ROOT set to its root. Compared, not
1121
+ // merely present — the variable can carry another plugin's root.
1122
+ if (env.CLAUDE_PLUGIN_ROOT && path.resolve(env.CLAUDE_PLUGIN_ROOT) === path.resolve(pluginRoot)) return true;
1123
+ // A marketplace install's copy, reached outside a session (doctor, auto-update).
1124
+ const rel = path.relative(pluginsCacheDir(), path.resolve(pluginRoot));
1125
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return true;
1126
+ // Another surface (npm global, dev checkout) while the plugin is installed and
1127
+ // enabled: the plugin's hooks.json already covers every session.
1128
+ return hasInstalledPluginRecord() && !isPluginExplicitlyDisabled(settings);
1129
+ }
1130
+
1131
+ // Put settings.json in the state the surface needs: our entries registered when
1132
+ // nothing else carries the hooks, removed when the plugin's hooks.json does.
1133
+ // Returns whether `settings` changed.
1134
+ function syncSettingsHooks(settings, opts = {}) {
1135
+ return hooksFromPluginManifest(settings, opts)
1136
+ ? removeHooksFromSettings(settings)
1137
+ : registerHooksToSettings(settings);
1138
+ }
1139
+
1104
1140
  // Extract the .js script path a hook command invokes — bare (`node "…"`) or
1105
1141
  // existence-guarded (`if [ -f "…" ]; then node "…"; fi`).
1106
1142
  // Is the composite command currently in the statusLine slot one we should
@@ -1251,6 +1287,7 @@ function hookFirePayload(matcher, event = '') {
1251
1287
  case 'Read':
1252
1288
  return { tool_name: 'Read', tool_input: { file_path: 'src/example.rs' } };
1253
1289
  case 'Edit':
1290
+ case 'Edit|Write':
1254
1291
  case 'Write|Edit':
1255
1292
  return { tool_name: 'Edit', tool_input: { file_path: 'src/example.rs', old_string: 'a', new_string: 'b' } };
1256
1293
  case '': // UserPromptSubmit
@@ -1269,9 +1306,9 @@ function hookFirePayload(matcher, event = '') {
1269
1306
  }
1270
1307
  }
1271
1308
 
1272
- // The hooks CC actually loads from settings.json (PreToolUse/PostToolUse/
1273
- // UserPromptSubmit). SessionStart (hooks.json) runs every session → its own
1274
- // liveness proof → excluded here.
1309
+ // Every hook besides SessionStart, from the one list both registration surfaces
1310
+ // use (hooks.json for a plugin, settings.json otherwise). SessionStart runs
1311
+ // every session → its own liveness proof → excluded here.
1275
1312
  function defaultHookFireProbes() {
1276
1313
  const probes = [];
1277
1314
  for (const [event, entries] of Object.entries(buildSettingsHookEntries())) {
@@ -1485,11 +1522,9 @@ function install({ reclaimStatusline = false, clearTombstone = false } = {}) {
1485
1522
  // Register code-graph provider
1486
1523
  registerStatuslineProvider('code-graph', codeGraphStatuslineCommand(), false);
1487
1524
 
1488
- // 2. Hooks — v0.32.0: actively write PreToolUse/PostToolUse/UserPromptSubmit
1489
- // to settings.json. Plugin-cache hooks.json is silently ignored by current
1490
- // Claude Code for these events (SessionStart still loads from cache).
1491
- // registerHooksToSettings is idempotent: strips priors then appends fresh.
1492
- const hooksRegistered = registerHooksToSettings(settings);
1525
+ // 2. Hooks — registered in settings.json only where the plugin's hooks.json
1526
+ // does not reach; removed from it where it does (syncSettingsHooks).
1527
+ const hooksRegistered = syncSettingsHooks(settings);
1493
1528
  if (hooksRegistered) settingsChanged = true;
1494
1529
 
1495
1530
  // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle.
@@ -1765,9 +1800,9 @@ function update() {
1765
1800
  // 2. Update code-graph provider in registry
1766
1801
  registerStatuslineProvider('code-graph', codeGraphStatuslineCommand(), false);
1767
1802
 
1768
- // 3. Hooks — v0.32.0: register PreToolUse/PostToolUse/UserPromptSubmit in
1769
- // settings.json (idempotent; absolute paths re-anchor on every update).
1770
- const hooksRegistered = registerHooksToSettings(settings);
1803
+ // 3. Hooks — as in install(): settings.json carries them only where the
1804
+ // plugin's hooks.json does not (idempotent; paths re-anchor on update).
1805
+ const hooksRegistered = syncSettingsHooks(settings);
1771
1806
  if (hooksRegistered) settingsChanged = true;
1772
1807
 
1773
1808
  // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle.
@@ -2192,6 +2227,7 @@ module.exports = {
2192
2227
  cacheDirVersion, // exported for the separator-agnostic test
2193
2228
 
2194
2229
  verifyHooksFire, defaultHookFireProbes, // v0.67.0 — firing self-test
2230
+ hooksFromPluginManifest, syncSettingsHooks, // D2 — where the hooks live
2195
2231
  activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
2196
2232
  SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
2197
2233
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers