Two lenses over a local tree beside grep: a structural dependency graph and a semantic cache, built in the background and read through four MCP tools that…

A dependency graph and a semantic search over a git repository, so the agent can ask "where is X" and "what breaks if I change X" without a research subagent.
Grep finds words, and an agent that navigates a codebase by words misses what the code means and how it hangs together. qsm-index exists to give the agent two other lenses over a repository: a graph of what depends on what, and a semantic search that returns the chunks that matter, both from one persisted index a session can query as tools. It takes the stance that the index is built once and read many times, so building is cheap to repeat and reading is cheap to trust. It refuses to be a language server or a substitute for reading the file. When the harness ships structural and semantic search of its own, this plugin has no reason to exist.
| Kind | Name | What it does |
|---|---|---|
| Skill | code-index | Fires when you need to find something in a tree the agent has not read, or see what depends on what. |
| Hook | session.start, prompt.submit | Decide whether to build an index, and ask you once through the first prompt. |
| Hook | tool.call | Refresh an indexed repository after a Bash git commit and after 20 distinct files edited. |
| MCP | qsm-index | Six tools that never block: search, structure, impact, context, reindex, status. |
| CLI | qsm-index | 11 verbs, see USAGE.md. |
claude plugin install qsm-index@qsm-marketplace
bun plugins/qsm-index/scripts/install.ts
uv tool install semble # optional
Install compiles qsm-index into $QSM_BIN_DIR (default ~/.local/bin) and upgrades graphify to 0.8.34 or newer with uv tool install -U graphifyy. Without semble, a search is partial. See INSTALL.md.
Ask the agent: "what depends on parser.ts?" or "where is the config loaded?"
qsm-index reindex "$PWD" # starts a background build, returns at once
qsm-index status "$PWD" # present or absent, behind HEAD, running, last result
qsm-index search "$PWD" "where is the parser"
qsm-index structure "$PWD" explain parser.ts
qsm-index impact "$PWD" parser.ts
qsm-index context "$PWD" src/parser.ts:40
qsm-index doctor
A path inside a git repository stands for the repository's top level.
<cache>/<pathHash>/, where <cache> is QSM_INDEX_CACHE_DIR or ~/.cache/qsm-index. A second build while one runs starts nothing. A lock left by a killed build is taken over.<repo>/graphify-out a symlink to it and adds graphify-out to the repository's git exclude file, so git status shows nothing new. A real graphify-out directory is left alone.impact answers unknown: for a node nothing depends on, never "safe": the graph misses dynamic and cross-language uses.hooks/register.tsx 88 lines1// qsm-index's function-hooks module: the noun, and the native events that each call one verb of it.
2// Every rule is the verb's (lib/); a hook picks the verb and hands the session what the verb said.
3import type { Caught, EngineInterface, On } from "claude-code";
4import { registerNoun } from "./noun.generated.ts";
5
6/** A command whose tokens hold `git` then `commit`: a token check, not a parse; a false positive
7 * only starts a refresh the build lock dedupes. */
8const COMMITS = /(?:^|[\s;&|()])git[\s;&|()]+commit(?:[\s;&|()]|$)/;
9
10/** What the session start owes the first prompt: the ask, or the line saying auto failed. The
11 * engine has no hidden-context event at session start, so it rides the first prompt. */
12let owed: string | undefined;
13
14/** The `askText` of the auto verb's data, when it answered ask. */
15function askTextOf(data: unknown): string | undefined {
16 const askText: unknown =
17 typeof data === "object" && data !== null ? Reflect.get(data, "askText") : null;
18 return typeof askText === "string" ? askText : undefined;
19}
20
21/** What every `.catch` here does: a toast and a log for the person, and the failure as the one
22 * line the agent may be told. */
23function failed($: EngineInterface, event: string, error: Caught["error"]): string {
24 const said = error.message ?? error.kind;
25 $.ui.toast(`qsm-index ERROR: ${event}: ${said.slice(0, 120)}`);
26 $.ui.log(`${event} failed open (${error.kind}): ${said}`);
27 return said;
28}
29
30/** The tool runs first, then a refresh when it succeeded. The tool's result goes back unchanged;
31 * a refresh that fails is a toast and a log, and never a second run of the tool. */
32async function refreshAfter<E, R extends { isError?: boolean }>(
33 $: EngineInterface,
34 e: E,
35 next: (e: E) => Promise<R>,
36 refresh: (cwd: string) => Promise<unknown>,
37): Promise<R> {
38 const r = await next(e);
39 if (r.isError !== true) {
40 // A host spawn that fails with ENOENT names the command, but the session's cwd being gone
41 // gives the same message, so the log names the cwd the refresh ran in.
42 let cwd = "(session cwd unknown)";
43 try {
44 cwd = await $.session.cwd();
45 await refresh(cwd);
46 } catch (error) {
47 $.ui.toast(`qsm-index ERROR: tool.call: ${String(error).slice(0, 120)}`);
48 $.ui.log(`tool.call refresh failed open in ${cwd}: ${String(error)}`);
49 }
50 }
51 return r;
52}
53
54export function register(on: On): void {
55 // Adds $.index: one method per qsm-index verb, generated by
56 // lib/generate-noun.ts. Never hand-edit noun.generated.ts or types/index.d.ts.
57 registerNoun(on);
58
59 on("session.start", async ($, e, next) => {
60 owed = askTextOf(await $.index.auto({ path: e.cwd }));
61 return next(e);
62 }).catch(($, e, next) => {
63 owed = failed($, "session.start", next.error);
64 return next(e);
65 });
66
67 on("prompt.submit", (_$, e, next) => {
68 const text = owed;
69 owed = undefined;
70 return next(text === undefined ? e : { ...e, context: [...(e.context ?? []), text] });
71 });
72
73 on("tool.call", { tool: "Bash", command: COMMITS }, ($, e, next) =>
74 refreshAfter($, e, next, (cwd) => $.index.reindex({ path: cwd, ifIndexed: true })),
75 ).catch(($, e, next) => {
76 failed($, "tool.call", next.error);
77 return next(e);
78 });
79
80 on("tool.call", { tool: ["Write", "Edit", "NotebookEdit"] }, ($, e, next) => {
81 const path = e.tool === "NotebookEdit" ? e.notebook_path : e.file_path;
82 return refreshAfter($, e, next, (cwd) => $.index.edited({ path, cwd }));
83 }).catch(($, e, next) => {
84 failed($, "tool.call", next.error);
85 return next(e);
86 });
87}
88hooks/noun.generated.ts 134 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { Index } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9 readonly name: string;
10 readonly flag: string | null;
11 readonly boolean?: true;
12}
13
14export interface VerbRow {
15 readonly words: readonly string[];
16 readonly args: readonly VerbArg[];
17 readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21 build: { words: ["qsm-index","build"], args: [{ name: "path", flag: null }] },
22 search: { words: ["qsm-index","search"], args: [{ name: "path", flag: null }, { name: "query", flag: null }, { name: "top_k", flag: "--top_k" }] },
23 structure: { words: ["qsm-index","structure"], args: [{ name: "repo", flag: null }, { name: "op", flag: null }, { name: "node", flag: null }, { name: "to", flag: "--to" }] },
24 impact: { words: ["qsm-index","impact"], args: [{ name: "repo", flag: null }, { name: "node", flag: null }, { name: "depth", flag: "--depth" }] },
25 context: { words: ["qsm-index","context"], args: [{ name: "repo", flag: null }, { name: "target", flag: null }] },
26 reindex: { words: ["qsm-index","reindex"], args: [{ name: "path", flag: null }, { name: "ifIndexed", flag: "--if-indexed", boolean: true }] },
27 edited: { words: ["qsm-index","edited"], args: [{ name: "path", flag: null }, { name: "cwd", flag: "--cwd" }] },
28 status: { words: ["qsm-index","status"], args: [{ name: "path", flag: null }] },
29 auto: { words: ["qsm-index","auto"], args: [{ name: "path", flag: null }, { name: "consent", flag: "--consent" }] },
30 doctor: { words: ["qsm-index","doctor"], args: [] },
31};
32
33/** One verb's argv: its words, each option in declaration order, the
34 * passthrough rest after `--`, and `--json` last. */
35export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
36 const argv = [...row.words];
37 for (const arg of row.args) {
38 const value = options[arg.name];
39 const values = Array.isArray(value) ? value : [value];
40 for (const one of values) {
41 if (one === undefined || one === false) {
42 continue;
43 }
44 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
45 }
46 }
47 const rest = options.rest;
48 if (row.passthrough && Array.isArray(rest)) {
49 argv.push("--", ...rest.map(String));
50 }
51 argv.push("--json");
52 return argv;
53}
54
55/** The binary prints one outcome envelope on stdout under --json:
56 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
57 * code and message become the error. A stale or misbehaving binary that
58 * prints something else (not JSON, or JSON with no numeric `exit`) throws
59 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
60export function unwrap(
61 argv: readonly string[],
62 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
63 label = argv.slice(0, 2).join(" "),
64): unknown {
65 const text = result.stdout.trim();
66 const stderr = result.stderr.trim().slice(0, 200);
67 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
68 if (text.length === 0) {
69 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
70 }
71 let parsed: unknown;
72 try {
73 parsed = JSON.parse(text);
74 } catch {
75 parsed = undefined;
76 }
77 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
78 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
79 }
80 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
81 if (envelope.exit !== 0) {
82 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
83 }
84 return envelope.data;
85}
86
87/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
88 * when the cause may carry something the toast must not show, else the cause; the cause goes to
89 * the log and comes back for the caller's own policy. */
90export function failure(
91 $: EngineInterface,
92 event: string,
93 error: Caught["error"],
94 fixedReason?: string,
95): string {
96 const said = error.message ?? error.kind;
97 $.ui.toast(`qsm-index ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
98 $.ui.log(`${event} failed (${error.kind}): ${said}`);
99 return said;
100}
101
102/** Adds $.index to the engine. Call once from the plugin's own
103 * `register(on, options)` — never call the engine.create hook's own
104 * `next(e)` result from anywhere but here. */
105export function registerNoun(on: On): void {
106 on("engine.create", async (_$, e, next) => {
107 const beneath = await next(e);
108 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
109 const argv = argvOf(row, options);
110 let result: Awaited<ReturnType<typeof beneath.process.run>>;
111 try {
112 result = await beneath.process.run(argv);
113 } catch (error) {
114 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
115 // check that cannot run leaves the spawn's own error as the cause.
116 const gone = await beneath.session
117 .cwd()
118 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
119 .catch(() => null);
120 if (gone !== null) {
121 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
122 }
123 throw error;
124 }
125 return unwrap(argv, result, row.words.join(" "));
126 };
127 const methods = Object.entries(verbs).map(([method, row]) => [
128 method,
129 (options?: Readonly<Record<string, unknown>>) => run(row, options),
130 ]);
131 return { ...beneath, index: Object.fromEntries(methods) as Index };
132 });
133}
134types/index.d.ts 51 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type Index = {
5 build(options: {
6 path: string;
7 }): Promise<unknown>;
8 search(options: {
9 path: string;
10 query: string;
11 top_k?: number;
12 }): Promise<unknown>;
13 structure(options: {
14 repo: string;
15 op: "explain" | "path";
16 node: string;
17 to?: string;
18 }): Promise<unknown>;
19 impact(options: {
20 repo: string;
21 node: string;
22 depth?: number;
23 }): Promise<unknown>;
24 context(options: {
25 repo: string;
26 target: string;
27 }): Promise<unknown>;
28 reindex(options: {
29 path: string;
30 ifIndexed?: boolean;
31 }): Promise<unknown>;
32 edited(options: {
33 path: string;
34 cwd?: string;
35 }): Promise<unknown>;
36 status(options: {
37 path: string;
38 }): Promise<unknown>;
39 auto(options: {
40 path: string;
41 consent?: "yes" | "no";
42 }): Promise<unknown>;
43 doctor(): Promise<unknown>;
44};
45
46declare module 'claude-code' {
47 interface EngineInterface {
48 index: Index;
49 }
50}
51