5 Commits 2c522c6254 ... bd4814d8c1

Autore SHA1 Messaggio Data
  Colby McHenry bd4814d8c1 feat(installer): stop auto-indexing on install + ship opt-in front-load prompt hook 1 giorno fa
  Colby McHenry 212dfc4b6a docs(changelog): note the sync cross-file caller-edge fix (#899) 1 giorno fa
  Colby McHenry 64426cad93 fix(react): recognize forwardRef/memo/styled components + index JSX-file routes (#841) 1 giorno fa
  Colby McHenry b5090cbad5 docs(dispatch-backlog): shelve trezor barrel-registry as single-lineage/overfit 1 giorno fa
  Colby McHenry feb2f641de feat(resolution): bridge Laravel event(new X) to its listener handles 1 giorno fa

+ 6 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- **Claude Code:** an optional front-load hook makes your agent reach for CodeGraph automatically. When you ask a structural question — "how does X work", "what calls Y", "trace the flow from A to B" — CodeGraph injects the relevant source and call paths into the prompt up front, so the agent answers from the graph instead of grepping around to rebuild it. You're asked during `codegraph install` (default yes; Claude Code only, since it's the agent with prompt hooks), it's removed by `codegraph uninstall`, and `codegraph upgrade` turns it on for existing Claude setups. It's strictly additive and degradable — non-structural prompts and un-indexed projects are left alone — and you can switch it off any time without uninstalling by setting `CODEGRAPH_NO_PROMPT_HOOK=1`.
 - Vue store actions, mutations, and getters are now indexed as symbols you can find and read. Whether your store is **Vuex** (`mutations` / `actions` objects in a module) or **Pinia** — both the options form (`defineStore({ actions: { … } })`) and the setup form (`defineStore('id', () => { … })`, where actions are local functions) — each action, mutation, and getter is now a real node. So `codegraph search` finds `login` or `getSessionList`, and `codegraph_explore` / `codegraph_node` show its body and what it calls, instead of "not found" because the function only existed as an object-literal property.
 - `codegraph_explore` now connects a Vue component to the **Pinia** store action it calls. When code does `const store = useUserStore()` and then `store.fetchUser()`, that call now links through to the `fetchUser` action in the store module — so "what happens when this view loads its data?" traces from the component into the action's body instead of stopping at the `store.fetchUser()` line. Works for both Pinia store styles (options and setup), and stays precise (a built-in like `store.$patch()` or an unrelated same-named method isn't mislinked).
 - `codegraph_explore` now follows **Vuex** string dispatch. A `dispatch('user/login')` or `commit('SET_TOKEN')` call — namespaced `'module/action'` keys included — now links to the action or mutation it names, resolved to the correct store module even when several modules share an action name (and without being fooled by a same-named `api/` helper). So "what runs when this dispatches?" traces from the call into the store handler and on to the mutations it commits. Vuex's canonical `export default { namespaced, actions, mutations }` module shape is now indexed too, so those handlers are findable symbols.
@@ -19,6 +20,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - `codegraph_explore` now follows **Spring application events** in Java. A `publishEvent(new OrderShippedEvent(...))` call now links to every `@EventListener` that handles that event — usually in a different class — so "what reacts when this is published?" traces from the publisher straight into each listener method instead of dead-ending at `publishEvent(...)`. The link is by event type, and all the common listener styles are recognized: a `@EventListener` typed on its parameter, the `@EventListener(SomeEvent.class)` form, `@TransactionalEventListener`, and the older `implements ApplicationListener<SomeEvent>`. One event fans out to all its listeners, and a plain Spring app with no event bus is unaffected.
 - `codegraph_explore` now follows **MediatR** request and notification dispatch in C#/.NET. A `_mediator.Send(command)` or `_mediator.Publish(notification)` call now links to the `Handle` method of the matching `IRequestHandler<>` / `INotificationHandler<>` — usually in a different file in a Clean Architecture layout — so "what handles this command?" traces from the controller straight into the handler instead of stopping at the mediator call. The sent type is recognized whether it's constructed inline (`Send(new GetFooQuery())`), built into a local first (`var cmd = new …; Send(cmd)`), or passed in as a parameter, and it's matched by type — so a `MessagingCenter.Send(...)` or a same-named DTO that isn't a request is never mislinked, and a project without MediatR is unaffected.
 - `codegraph_explore` now follows **Sidekiq** background-job dispatch in Ruby. A `DestroyUserWorker.perform_async(id)` (or `.perform_in` / `.perform_at`) call now links to that worker's `perform` method — usually in `app/workers/` away from the controller or model that enqueues it — so "what runs in the background here?" traces from the enqueue straight into the job body. Both the modern `include Sidekiq::Job` and the older `Sidekiq::Worker` are recognized, namespaced workers resolve to the right class even when several share a name (e.g. `Comments::NotifyWorker` vs `Articles::NotifyWorker`), and Rails ActiveJob's `perform_later` — a different mechanism — is intentionally left alone.
+- `codegraph_explore` now follows **Laravel events** in PHP. An `event(new OrderShipped($order))` call now links to every listener that handles it — each listener's `handle()` method, usually a separate `app/Listeners/` class — so "what reacts to this event?" traces from the dispatch straight into the listener bodies. Listeners are found both ways Laravel registers them: by a typed `handle(OrderShipped $event)` (auto-discovery, including a `handle(A|B $event)` union that listens for two events) and by the `protected $listen` map in your `EventServiceProvider` (which also catches a listener whose `handle()` has no type-hint). One event fans out to all its listeners, and queued jobs — dispatched via `::dispatch()` rather than `event()` — are correctly left out.
 
 - `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking.
 - Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.
@@ -29,6 +31,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- `codegraph install` now wires up your agents and stops there — it no longer indexes the current directory. Building a project's graph is always the explicit `codegraph init` (or `codegraph index`), so you decide what gets indexed and when, and the steps are the same whether you installed globally or just for one project. This clears up the confusion where a project-local install silently indexed but a global one didn't, and where the docs and the tool disagreed about whether you still had to run `init`. (#826)
+- React components declared with `forwardRef`, `memo`, or styled-components / emotion (`const Button = forwardRef(...)`, `const Card = memo(...)`, `const Box = styled.button\`…\``) are now recognized as components, so finding where they're used works. Before, they were indexed as plain constants, so `codegraph callers` and impact analysis reported "no callers found" even when the component was rendered across dozens of files — a dangerous false "safe to change" right before refactoring a shared component. Now every `<Button/>` usage links back to the component, so callers and blast radius are complete. This is the standard shadcn/ui declaration style, so for typical React design systems the whole UI layer is no longer invisible to impact analysis. Thanks @Arlandaren for the report and @maxmilian for the root-cause. (#841)
+- React Router and Next.js routes defined in `.tsx` / `.jsx` files are now indexed. Routes written as JSX — `<Route path="/users" element={<UsersPage/>}/>`, `createBrowserRouter([...])`, and Next.js `app/`/`pages/` page files — were being skipped entirely (only routes that happened to live in plain `.ts`/`.js` were picked up), so "what renders at this path?" and the route → page-component link were missing for most React apps. Now those routes show up in `codegraph search`/`codegraph_explore` and connect to the component they render, just like the backend route → handler links on other frameworks.
+- `codegraph sync` (and the file-watcher auto-sync behind always-on / MCP use) no longer drops a function's callers when you edit the file that function lives in. Re-indexing a file after an edit — even a docstring-only change — was severing the `calls` / `references` edges coming from *other, unchanged* files (e.g. `from pkg import mod; mod.fn()` callers, or any cross-file reference), so `codegraph callers` / `impact` would abruptly report "no callers" for a function that's used throughout the codebase, until the next full re-index. Sync now preserves those incoming cross-file edges across the re-index, re-matching them to the symbol even when the edit shifted its line number. Most visible in large Python package trees, but it applies to every language. Thanks @JosefAschauer. (#899)
 - `codegraph index` now rebuilds the full graph from scratch, so it produces the same result as a fresh `codegraph init` instead of reporting "0 nodes, 0 edges" and looking like it wiped your index. Previously, re-running `index` on an unchanged project skipped every file (their contents hadn't changed) and showed an empty-looking summary; it now clears and re-indexes for an honest, complete rebuild every time. Use `codegraph sync` for fast incremental updates between full rebuilds. Thanks @Arc-univer. (#874)
 - The file watcher that auto-syncs the graph now fails cleanly when live watching can no longer be trusted, instead of looking healthy while the index quietly goes stale. If the operating system runs out of file-watch resources, or another process holds the write lock far longer than a normal save, CodeGraph now disables auto-sync once — with a single clear message telling you to run `codegraph sync` (or rely on the git sync hooks) to refresh — rather than retrying forever or repeating the same error on a loop. And while auto-sync is disabled, CodeGraph's tool responses (and `codegraph status`) now say so plainly, so your AI agent knows to read files directly instead of trusting a frozen index. This mostly matters for long-running MCP/daemon sessions, which could otherwise keep serving stale results while appearing to work. Thanks @thismilktea. (#876)
 - On Linux, hitting the kernel's inotify watch limit on a large project no longer silently leaves half the tree unwatched. CodeGraph now tells you once — naming the exact setting to raise (`fs.inotify.max_user_watches`, e.g. `sudo sysctl fs.inotify.max_user_watches=1048576`) — and keeps live-watching the directories it could register while `codegraph sync` (or the git sync hooks) covers the rest. (#876)

+ 4 - 3
README.md

@@ -76,7 +76,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you
 codegraph install
 ```
 
-<sub>Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)</sub>
+<sub>Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)</sub>
 
 ### 3. Initialize each project
 
@@ -341,7 +341,8 @@ The installer will:
 - Ask whether configs apply to all your projects or just this one
 - Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`.
 - Set up auto-allow permissions when Claude Code is one of the targets
-- Initialize your current project (local installs only)
+
+The installer **wires up your agents only — it does not index your code.** After it finishes, build each project's graph yourself with `codegraph init` (step 3). One global `codegraph install` covers every project; you run `codegraph init` once per project.
 
 **Non-interactive (scripting / CI):**
 
@@ -466,7 +467,7 @@ The exact text is `src/mcp/server-instructions.ts` — the single source of trut
 codegraph                         # Run interactive installer
 codegraph install                 # Run installer (explicit)
 codegraph uninstall               # Remove CodeGraph from your agents (inverse of install)
-codegraph init [path]             # Initialize in a project (--index to also index)
+codegraph init [path]             # Initialize a project + build its graph (one step)
 codegraph uninit [path]           # Remove CodeGraph from a project (--force to skip prompt)
 codegraph index [path]            # Full index (--force to re-index, --quiet for less output)
 codegraph sync [path]             # Incremental update

+ 53 - 0
__tests__/frameworks-integration.test.ts

@@ -908,3 +908,56 @@ describe('Go gRPC stub→impl synthesis', () => {
     }
   });
 });
+
+describe('React Router end-to-end route extraction (.tsx/.jsx)', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  // Regression for the resolver language-gate bug: the `react` resolver's
+  // `extract()` was filtered out of the .tsx/.jsx grammars, so `<Route>` routes
+  // — which only live in JSX files — were never indexed through the real
+  // indexing path (the unit tests call extract() directly and so missed this).
+  it('indexes <Route element={<X/>}> routes from a .tsx file and links them to the component', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'package.json'),
+      '{"dependencies":{"react":"^18.0.0","react-router-dom":"^6.0.0"}}'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'Home.tsx'),
+      'export function Home() { return null; }\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'routes.tsx'),
+      `import { Routes, Route } from 'react-router-dom';
+import { Home } from './Home';
+export function AppRoutes() {
+  return (
+    <Routes>
+      <Route path="/home" element={<Home/>} />
+    </Routes>
+  );
+}
+`
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+    try {
+      // The route node from the .tsx file exists (the bug: it didn't).
+      const route = cg.getNodesByKind('route').find((n) => n.name === '/home');
+      expect(route, '/home route from .tsx should be indexed').toBeDefined();
+
+      // ...and it links to the Home component.
+      const home = cg.getNodesByName('Home').find((n) => n.kind === 'function');
+      expect(home).toBeDefined();
+      const toHome = cg.getOutgoingEdges(route!.id).find((e) => e.target === home!.id);
+      expect(toHome, 'route → Home component edge').toBeDefined();
+    } finally {
+      cg.close();
+    }
+  });
+});

+ 79 - 1
__tests__/installer-targets.test.ts

@@ -21,7 +21,7 @@ import * as os from 'os';
 import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
 import { uninstallTargets } from '../src/installer';
 import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
-import { cleanupLegacyHooks } from '../src/installer/targets/claude';
+import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude';
 
 function mkTmpDir(label: string): string {
   return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
@@ -1097,6 +1097,84 @@ describe('Installer targets — partial-state idempotency', () => {
     // Both events emptied → the whole `hooks` object is removed.
     expect(after.hooks).toBeUndefined();
   });
+
+  // ---- Front-load prompt hook (UserPromptSubmit) — #841 follow-up ----
+  // Opt-in (default-yes in the installer) UserPromptSubmit hook that runs
+  // `codegraph prompt-hook`. Must write/remove surgically, be idempotent, and
+  // round-trip an opt-out — without disturbing the user's own hooks.
+  const promptCommands = (s: any): string[] =>
+    (s.hooks?.UserPromptSubmit ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
+
+  it('claude: install with promptHook:true writes the UserPromptSubmit hook (alongside permissions)', () => {
+    const claude = getTarget('claude')!;
+    claude.install('global', { autoAllow: true, promptHook: true });
+    const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
+    expect(promptCommands(s)).toContain('codegraph prompt-hook');
+    expect(s.permissions?.allow).toContain('mcp__codegraph__*');
+  });
+
+  it('claude: install without promptHook does NOT add the hook', () => {
+    const claude = getTarget('claude')!;
+    claude.install('global', { autoAllow: true });
+    const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
+    expect(promptCommands(s)).not.toContain('codegraph prompt-hook');
+  });
+
+  it('claude: install with promptHook:true is idempotent (no duplicate, byte-identical re-run)', () => {
+    const claude = getTarget('claude')!;
+    const file = path.join(tmpHome, '.claude', 'settings.json');
+    claude.install('global', { autoAllow: true, promptHook: true });
+    const first = fs.readFileSync(file, 'utf-8');
+    claude.install('global', { autoAllow: true, promptHook: true });
+    expect(fs.readFileSync(file, 'utf-8')).toBe(first);
+    const s = JSON.parse(first);
+    expect(promptCommands(s).filter((c: string) => c === 'codegraph prompt-hook')).toHaveLength(1);
+  });
+
+  it('claude: install with promptHook:false strips a hook a prior install wrote (opt-out round-trips)', () => {
+    const claude = getTarget('claude')!;
+    claude.install('global', { autoAllow: true, promptHook: true });
+    claude.install('global', { autoAllow: true, promptHook: false });
+    const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
+    expect(promptCommands(s)).not.toContain('codegraph prompt-hook');
+  });
+
+  it('claude: writePromptHookEntry preserves a sibling UserPromptSubmit hook', () => {
+    const file = seedSettings('global', {
+      hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'my-own-hook' }] }] },
+    });
+    expect(writePromptHookEntry('global').action).toBe('updated');
+    const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
+    expect(promptCommands(s)).toEqual(['my-own-hook', 'codegraph prompt-hook']);
+  });
+
+  it('claude: uninstall removes the prompt hook but keeps the user\'s sibling', () => {
+    const file = seedSettings('global', {
+      hooks: {
+        UserPromptSubmit: [
+          { hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] },
+          { hooks: [{ type: 'command', command: 'my-own-hook' }] },
+        ],
+      },
+    });
+    getTarget('claude')!.uninstall('global');
+    const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
+    expect(promptCommands(s)).toEqual(['my-own-hook']);
+  });
+
+  it('claude: removePromptHookEntry leaves the legacy auto-sync hook untouched', () => {
+    const file = seedSettings('global', {
+      hooks: {
+        UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] }],
+        Stop: [{ hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }],
+      },
+    });
+    expect(removePromptHookEntry('global').action).toBe('removed');
+    const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
+    expect(promptCommands(s)).not.toContain('codegraph prompt-hook');
+    const stopCmds = (s.hooks?.Stop ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
+    expect(stopCmds).toContain('codegraph sync-if-dirty');
+  });
 });
 
 describe('Installer targets — registry', () => {

+ 169 - 0
__tests__/laravel-event-synthesizer.test.ts

@@ -0,0 +1,169 @@
+/**
+ * Laravel event-dispatch bridge (PHP).
+ *
+ * Laravel decouples an event dispatch from its listener(s), linked by the event class:
+ * `event(new SongLiked($id))` has no static edge to the `handle(SongLiked $e)` that runs it
+ * (usually a separate `app/Listeners/` file). This bridges each `event(new X(...))` site to every
+ * listener's `handle` for X, via TWO registration mechanisms: (A) a typed `handle(EventType $e)`
+ * (auto-discovery, union-split for `A|B`) and (B) the `protected $listen` map in an
+ * EventServiceProvider (which also covers a listener whose `handle()` is untyped). Queued JOBS
+ * dispatch via `::dispatch()`/`dispatch()` and their `handle()` takes a service — so only
+ * `event(new X)` is matched and jobs are excluded.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+describe('laravel-event synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'laravel-event-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  const write = (rel: string, body: string) => {
+    const p = path.join(dir, rel);
+    fs.mkdirSync(path.dirname(p), { recursive: true });
+    fs.writeFileSync(p, body);
+  };
+
+  it('bridges event(new X) to listener handles via typed handles, the $listen map, unions, and fan-out; excludes jobs', async () => {
+    for (const [name, body] of [
+      ['SongLiked', 'public int $id; public function __construct(int $id) { $this->id = $id; }'],
+      ['LibraryChanged', ''],
+      ['ScanDone', ''],
+      ['OwnerTest', ''],
+      ['UserTest', ''],
+    ] as const) {
+      write(`app/Events/${name}.php`, `<?php\nnamespace App\\Events;\nclass ${name} {\n  ${body}\n}\n`);
+    }
+    // (A) typed-handle listener — auto-discovery, no $listen entry needed.
+    write('app/Listeners/LoveTrack.php', `<?php
+namespace App\\Listeners;
+use App\\Events\\SongLiked;
+class LoveTrack {
+    public function handle(SongLiked $event): void {}
+}
+`);
+    // (B) UNTYPED handle — linkable only through the $listen map.
+    write('app/Listeners/PruneLibrary.php', `<?php
+namespace App\\Listeners;
+class PruneLibrary {
+    public function handle(): void {}
+}
+`);
+    // Fan-out: two listeners for ScanDone.
+    write('app/Listeners/WriteScanLog.php', `<?php
+namespace App\\Listeners;
+use App\\Events\\ScanDone;
+class WriteScanLog {
+    public function handle(ScanDone $event): void {}
+}
+`);
+    write('app/Listeners/DeleteStale.php', `<?php
+namespace App\\Listeners;
+use App\\Events\\ScanDone;
+class DeleteStale {
+    public function handle(ScanDone $event): void {}
+}
+`);
+    // Union-typed handle — one listener, two events.
+    write('app/Listeners/SendsTestNotification.php', `<?php
+namespace App\\Listeners;
+use App\\Events\\OwnerTest;
+use App\\Events\\UserTest;
+class SendsTestNotification {
+    public function handle(OwnerTest|UserTest $event): void {}
+}
+`);
+    // A queued JOB — handle takes a service, dispatched via ::dispatch()/dispatch(). Never an edge.
+    write('app/Jobs/ProcessAudio.php', `<?php
+namespace App\\Jobs;
+use App\\Services\\AudioService;
+class ProcessAudio implements ShouldQueue {
+    public function handle(AudioService $svc): void {}
+}
+`);
+    // The $listen map — registers the untyped PruneLibrary for LibraryChanged.
+    write('app/Providers/EventServiceProvider.php', `<?php
+namespace App\\Providers;
+use App\\Events\\LibraryChanged;
+use App\\Listeners\\PruneLibrary;
+class EventServiceProvider {
+    protected $listen = [
+        LibraryChanged::class => [
+            PruneLibrary::class,
+        ],
+    ];
+}
+`);
+    write('app/Services/SongService.php', `<?php
+namespace App\\Services;
+use App\\Events\\SongLiked;
+use App\\Events\\LibraryChanged;
+use App\\Events\\ScanDone;
+use App\\Events\\OwnerTest;
+use App\\Events\\UserTest;
+use App\\Jobs\\ProcessAudio;
+class SongService {
+    public function like(int $id): void { event(new SongLiked($id)); }
+    public function deleteSongs(): void { event(new LibraryChanged()); }
+    public function scan(): void { event(new ScanDone()); }
+    public function ownerTest(): void { event(new OwnerTest()); }
+    public function userTest(): void { event(new UserTest()); }
+    public function process(): void {
+        ProcessAudio::dispatch();
+        dispatch(new ProcessAudio());
+    }
+}
+`);
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    const edges = db
+      .prepare(
+        `SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
+         FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy') = 'laravel-event'`
+      )
+      .all();
+    const bySrc = (s: string) => edges.filter((r: any) => r.source === s);
+    const file = (r: any) => /(\w+)\.php$/.exec(r.tf)![1];
+
+    expect(edges.length).toBe(6);
+    expect(edges.every((r: any) => r.target === 'handle')).toBe(true);
+    // (A) typed handle.
+    expect(bySrc('like').map((r: any) => [r.via, file(r)])).toEqual([['SongLiked', 'LoveTrack']]);
+    // (B) untyped handle via the $listen map.
+    expect(bySrc('deleteSongs').map((r: any) => [r.via, file(r)])).toEqual([['LibraryChanged', 'PruneLibrary']]);
+    // Fan-out: ScanDone → both listeners.
+    expect(new Set(bySrc('scan').map(file))).toEqual(new Set(['WriteScanLog', 'DeleteStale']));
+    // Union split: OwnerTest and UserTest each reach the one listener (separate dispatchers,
+    // so they aren't deduped to a single source→target edge).
+    expect(bySrc('ownerTest').map((r: any) => [r.via, file(r)])).toEqual([['OwnerTest', 'SendsTestNotification']]);
+    expect(bySrc('userTest').map((r: any) => [r.via, file(r)])).toEqual([['UserTest', 'SendsTestNotification']]);
+    // PRECISION: a queued job (::dispatch / dispatch()) produces nothing.
+    expect(edges.some((r: any) => r.source === 'process')).toBe(false);
+
+    cg.close?.();
+  });
+
+  it('produces no edges in a PHP project with no Laravel events (clean control)', async () => {
+    write('src/Client.php', `<?php
+namespace Acme;
+class Client {
+    public function send(string $url): string { return $url; }
+}
+`);
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const count = db
+      .prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'laravel-event'`)
+      .get();
+    expect(count.c).toBe(0);
+    cg.close?.();
+  });
+});

+ 145 - 0
__tests__/react-hoc-component.test.ts

@@ -0,0 +1,145 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+/**
+ * #841 — React components declared via an HOC wrapper
+ * (`const Button = forwardRef(...)`, `memo(...)`, `styled.x\`…\``) were indexed
+ * as plain `constant` nodes, so their JSX usages (`<Button/>`) got no render
+ * edge and `getCallers` / `getImpactRadius` returned empty — a dangerous silent
+ * false negative for every shadcn/ui-style design system. They must now be
+ * `component` nodes that receive jsx-render edges like function components do.
+ */
+describe('React HOC-wrapped component recognition (#841)', () => {
+  let dir: string;
+  let cg: any;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-hoc-'));
+    fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
+  });
+
+  afterEach(() => {
+    cg?.close?.();
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  async function index() {
+    cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    return (cg as any).db.db;
+  }
+
+  const kindsOf = (db: any, name: string): string[] =>
+    db
+      .prepare('SELECT kind FROM nodes WHERE name=? ORDER BY kind')
+      .all(name)
+      .map((r: any) => r.kind);
+
+  it('classifies forwardRef / memo / styled consts as component nodes (not constant)', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'ui.tsx'),
+      `import * as React from 'react';
+import styled from 'styled-components';
+export const Button = React.forwardRef<HTMLButtonElement, {}>((props, ref) => <button ref={ref} {...props} />);
+export const Bare = forwardRef((props, ref) => <span ref={ref} />);
+export const Card = memo((props: { t: string }) => <div>{props.t}</div>);
+export const Named = memo(function Named(props: { t: string }) { return <div>{props.t}</div>; });
+export const Boxed = styled.div\`color: red;\`;
+export const Wrapped = styled(Button)\`padding: 4px;\`;
+export const Rewrapped = memo(Button);
+`
+    );
+    const db = await index();
+    for (const name of ['Button', 'Bare', 'Card', 'Named', 'Boxed', 'Wrapped', 'Rewrapped']) {
+      expect(kindsOf(db, name), `${name} should be a component`).toContain('component');
+      // The bug was that these stayed plain constants.
+      expect(kindsOf(db, name), `${name} should not remain a constant`).not.toContain('constant');
+    }
+  });
+
+  it('emits jsx-render edges so getCallers/getImpactRadius resolve a forwardRef component', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'button.tsx'),
+      `import * as React from 'react';
+export const Button = React.forwardRef<HTMLButtonElement, {}>((props, ref) => <button ref={ref} {...props} />);
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'page.tsx'),
+      `import { Button } from './button';
+export function Page() {
+  return <Button>Click</Button>;
+}
+`
+    );
+    const db = await index();
+
+    // The render edge exists and is the synthesized jsx-render kind.
+    const edgeRows = db
+      .prepare(
+        `SELECT s.name caller FROM edges e
+         JOIN nodes s ON s.id = e.source
+         JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
+           AND t.kind = 'component' AND t.name = 'Button'`
+      )
+      .all();
+    expect(edgeRows.map((r: any) => r.caller)).toContain('Page');
+
+    // ...and it surfaces through the public callers API (the issue's symptom:
+    // "No callers found" before the fix).
+    const buttonId = db
+      .prepare("SELECT id FROM nodes WHERE name='Button' AND kind='component'")
+      .get().id as string;
+    const callers = cg.getCallers(buttonId).map((c: any) => c.node.name);
+    expect(callers).toContain('Page');
+  });
+
+  it('captures the inner render-fn body callees under the component', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'widget.tsx'),
+      `import * as React from 'react';
+function useThing() { return 1; }
+export const Widget = React.forwardRef((props, ref) => {
+  const v = useThing();
+  return <div ref={ref}>{v}</div>;
+});
+`
+    );
+    const db = await index();
+    const rows = db
+      .prepare(
+        `SELECT t.name FROM edges e
+         JOIN nodes s ON s.id = e.source
+         JOIN nodes t ON t.id = e.target
+         WHERE s.name = 'Widget' AND s.kind = 'component'
+           AND e.kind = 'calls' AND t.name = 'useThing'`
+      )
+      .all();
+    expect(rows.length).toBeGreaterThanOrEqual(1);
+  });
+
+  it('does not misclassify non-component PascalCase consts (precision)', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'controls.tsx'),
+      `import * as React from 'react';
+const cache = memo(expensiveFn);
+export const Config = loadConfig();
+export const Client = new ApiClient();
+export const Styles = styledHelper();
+export const Total = [1, 2].reduce((a, b) => a + b, 0);
+export const Theme = { color: 'red' };
+`
+    );
+    const db = await index();
+    for (const name of ['Config', 'Client', 'Styles', 'Total', 'Theme']) {
+      expect(kindsOf(db, name), `${name} must stay a constant`).toContain('constant');
+      expect(kindsOf(db, name), `${name} must not be a component`).not.toContain('component');
+    }
+    // A lowercase-named memo() result is a memoization util, not a component.
+    expect(kindsOf(db, 'cache')).not.toContain('component');
+  });
+});

File diff suppressed because it is too large
+ 0 - 1
docs/design/dispatch-synthesizer-backlog.md


+ 76 - 0
src/bin/codegraph.ts

@@ -1017,6 +1017,82 @@ program
     }
   });
 
+/**
+ * codegraph prompt-hook  (hidden)
+ *
+ * A Claude Code `UserPromptSubmit` hook entry point. Reads `{prompt, cwd}` JSON
+ * on stdin; for a structural/flow/impact prompt it runs `codegraph_explore` on
+ * the indexed project and prints the result to stdout, which Claude injects into
+ * the agent's context — so the agent's reflex grep/read has nothing left to find
+ * and reliably uses CodeGraph (the adoption problem). Installed by the installer
+ * into Claude's settings.json (opt-in, default-yes).
+ *
+ * LOAD-BEARING: this must NEVER break the user's prompt. Every failure path —
+ * kill-switch, non-structural prompt, no index, engine error — exits 0 with no
+ * output. The only effect is additive context when it can confidently provide it.
+ */
+program
+  .command('prompt-hook', { hidden: true })
+  .description('Claude UserPromptSubmit hook: inject CodeGraph context for structural prompts (reads {prompt,cwd} JSON on stdin)')
+  .action(async () => {
+    try {
+      // Kill-switch: lets a user disable the nudge without uninstalling /
+      // editing settings.json (CI, low-power machines, personal preference).
+      if (process.env.CODEGRAPH_NO_PROMPT_HOOK === '1' || process.env.CODEGRAPH_PROMPT_HOOK === '0') return;
+      if (process.stdin.isTTY) return; // invoked by hand, no piped payload
+
+      const raw = await new Promise<string>((resolve) => {
+        let data = '';
+        process.stdin.setEncoding('utf8');
+        process.stdin.on('data', (c) => { data += c; });
+        process.stdin.on('end', () => resolve(data));
+        process.stdin.on('error', () => resolve(data));
+      });
+
+      let input: { prompt?: string; cwd?: string } = {};
+      try { input = JSON.parse(raw); } catch { return; }
+      const prompt = String(input.prompt || '');
+
+      // Gate: only structural / flow / impact / where-how prompts get context.
+      // A cheap regex keeps every other prompt ("fix this typo") a zero-cost
+      // no-op so we never add latency where there's no structural answer to give.
+      const STRUCTURAL = /\b(how|where|trace|flow|path|reach(?:es|ed)?|call(?:s|ed|er|ers|ee)?|depend|impact|affect|wired?|connect|implement|architect|structure|breaks?|what calls|why does)\b/i;
+      if (!prompt || !STRUCTURAL.test(prompt)) return;
+
+      // Find an indexed project: cwd, then walk up a few levels.
+      let root: string | null = null;
+      let dir = path.resolve(String(input.cwd || process.cwd()));
+      for (let i = 0; i < 6; i++) {
+        if (isInitialized(dir)) { root = dir; break; }
+        const parent = path.dirname(dir);
+        if (parent === dir) break;
+        dir = parent;
+      }
+      if (!root) return; // not indexed — the agent's normal tools apply
+
+      const { default: CodeGraph } = await loadCodeGraph();
+      const cg = await CodeGraph.open(root);
+      try {
+        const { ToolHandler } = await import('../mcp/tools');
+        const handler = new ToolHandler(cg);
+        const result = await handler.execute('codegraph_explore', { query: prompt });
+        const text = result.content[0]?.text ?? '';
+        if (!result.isError && text.trim()) {
+          // Cap the injection so a large-repo explore can't flood the prompt.
+          const MAX = 16000;
+          const body = text.length > MAX ? `${text.slice(0, MAX)}\n…(truncated; call codegraph_explore for the rest)` : text;
+          process.stdout.write(
+            `<codegraph_context note="Structural context from CodeGraph for this prompt — treat returned source as already read; call codegraph_explore for more.">\n${body}\n</codegraph_context>\n`,
+          );
+        }
+      } finally {
+        cg.destroy();
+      }
+    } catch {
+      // Degradable by contract: never surface an error to the prompt pipeline.
+    }
+  });
+
 /**
  * codegraph node <name>
  *

+ 89 - 0
src/extraction/tree-sitter.ts

@@ -44,6 +44,10 @@ export { generateNodeId } from './tree-sitter-helpers';
  */
 const RTK_HOOK_NAME_RE = /^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$/;
 
+/** React HOC callees whose result is itself a component — a PascalCase const
+ *  initialized with one of these is a component, not a constant (#841). */
+const REACT_COMPONENT_HOCS = new Set(['forwardRef', 'memo', 'React.forwardRef', 'React.memo']);
+
 /** Vue store collections whose object-literal members are the symbols an agent
  *  looks for. Extracted as function nodes so `actions`/`mutations`/`getters` are
  *  findable + readable (the foundation under any later dispatch-bridge synth). */
@@ -1421,6 +1425,71 @@ export class TreeSitterExtractor {
     this.nodeStack.pop();
   }
 
+  /**
+   * Detect a React component declared via an HOC wrapper whose result is itself a
+   * component: `forwardRef(...)`, `memo(...)`, `React.forwardRef/memo(...)`, and
+   * styled-components / emotion `styled.tag\`…\`` / `styled(Base)\`…\``. These
+   * initializers are a call / tagged-template (not a bare arrow), so the const is
+   * otherwise classified `constant` — and a constant is skipped by both the
+   * JSX-render edge synthesizer and component resolution, so `<Button/>` usages
+   * get no edge and callers/impact silently return empty (#841).
+   *
+   * Returns `{ inner }` — the inline render function to extract as the component
+   * body, or `null` when the wrapper has no inline function (`memo(Imported)`,
+   * `styled.button\`…\``) and only a bodyless component node is minted — or
+   * `undefined` when this initializer is not a recognized component wrapper.
+   */
+  private reactComponentHoc(valueNode: SyntaxNode): { inner: SyntaxNode | null } | undefined {
+    if (valueNode.type !== 'call_expression') return undefined;
+    const callee = getChildByField(valueNode, 'function');
+    if (!callee) return undefined;
+    const calleeText = getNodeText(callee, this.source);
+    // styled-components / emotion: `styled.button\`…\`` / `styled(Base)\`…\``.
+    // tree-sitter models these tagged templates as a call_expression whose callee
+    // is the `styled.x` / `styled(Base)` tag (\b avoids matching `styledFoo`).
+    // No inline render fn — the argument is the CSS template.
+    if (/^styled\b/.test(calleeText)) return { inner: null };
+    // React HOCs: `forwardRef`/`memo`/`React.forwardRef`/`React.memo`.
+    if (!REACT_COMPONENT_HOCS.has(calleeText)) return undefined;
+    // The first arrow / function-expression argument is the render fn (if inline;
+    // `memo(Imported)` passes a bare identifier and has none).
+    const args = getChildByField(valueNode, 'arguments');
+    let inner: SyntaxNode | null = null;
+    if (args) {
+      for (let i = 0; i < args.namedChildCount; i++) {
+        const a = args.namedChild(i);
+        if (a && (a.type === 'arrow_function' || a.type === 'function_expression')) {
+          inner = a;
+          break;
+        }
+      }
+    }
+    return { inner };
+  }
+
+  /**
+   * Emit a `component` node for an HOC-wrapped React component declaration (see
+   * reactComponentHoc). Named by the declarator (`Button`) and located at it so
+   * the node range spans the body. When the wrapper has an inline render
+   * function, its body is walked so the component's callees (hooks, helpers) are
+   * captured under the component node — matching how a plain
+   * `const Foo = () => …` arrow component already behaves.
+   */
+  private extractReactComponentNode(
+    name: string,
+    declarator: SyntaxNode,
+    innerFn: SyntaxNode | null,
+    extra: { docstring?: string; signature?: string; isExported?: boolean }
+  ): void {
+    const compNode = this.createNode('component', name, declarator, extra);
+    if (!compNode || !innerFn || !this.extractor) return;
+    this.nodeStack.push(compNode.id);
+    const body = this.extractor.resolveBody?.(innerFn, this.extractor.bodyField)
+      ?? getChildByField(innerFn, this.extractor.bodyField);
+    if (body) this.visitFunctionBody(body, compNode.id);
+    this.nodeStack.pop();
+  }
+
   /**
    * Extract a class
    */
@@ -2316,6 +2385,26 @@ export class TreeSitterExtractor {
             const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
             const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
 
+            // React HOC-wrapped components (`forwardRef`/`memo`/`styled`) — see
+            // reactComponentHoc. The initializer is a call / tagged-template (not
+            // a bare arrow), so without this the const is a plain `constant`,
+            // which the JSX-render synthesizer and component resolution both skip
+            // → `<Button/>` usages get no edge and callers/impact return empty
+            // (the whole shadcn/ui design-system pattern, #841). PascalCase-gated
+            // to the component naming convention so a memoization util
+            // (`const cache = memo(fn)`) stays a constant.
+            if (valueNode && /^[A-Z]/.test(name)) {
+              const hoc = this.reactComponentHoc(valueNode);
+              if (hoc) {
+                this.extractReactComponentNode(name, child, hoc.inner, {
+                  docstring,
+                  signature: initSignature,
+                  isExported,
+                });
+                continue;
+              }
+            }
+
             const varNode = this.createNode(kind, name, child, {
               docstring,
               signature: initSignature,

+ 39 - 82
src/installer/index.ts

@@ -22,14 +22,13 @@ import {
   resolveTargetFlag,
 } from './targets/registry';
 import type { AgentTarget, Location, TargetId } from './targets/types';
-import { getGlyphs } from '../ui/glyphs';
 // Import the lightweight submodules directly (not the ../sync barrel, which
 // re-exports FileWatcher and would transitively pull in ../extraction — the
 // installer must stay importable even when native modules can't load).
 import { watchDisabledReason } from '../sync/watch-policy';
 import { isGitRepo, isSyncHookInstalled, installGitSyncHook } from '../sync/git-hooks';
-import { getCodeGraphDir, codeGraphDirName, unsafeIndexRootReason } from '../directory';
-import { getTelemetry, recordIndexEvent, TELEMETRY_DOCS } from '../telemetry';
+import { getCodeGraphDir, codeGraphDirName } from '../directory';
+import { getTelemetry, TELEMETRY_DOCS } from '../telemetry';
 
 // Backwards-compat: keep these named exports — downstream code may
 // import them. The shim in `config-writer.ts` continues to re-export
@@ -48,9 +47,6 @@ export type { InstallLocation } from './config-writer';
 const importESM = new Function('specifier', 'return import(specifier)') as
   (specifier: string) => Promise<typeof import('@clack/prompts')>;
 
-function formatNumber(n: number): string {
-  return n.toLocaleString();
-}
 
 function getVersion(): string {
   try {
@@ -205,6 +201,31 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
     }
   }
 
+  // Step 4¾: front-load prompt hook (Claude Code only). A UserPromptSubmit hook
+  // that runs `codegraph prompt-hook` — it injects codegraph_explore context on
+  // structural ("how / where / trace / impact") prompts so the agent reliably
+  // reaches for the graph instead of grepping. Opt-in, default-yes. Only Claude
+  // Code has UserPromptSubmit, so it's offered only when Claude is a target;
+  // other targets ignore the option. `undefined` (no Claude / not asked) leaves
+  // any existing hook untouched.
+  let promptHook: boolean | undefined;
+  if (targets.some((t) => t.id === 'claude')) {
+    if (useDefaults) {
+      promptHook = true; // --yes → on
+    } else {
+      const ans = await clack.confirm({
+        message:
+          'Front-load CodeGraph on “how / where / trace” prompts? Auto-injects structural context so answers need fewer steps (adds a moment to those prompts; Claude Code only).',
+        initialValue: true,
+      });
+      if (clack.isCancel(ans)) {
+        clack.cancel('Installation cancelled.');
+        process.exit(0);
+      }
+      promptHook = ans; // false → opt out; install() strips any prior hook
+    }
+  }
+
   // Step 5: per-target install loop.
   const installedIds: TargetId[] = [];
   let sawCreated = false;
@@ -216,7 +237,7 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
       );
       continue;
     }
-    const result = target.install(location, { autoAllow });
+    const result = target.install(location, { autoAllow, promptHook });
     installedIds.push(target.id);
     for (const file of result.files) {
       if (file.action === 'created') sawCreated = true;
@@ -243,14 +264,17 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
     });
   }
 
-  // Step 6: for local install, initialize the project.
-  if (location === 'local') {
-    await initializeLocalProject(clack, useDefaults);
-  }
-
-  if (location === 'global') {
-    clack.note('cd your-project\ncodegraph init -i', 'Quick start');
-  }
+  // Step 6: install wires up agents only — it deliberately does NOT index.
+  // Building the per-project graph is the user's explicit `codegraph init`
+  // (or `index`), so they choose what gets indexed and when, and we never
+  // index a surprise directory (e.g. a shell sitting in $HOME). Same next step
+  // regardless of global/local scope.
+  clack.note(
+    location === 'local'
+      ? 'codegraph init        # build this project’s graph (one time; auto-syncs after)'
+      : 'cd <your-project>\ncodegraph init        # build a project’s graph (one time; auto-syncs after)',
+    'Next: index a project',
+  );
 
   // Deliver buffered telemetry while we're already in a long interactive
   // command — bounded (~1.5s worst case), invisible after a multi-second install.
@@ -490,73 +514,6 @@ async function resolveTargets(
     .filter((t): t is AgentTarget => t !== undefined);
 }
 
-/**
- * Initialize CodeGraph in the current project (for local installs), then
- * offer the watch fallback when the live watcher won't run here (see
- * offerWatchFallback). Agent-agnostic by nature.
- */
-async function initializeLocalProject(
-  clack: typeof import('@clack/prompts'),
-  useDefaults = false,
-): Promise<void> {
-  const projectPath = process.cwd();
-
-  // Never auto-index the home directory or a filesystem root. Running the
-  // installer from `$HOME` would otherwise index the entire home tree — a
-  // multi-GB index, constant watcher churn, and (pre-1.0 on macOS) fd
-  // exhaustion that crashed the machine (#845). The install itself still
-  // completes; we just skip the auto-index and point them at a real project.
-  const unsafe = unsafeIndexRootReason(projectPath);
-  if (unsafe) {
-    clack.log.warn(`Skipping automatic indexing — ${projectPath} looks like ${unsafe}.`);
-    clack.log.info('Indexing it would pull in caches, other projects, and your whole tree. Run "codegraph init" inside a specific project instead.');
-    return;
-  }
-
-  let CodeGraph: typeof import('../index').default;
-  try {
-    CodeGraph = (await import('../index')).default;
-  } catch (err) {
-    const msg = err instanceof Error ? err.message : String(err);
-    clack.log.error(`Could not load native modules: ${msg}`);
-    clack.log.info('Skipping project initialization. Run "codegraph init -i" later.');
-    return;
-  }
-
-  // Check if already initialized
-  if (CodeGraph.isInitialized(projectPath)) {
-    clack.log.info('CodeGraph already initialized in this project');
-    await offerWatchFallback(clack, projectPath, { yes: useDefaults });
-    return;
-  }
-
-  // Initialize
-  const cg = await CodeGraph.init(projectPath);
-  clack.log.success('Created .codegraph/ directory');
-
-  // Index the project with shimmer progress (worker thread for smooth animation)
-  const { createShimmerProgress } = await import('../ui/shimmer-progress');
-  process.stdout.write(`\x1b[2m${getGlyphs().rail}\x1b[0m\n`);
-  const progress = createShimmerProgress();
-
-  const result = await cg.indexAll({
-    onProgress: progress.onProgress,
-  });
-
-  await progress.stop();
-
-  if (result.filesErrored > 0) {
-    clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} failed, ${formatNumber(result.nodesCreated)} symbols)`);
-  } else {
-    clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.nodesCreated)} symbols)`);
-  }
-
-  recordIndexEvent(cg, result); // buffered; the installer flushes at the end
-
-  cg.close();
-
-  await offerWatchFallback(clack, projectPath, { yes: useDefaults });
-}
 
 /**
  * When the live file watcher will be disabled for this project (e.g. WSL2

+ 85 - 8
src/installer/targets/claude.ts

@@ -121,6 +121,18 @@ class ClaudeCodeTarget implements AgentTarget {
     const hookCleanup = cleanupLegacyHooks(loc);
     if (hookCleanup.action === 'removed') files.push(hookCleanup);
 
+    // 2c. Front-load prompt hook (Claude UserPromptSubmit). Opt-in via the
+    // installer prompt (default-yes): `promptHook === true` writes it;
+    // `=== false` strips any a prior install wrote so opting out round-trips
+    // (and an upgrade re-run honors the new choice); `undefined` leaves it
+    // untouched for callers that don't manage it.
+    if (opts.promptHook === true) {
+      files.push(writePromptHookEntry(loc));
+    } else if (opts.promptHook === false) {
+      const removed = removePromptHookEntry(loc);
+      if (removed.action === 'removed') files.push(removed);
+    }
+
     // 3. CLAUDE.md instructions — the short marker-fenced CodeGraph
     // block (#704). The MCP initialize instructions reach only the main
     // agent; CLAUDE.md is what Task-tool subagents (and non-MCP
@@ -187,6 +199,10 @@ class ClaudeCodeTarget implements AgentTarget {
     const hookCleanup = cleanupLegacyHooks(loc);
     if (hookCleanup.action === 'removed') files.push(hookCleanup);
 
+    // 2c. Remove the front-load prompt hook this installer may have written.
+    const promptHookCleanup = removePromptHookEntry(loc);
+    if (promptHookCleanup.action === 'removed') files.push(promptHookCleanup);
+
     // 3. Instructions — strip the legacy CodeGraph block if present.
     files.push(removeInstructionsEntry(loc));
 
@@ -278,6 +294,16 @@ function isLegacyCodegraphHookCommand(command: unknown): boolean {
   );
 }
 
+/**
+ * The front-load prompt-hook command the installer writes into Claude's
+ * `UserPromptSubmit` (see writePromptHookEntry). Matched by substring so an
+ * `npx @colbymchenry/codegraph prompt-hook` form is recognized too.
+ */
+const PROMPT_HOOK_COMMAND = 'codegraph prompt-hook';
+function isPromptHookCommand(command: unknown): boolean {
+  return typeof command === 'string' && command.includes(PROMPT_HOOK_COMMAND);
+}
+
 /**
  * Remove stale codegraph auto-sync hooks from Claude `settings.json`.
  *
@@ -293,7 +319,10 @@ function isLegacyCodegraphHookCommand(command: unknown): boolean {
  * Exported so it can be unit-tested directly and reused by both
  * `install` (an upgrade self-heals) and `uninstall`.
  */
-export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number] {
+function removeHookCommandsMatching(
+  loc: Location,
+  match: (command: unknown) => boolean,
+): WriteResult['files'][number] {
   const file = settingsJsonPath(loc);
   if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
 
@@ -303,7 +332,7 @@ export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number]
     return { path: file, action: 'unchanged' };
   }
 
-  // Pass 1: drop the legacy command(s) from inside every matcher group.
+  // Pass 1: drop matching command(s) from inside every matcher group.
   let removedAny = false;
   for (const event of Object.keys(hooks)) {
     const groups = hooks[event];
@@ -311,18 +340,17 @@ export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number]
     for (const group of groups) {
       if (!group || !Array.isArray(group.hooks)) continue;
       const before = group.hooks.length;
-      group.hooks = group.hooks.filter(
-        (h: any) => !isLegacyCodegraphHookCommand(h?.command),
-      );
+      group.hooks = group.hooks.filter((h: any) => !match(h?.command));
       if (group.hooks.length !== before) removedAny = true;
     }
   }
 
   if (!removedAny) return { path: file, action: 'unchanged' };
 
-  // Pass 2: prune empty matcher groups, then events with no groups
-  // left, then an empty top-level `hooks`. Guarded by `removedAny` so
-  // we never restructure a settings.json that had no codegraph hooks.
+  // Pass 2: prune empty matcher groups, then events with no groups left,
+  // then an empty top-level `hooks`. Guarded by `removedAny` so we never
+  // restructure a settings.json that had no matching hooks. Sibling hooks
+  // (a different command in the group, or a different event) survive.
   for (const event of Object.keys(hooks)) {
     const groups = hooks[event];
     if (!Array.isArray(groups)) continue;
@@ -337,6 +365,24 @@ export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number]
   return { path: file, action: 'removed' };
 }
 
+/**
+ * Remove stale codegraph auto-sync hooks (`mark-dirty` / `sync-if-dirty`) that a
+ * pre-0.8 install wrote. Exported for direct unit-testing; reused by both
+ * `install` (an upgrade self-heals) and `uninstall`.
+ */
+export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number] {
+  return removeHookCommandsMatching(loc, isLegacyCodegraphHookCommand);
+}
+
+/**
+ * Remove the front-load `UserPromptSubmit` hook this installer writes (see
+ * writePromptHookEntry). Used by `uninstall`, and by `install` when the user
+ * opts out, so the choice round-trips.
+ */
+export function removePromptHookEntry(loc: Location): WriteResult['files'][number] {
+  return removeHookCommandsMatching(loc, isPromptHookCommand);
+}
+
 export function writePermissionsEntry(loc: Location): WriteResult['files'][number] {
   const file = settingsJsonPath(loc);
   const settings = readJsonFile(file);
@@ -359,6 +405,37 @@ export function writePermissionsEntry(loc: Location): WriteResult['files'][numbe
   return { path: file, action: created ? 'created' : 'updated' };
 }
 
+/**
+ * Write the front-load `UserPromptSubmit` hook into Claude `settings.json` —
+ * a `command` hook that runs `codegraph prompt-hook`, which injects
+ * codegraph_explore context for structural prompts so the agent reliably uses
+ * the graph. Idempotent: if our command is already wired under UserPromptSubmit
+ * the file is left byte-for-byte untouched and reported `unchanged`. Sibling
+ * hooks (the user's own, or other events) are preserved. Opt-in — the installer
+ * only calls this when the user accepts the prompt (default-yes).
+ */
+export function writePromptHookEntry(loc: Location): WriteResult['files'][number] {
+  const file = settingsJsonPath(loc);
+  const created = !fs.existsSync(file);
+  const settings = readJsonFile(file);
+
+  if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
+    settings.hooks = {};
+  }
+  if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = [];
+
+  const already = settings.hooks.UserPromptSubmit.some(
+    (g: any) => g && Array.isArray(g.hooks) && g.hooks.some((h: any) => isPromptHookCommand(h?.command)),
+  );
+  if (already) return { path: file, action: 'unchanged' };
+
+  settings.hooks.UserPromptSubmit.push({
+    hooks: [{ type: 'command', command: PROMPT_HOOK_COMMAND }],
+  });
+  writeJsonFile(file, settings);
+  return { path: file, action: created ? 'created' : 'updated' };
+}
+
 /**
  * Strip the marker-delimited CodeGraph block from CLAUDE.md if a prior
  * install wrote one. Codegraph no longer maintains an instructions file

+ 7 - 0
src/installer/targets/types.ts

@@ -68,6 +68,13 @@ export interface InstallOptions {
    * target has no permissions concept this option is a no-op.
    */
   autoAllow: boolean;
+  /**
+   * Front-load prompt hook (Claude `UserPromptSubmit`) that injects
+   * codegraph_explore context for structural prompts. `true` installs it,
+   * `false` removes any prior install (so opt-out round-trips), `undefined`
+   * leaves it untouched. Targets without a prompt-hook concept ignore it.
+   */
+  promptHook?: boolean;
 }
 
 export interface AgentTarget {

+ 144 - 1
src/resolution/callback-synthesizer.ts

@@ -2504,6 +2504,147 @@ function sidekiqDispatchEdges(ctx: ResolutionContext): Edge[] {
   return edges;
 }
 
+// ── Laravel events (PHP) ──────────────────────────────────────────────────────
+// Laravel decouples an event dispatch from its listener(s), linked by the EVENT CLASS:
+//   // app/Events/PlaybackStarted.php  +  app/Listeners/UpdateLastfmNowPlaying.php
+//   class UpdateLastfmNowPlaying { public function handle(PlaybackStarted $event) { … } }
+//   // a controller / service — a DIFFERENT file
+//   event(new PlaybackStarted($song, $user));
+// Bridge it: link the enclosing method at each `event(new XEvent(...))` site → every listener's
+// `handle` for XEvent. Listeners come from TWO registration mechanisms (both real, both needed):
+//   (A) auto-discovery — a `handle(EventType $e)` typed first param (also splits a union A|B);
+//   (B) the `protected $listen = [ XEvent::class => [Listener::class, …] ]` map in an
+//       EventServiceProvider, which also covers a listener whose `handle()` is UNTYPED.
+// Only `event(new X)` is matched — queued JOBS dispatch via `::dispatch()` and their `handle()`
+// takes an injected service, never an event type, so jobs are excluded by construction.
+const LARAVEL_DISPATCH_RE = /\bevent\s*\(\s*new\s+\\?([A-Za-z_][\w\\]*)/g;
+const LARAVEL_PHP_EXT = /\.php$/;
+const LARAVEL_FANOUT_CAP = 200;
+// A `$listen` entry: `Event::class => [Listener::class, …]`, key/values as `::class` or strings.
+const LISTEN_ENTRY_RE = /(?:([A-Za-z_\\][\w\\]*)::class|'([^']+)'|"([^"]+)")\s*=>\s*\[([^\]]*)\]/g;
+const LISTEN_CLASS_RE = /(?:([A-Za-z_\\][\w\\]*)::class|'([^']+)'|"([^"]+)")/g;
+
+/** Short class name from a PHP reference: `\App\Events\Foo` / `App\Events::Foo` → `Foo`. */
+function phpSimpleName(s: string): string {
+  return s.replace(/^\\/, '').split('\\').pop()!.split('::').pop()!.trim();
+}
+
+/** The first-parameter class type(s) of a `handle(...)` declaration — union-split, short-named,
+ *  primitives dropped. `handle(A|B $e)` → [A, B]; `handle(string $x)` / `handle()` → []. */
+function laravelHandleEventTypes(decl: string): string[] {
+  const m = /function\s+handle\s*\(\s*(?:\.\.\.\s*)?(\??[A-Za-z_\\][\w\\|]*)\s+&?\s*(?:\.\.\.\s*)?\$/.exec(decl);
+  if (!m) return [];
+  return m[1]!
+    .replace(/^\?/, '')
+    .split('|')
+    .map((t) => phpSimpleName(t))
+    .filter((t) => /^[A-Z]\w*$/.test(t));
+}
+
+/** From an opening `[`, the bracket-balanced body up to its matching `]`. */
+function phpArrayBody(src: string, openIdx: number): string | null {
+  let depth = 0;
+  for (let i = openIdx; i < src.length; i++) {
+    if (src[i] === '[') depth++;
+    else if (src[i] === ']' && --depth === 0) return src.slice(openIdx + 1, i);
+  }
+  return null;
+}
+
+function laravelEventEdges(ctx: ResolutionContext): Edge[] {
+  // event short name → its listener `handle` methods (deduped by node id).
+  const listeners = new Map<string, Map<string, Node>>();
+  const add = (event: string, handle: Node) => {
+    let m = listeners.get(event);
+    if (!m) { m = new Map(); listeners.set(event, m); }
+    m.set(handle.id, handle);
+  };
+  const handleOf = (cls: Node): Node | null =>
+    ctx
+      .getNodesInFile(cls.filePath)
+      .find(
+        (n) => n.kind === 'method' && n.name === 'handle'
+          && n.startLine >= cls.startLine && n.startLine <= (cls.endLine ?? cls.startLine)
+      ) ?? null;
+
+  // Pass 1 — build the event→handle map from both registration mechanisms.
+  for (const file of ctx.getAllFiles()) {
+    if (!LARAVEL_PHP_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content) continue;
+
+    // (A) typed listener handles — node-driven, so a commented-out method can't leak in.
+    if (content.includes('function handle')) {
+      const lines = content.split('\n');
+      for (const node of ctx.getNodesInFile(file)) {
+        if (node.kind !== 'method' || node.name !== 'handle') continue;
+        const decl = lines.slice(node.startLine - 1, node.startLine + 2).join('\n');
+        for (const ev of laravelHandleEventTypes(decl)) add(ev, node);
+      }
+    }
+
+    // (B) the EventServiceProvider `$listen` map — parsed from comment-stripped source so a
+    // fully-commented map (firefly's, on auto-discovery) contributes nothing.
+    if (content.includes('$listen')) {
+      const safe = stripCommentsForRegex(content, 'php');
+      const decl = safe.search(/\$listen\s*=\s*\[/);
+      const body = decl >= 0 ? phpArrayBody(safe, safe.indexOf('[', decl)) : null;
+      if (body) {
+        LISTEN_ENTRY_RE.lastIndex = 0;
+        let em: RegExpExecArray | null;
+        while ((em = LISTEN_ENTRY_RE.exec(body))) {
+          const event = phpSimpleName(em[1] ?? em[2] ?? em[3] ?? '');
+          LISTEN_CLASS_RE.lastIndex = 0;
+          let lm: RegExpExecArray | null;
+          while ((lm = LISTEN_CLASS_RE.exec(em[4]!))) {
+            const ln = phpSimpleName(lm[1] ?? lm[2] ?? lm[3] ?? '');
+            const cls = ctx.getNodesByName(ln).find((n) => n.kind === 'class' && handleOf(n));
+            if (cls) add(event, handleOf(cls)!);
+          }
+        }
+      }
+    }
+  }
+  if (!listeners.size) return [];
+
+  // Pass 2 — link each event(new X(...)) site → every listener of X.
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!LARAVEL_PHP_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || !content.includes('event(')) continue;
+    const safe = stripCommentsForRegex(content, 'php');
+    const nodesInFile = ctx.getNodesInFile(file);
+    LARAVEL_DISPATCH_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    let added = 0;
+    while ((m = LARAVEL_DISPATCH_RE.exec(safe)) && added < LARAVEL_FANOUT_CAP) {
+      const targets = listeners.get(phpSimpleName(m[1]!));
+      if (!targets) continue;
+      const line = safe.slice(0, m.index).split('\n').length;
+      const disp = enclosingFn(nodesInFile, line);
+      if (!disp) continue;
+      for (const target of targets.values()) {
+        if (target.id === disp.id) continue;
+        const key = `${disp.id}>${target.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: disp.id,
+          target: target.id,
+          kind: 'calls',
+          line,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'laravel-event', via: phpSimpleName(m[1]!), registeredAt: `${file}:${line}` },
+        });
+        added++;
+      }
+    }
+  }
+  return edges;
+}
+
 /**
  * Synthesize dispatcher→callback edges (field observers + EventEmitters +
  * React re-render + JSX children + Vue templates + SvelteKit load + RN event
@@ -2512,7 +2653,7 @@ function sidekiqDispatchEdges(ctx: ResolutionContext): Edge[] {
  * generated-hook → endpoint + Pinia useStore().action() + Vuex string dispatch +
  * Celery task .delay()/.apply_async() → task body + Spring publishEvent → @EventListener +
  * MediatR Send/Publish → IRequestHandler/INotificationHandler +
- * Sidekiq Worker.perform_async → #perform).
+ * Sidekiq Worker.perform_async → #perform + Laravel event(new X) → listener handle).
  * Returns the count added. Never throws into indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
@@ -2559,6 +2700,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const springEdges = springEventEdges(ctx);
   const mediatrEdges = mediatrDispatchEdges(ctx);
   const sidekiqEdges = sidekiqDispatchEdges(ctx);
+  const laravelEdges = laravelEventEdges(ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
@@ -2591,6 +2733,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...springEdges,
     ...mediatrEdges,
     ...sidekiqEdges,
+    ...laravelEdges,
   ]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;

+ 17 - 65
src/resolution/frameworks/react.ts

@@ -9,7 +9,12 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from
 
 export const reactResolver: FrameworkResolver = {
   name: 'react',
-  languages: ['javascript', 'typescript'],
+  // Includes 'tsx'/'jsx' so route extraction runs on JSX files (where
+  // `<Route element={<X/>}>` routes live) — without them the .tsx/.jsx grammars
+  // were filtered out of the extract pass and those routes were never indexed.
+  // (resolve() is unaffected — it runs for every detected framework regardless
+  // of language; only the extract pass filters on `languages`.)
+  languages: ['javascript', 'typescript', 'tsx', 'jsx'],
 
   detect(context: ResolutionContext): boolean {
     // Check for React in package.json
@@ -90,70 +95,17 @@ export const reactResolver: FrameworkResolver = {
     const references: UnresolvedRef[] = [];
     const now = Date.now();
 
-    // Extract component definitions
-    // function Component() or const Component = () =>
-    const componentPatterns = [
-      // Function components
-      /(?:export\s+)?function\s+([A-Z][a-zA-Z0-9]*)\s*\(/g,
-      // Arrow function components
-      /(?:export\s+)?(?:const|let)\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:\([^)]*\)|[a-zA-Z_][a-zA-Z0-9_]*)\s*=>/g,
-      // forwardRef components
-      /(?:export\s+)?(?:const|let)\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:React\.)?forwardRef/g,
-      // memo components
-      /(?:export\s+)?(?:const|let)\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:React\.)?memo/g,
-    ];
-
-    for (const pattern of componentPatterns) {
-      let match;
-      while ((match = pattern.exec(content)) !== null) {
-        const [fullMatch, name] = match;
-        const line = content.slice(0, match.index).split('\n').length;
-
-        // Check if it returns JSX (rough heuristic)
-        const afterMatch = content.slice(match.index + fullMatch.length, match.index + fullMatch.length + 500);
-        const hasJSX = afterMatch.includes('<') && (afterMatch.includes('/>') || afterMatch.includes('</'));
-
-        if (hasJSX) {
-          nodes.push({
-            id: `component:${filePath}:${name}:${line}`,
-            kind: 'component',
-            name: name!,
-            qualifiedName: `${filePath}::${name}`,
-            filePath,
-            startLine: line,
-            endLine: line,
-            startColumn: 0,
-            endColumn: fullMatch.length,
-            language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
-            isExported: fullMatch.includes('export'),
-            updatedAt: now,
-          });
-        }
-      }
-    }
-
-    // Extract custom hooks
-    const hookPattern = /(?:export\s+)?(?:function|const|let)\s+(use[A-Z][a-zA-Z0-9]*)\s*[=(]/g;
-    let hookMatch;
-    while ((hookMatch = hookPattern.exec(content)) !== null) {
-      const [fullMatch, name] = hookMatch;
-      const line = content.slice(0, hookMatch.index).split('\n').length;
-
-      nodes.push({
-        id: `hook:${filePath}:${name}:${line}`,
-        kind: 'function',
-        name: name!,
-        qualifiedName: `${filePath}::${name}`,
-        filePath,
-        startLine: line,
-        endLine: line,
-        startColumn: 0,
-        endColumn: fullMatch.length,
-        language: filePath.endsWith('.ts') || filePath.endsWith('.tsx') ? 'typescript' : 'javascript',
-        isExported: fullMatch.includes('export'),
-        updatedAt: now,
-      });
-    }
+    // Components and custom hooks are NOT extracted here. The tree-sitter
+    // extractor already emits them natively across .ts/.tsx/.js/.jsx — function
+    // and arrow components as `function` nodes, HOC-wrapped components
+    // (`forwardRef`/`memo`/`styled`) as `component` nodes (#841), and `useX`
+    // hooks as `function` nodes. Re-deriving them here with regex only ran on
+    // .ts/.js anyway (this resolver's `languages` didn't include the 'tsx'/'jsx'
+    // grammars), and it DUPLICATED those tree-sitter nodes (e.g. a `useAuth`
+    // ended up as two `function` nodes). This `extract` now contributes only
+    // what tree-sitter can't: route nodes (React Router + Next.js conventions),
+    // which is why 'tsx'/'jsx' are now in `languages` — `<Route>`/`element={<X/>}`
+    // routes live in JSX files and were previously skipped entirely.
 
     // React Router: <Route path="/x" component={Comp}/> (v5) or
     // <Route path="/x" element={<Comp/>}/> (v6). Attributes appear in any order,

+ 41 - 4
src/upgrade/index.ts

@@ -340,16 +340,21 @@ export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promi
     return 0;
   }
 
-  // Dispatch by install method.
+  // Dispatch by install method. bundle/npm perform a real binary update, so
+  // after they succeed we self-heal the front-load hook (below); npx/source/
+  // unknown don't update anything here, so they return directly.
+  let code: number;
   switch (method.kind) {
     case 'bundle':
-      return method.os === 'windows'
+      code = await (method.os === 'windows'
         ? upgradeWindowsBundle(method, latest, deps)
-        : upgradeUnixBundle(method, opts.version ? latest : undefined, deps);
+        : upgradeUnixBundle(method, opts.version ? latest : undefined, deps));
+      break;
     case 'npm':
       // npm version specs have no leading "v" (`@0.9.8`, not `@v0.9.8` — the
       // latter resolves as a nonexistent dist-tag).
-      return upgradeNpm(method, opts.version ? stripV(latest) : 'latest', deps);
+      code = await upgradeNpm(method, opts.version ? stripV(latest) : 'latest', deps);
+      break;
     case 'npx':
       deps.log(c.green('npx always runs the latest version on demand — nothing to upgrade.'));
       deps.log(c.dim(`Force a fresh fetch with: npx ${NPM_PACKAGE}@latest`));
@@ -363,6 +368,38 @@ export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promi
       deps.log(c.dim(`Reinstall manually — see https://github.com/${REPO}#install`));
       return 1;
   }
+
+  // After a successful update, ensure the front-load prompt hook is wired for an
+  // already-configured global Claude install — so existing users pick it up on
+  // upgrade, not only on a fresh `install` (the hook config is version-agnostic,
+  // so the still-running old binary can write it safely). Idempotent + gated on
+  // an existing Claude config, and skipped entirely by the kill-switch. Never
+  // fatal to the upgrade.
+  if (code === 0) {
+    try {
+      await selfHealPromptHook(deps);
+    } catch {
+      /* a hook-wiring hiccup must not fail the upgrade */
+    }
+  }
+  return code;
+}
+
+/**
+ * Wire the Claude `UserPromptSubmit` front-load hook on upgrade for an
+ * already-configured global Claude install. No-op when Claude isn't configured,
+ * when the hook is already present, or when the kill-switch is set.
+ */
+async function selfHealPromptHook(deps: UpgradeDeps): Promise<void> {
+  if (process.env.CODEGRAPH_NO_PROMPT_HOOK === '1' || process.env.CODEGRAPH_PROMPT_HOOK === '0') return;
+  const { claudeTarget, writePromptHookEntry } = await import('../installer/targets/claude');
+  if (!claudeTarget.detect('global').alreadyConfigured) return;
+  const res = writePromptHookEntry('global');
+  if (res.action === 'created' || res.action === 'updated') {
+    deps.log(
+      c.dim('Enabled the CodeGraph front-load hook for Claude Code (structural prompts). Disable any time: CODEGRAPH_NO_PROMPT_HOOK=1'),
+    );
+  }
 }
 
 function upgradeUnixBundle(

Some files were not shown because too many files changed in this diff