@sdsrs/code-graph 0.143.0 → 0.145.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
@@ -528,6 +528,7 @@ defaults are what you get by doing nothing.
528
528
  | `CODE_GRAPH_NO_AUTO_UPDATE=1` | Never check GitHub for a new release. |
529
529
  | `CODE_GRAPH_NO_AUTO_ADOPT=1` | Do not write the steering block into a project's `CLAUDE.md` on SessionStart. |
530
530
  | `CODE_GRAPH_NO_TEMPLATE_REFRESH=1` | Keep hand edits to the generated steering block — it is otherwise refreshed to the current template. |
531
+ | `CODE_GRAPH_NO_GITIGNORE=1` | Never touch `.gitignore` — not even to add the `.code-graph/` entry. |
531
532
  | `CODE_GRAPH_QUIET_HOOKS=1` | Hooks inject a one-line pointer instead of the full decision table. |
532
533
  | `CODE_GRAPH_VERBOSE_HOOKS=1` | The opposite: opt into the noisy form. |
533
534
  | `CODE_GRAPH_NO_BLOCK_GREP=1` | Never turn a `grep` hint into a block — prefix a single command with it to get past one. |
package/bin/cli.js CHANGED
@@ -1,159 +1,20 @@
1
1
  #!/usr/bin/env node
2
-
3
- const { spawn } = require("child_process");
4
- const path = require("path");
5
-
6
- // Tell find-binary.js our package root so it can locate bundled binaries
7
- // and detect dev mode from bin/ → repo root (one level up)
8
- process.env._FIND_BINARY_ROOT = path.resolve(__dirname, "..");
9
-
10
- // Intercept adopt / unadopt before forwarding — they're node-only concerns
11
- // (write <cwd>/CLAUDE.md + <cwd>/.claude/) and have no Rust counterpart.
12
- // Lets `code-graph-mcp adopt` / `unadopt` work uniformly across plugin / npm / npx.
13
- const sub = process.argv[2];
14
-
15
- // Reject unknown flags on the intercepted subcommands BEFORE doing their work.
16
- // `--help` was already guarded, but every OTHER token was ignored, so
17
- // `code-graph-mcp adopt --helpp` ran adopt and wrote the user's CLAUDE.md — a
18
- // typo away from the very side effect the --help guard exists to prevent. This
19
- // is the fourth entry point onto the same "ignore what you don't recognise"
20
- // idiom (doctor.js, lifecycle.js doctor, src/main.rs were the first three); it
21
- // is the npm/npx surface, so it is the one most users reach.
22
- function rejectUnknownFlags(name, known) {
23
- const unknown = process.argv.slice(3).filter((a) => !known.has(a));
24
- if (unknown.length) {
25
- process.stderr.write(
26
- `code-graph-mcp ${name}: unknown argument(s): ${unknown.join(" ")}\n` +
27
- `Run \`code-graph-mcp ${name} --help\` for usage.\n`);
28
- process.exit(2);
29
- }
30
- }
31
- if (sub === "adopt" || sub === "unadopt") {
32
- // `--help`/`-h` must be side-effect-free: adopt() writes the memory file +
33
- // MEMORY.md sentinel, unadopt() removes them. The Rust binary guards this for
34
- // direct invocation, but npm/npx routes through this wrapper, which intercepts
35
- // adopt/unadopt *before* the binary — so the guard must be repeated here, or
36
- // `code-graph-mcp adopt --help` rewrites MEMORY.md (the common new-user path).
37
- if (process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
38
- process.stdout.write(sub === "adopt"
39
- // Kept in sync with src/main.rs's adopt/unadopt help. This text described
40
- // the pre-v0.74 scheme (a sentinel in the ~/.claude memory dir) for three
41
- // releases after the target moved to the project's own CLAUDE.md, so npm
42
- // users were told this command edits a file it has not touched since.
43
- ? "code-graph-mcp adopt — install the code-graph steering block into the project CLAUDE.md\n\n" +
44
- "USAGE:\n code-graph-mcp adopt\n\n" +
45
- "Writes a sentinel-wrapped managed block into <cwd>/CLAUDE.md plus a\n" +
46
- "<cwd>/.claude/plugin_code_graph_mcp.md detail doc, so Claude Code loads the\n" +
47
- "decision table each session. Run `code-graph-mcp unadopt` to remove it.\n"
48
- : "code-graph-mcp unadopt — remove the code-graph steering block\n\n" +
49
- "USAGE:\n code-graph-mcp unadopt\n\n" +
50
- "Reverses `code-graph-mcp adopt`: strips the managed block from\n" +
51
- "<cwd>/CLAUDE.md and deletes <cwd>/.claude/plugin_code_graph_mcp.md.\n" +
52
- "User content outside the sentinel is kept.\n");
53
- process.exit(0);
54
- }
55
- rejectUnknownFlags(sub, new Set(["--help", "-h"]));
56
- const { adopt, unadopt, formatResult } = require("../claude-plugin/scripts/adopt");
57
- const result = sub === "unadopt" ? unadopt() : adopt();
58
- process.stdout.write(formatResult(sub, result) + "\n");
59
- process.exit(result.ok === false ? 1 : 0);
60
- }
61
-
62
- // Intercept `uninstall` — full local teardown (restore prior statusline, strip
63
- // code-graph hooks from settings.json, delete ~/.cache/code-graph, and unadopt the
64
- // CURRENT project). Node-only; no Rust counterpart. CC's `/plugin uninstall` fires
65
- // no uninstall hook, so this is the user's one-shot CLI teardown. Guard `--help`
66
- // before the destructive work (same discipline as adopt/unadopt above).
67
- if (sub === "uninstall") {
68
- if (process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
69
- process.stdout.write(
70
- "code-graph-mcp uninstall — remove code-graph config + cache from this machine\n\n" +
71
- "USAGE:\n code-graph-mcp uninstall [--unadopt-all] [--purge-global]\n\n" +
72
- "Restores your prior statusline, strips code-graph hooks from settings.json,\n" +
73
- "deletes ~/.cache/code-graph, and removes this project's CLAUDE.md adoption\n" +
74
- "block. --unadopt-all also removes the managed block + detail file from every\n" +
75
- "registered adopted project; --purge-global removes the globally-installed\n" +
76
- "@sdsrs npm packages even without the plugin-install marker. Also run\n" +
77
- "`/plugin uninstall code-graph-mcp` in Claude Code to sync its UI.\n");
78
- process.exit(0);
79
- }
80
- rejectUnknownFlags("uninstall", new Set(["--help", "-h", "--unadopt-all", "--purge-global"]));
81
- const lifecycle = require("../claude-plugin/scripts/lifecycle");
82
- const { unadopt } = require("../claude-plugin/scripts/adopt");
83
- const r = lifecycle.uninstall({
84
- purgeGlobal: process.argv.slice(3).includes("--purge-global"),
85
- unadoptAll: process.argv.slice(3).includes("--unadopt-all"),
86
- });
87
- let ua = { ok: false };
88
- try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
89
- const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
90
- let out =
91
- `Uninstalled code-graph-mcp | settings cleaned=${r.settingsChanged}` +
92
- ` | this project unadopted=${projectUnadopted}\n`;
93
- if (r.globalPkgsRemoved.length) {
94
- out += ` Removed global npm package(s): ${r.globalPkgsRemoved.join(", ")}\n`;
95
- }
96
- if (r.globalPkgsRemaining.length) {
97
- out += ` Global npm package(s) still installed: ${r.globalPkgsRemaining.join(", ")}\n` +
98
- ` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(" ")}` +
99
- (r.pluginInstalledGlobals ? "\n" : " (or re-run with --purge-global)\n");
100
- }
101
- if (r.unadopted.length) {
102
- const cleaned = r.unadopted.filter((u) => u.cleaned).length;
103
- out += ` Unadopted ${cleaned}/${r.unadopted.length} registered project(s) (--unadopt-all).\n`;
104
- }
105
- const otherAdopted = r.adoptedProjects.filter((p) => p !== process.cwd());
106
- if (otherAdopted.length) {
107
- out += " Other adopted project(s) — re-run with --unadopt-all, or in each:" +
108
- " `code-graph-mcp unadopt` + `rm -rf .code-graph`\n" +
109
- otherAdopted.map((p) => ` ${p}\n`).join("");
110
- }
111
- out += " Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its UI state.\n";
112
- process.stdout.write(out);
113
- process.exit(0);
114
- }
115
-
116
- const { findBinary, unsupportedPlatformHint } = require("../claude-plugin/scripts/find-binary");
117
-
118
- const binary = findBinary();
119
-
120
- if (!binary) {
121
- const hint = unsupportedPlatformHint();
122
- console.error(
123
- "Error: code-graph-mcp binary not found.\n\n" +
124
- (hint ? hint + "\n\n" : "") +
125
- "To install:\n" +
126
- " npm install -g @sdsrs/code-graph\n\n" +
127
- "To build from source:\n" +
128
- " cargo install code-graph-mcp --features embed-model\n"
129
- );
130
- process.exit(1);
131
- }
132
-
133
- // Spawn the binary, forwarding stdio for MCP JSON-RPC communication
134
- // windowsHide as a literal rather than via claude-plugin/scripts/proc-opts:
135
- // this file is the npm package's bin entry and ships in tarballs that may not
136
- // carry claude-plugin/. Same meaning — CREATE_NO_WINDOW, so a console-less
137
- // parent (the MCP client) does not flash a window; inherited stdio still works.
138
- const child = spawn(binary, process.argv.slice(2), {
139
- stdio: "inherit",
140
- env: process.env,
141
- windowsHide: true,
142
- });
143
-
144
- child.on("error", (err) => {
145
- console.error(`Failed to start code-graph-mcp: ${err.message}`);
146
- // A glibc binary installed on musl (older npm ignores the `libc` field) is present
147
- // but fails to exec — surface the actionable platform hint instead of a bare error.
148
- const hint = unsupportedPlatformHint();
149
- if (hint) console.error("\n" + hint);
150
- process.exit(1);
151
- });
152
-
153
- child.on("exit", (code, signal) => {
154
- if (signal) {
155
- process.kill(process.pid, signal);
156
- } else {
157
- process.exit(code ?? 1);
158
- }
2
+ 'use strict';
3
+ /**
4
+ * The npm package's `code-graph-mcp` / `code-graph` bin entry.
5
+ *
6
+ * The dispatcher it used to hold now lives in claude-plugin/scripts/cli-entry.js,
7
+ * shared with the plugin's own launcher (claude-plugin/bin/code-graph-mcp) so
8
+ * the two PATH names cannot drift. `claude-plugin/` is in this package's `files`
9
+ * list and this entry already required from it, so nothing new is depended on
10
+ * here — a comment in the old body claimed some tarballs omit that directory,
11
+ * which was already false of the top-level `find-binary` require above it.
12
+ *
13
+ * `_FIND_BINARY_ROOT` is this package's root, one level up from bin/: it is what
14
+ * lets find-binary.js find a bundled `bin/<binary>` and detect a dev checkout.
15
+ */
16
+ const path = require('path');
17
+
18
+ require('../claude-plugin/scripts/cli-entry').main({
19
+ findBinaryRoot: path.resolve(__dirname, '..'),
159
20
  });
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.143.0",
7
+ "version": "0.145.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * The plugin's `code-graph-mcp` on PATH.
5
+ *
6
+ * Claude Code adds `<plugin-root>/bin` to the Bash tool's PATH for every
7
+ * enabled plugin. This plugin shipped no such directory, so the PATH entry
8
+ * pointed at nothing and every steering surface that printed the bare name —
9
+ * the CLAUDE.md block's six rows, the MCP `instructions` field, the `unadopt`
10
+ * escape hatch — printed a command the user's shell answered with "command not
11
+ * found" (issue #41). A `/plugin install` downloads the native binary to
12
+ * `~/.cache/code-graph/bin/` and resolves it internally; nothing ever put a
13
+ * name in front of it.
14
+ *
15
+ * Deliberately a Node script and not a symlink to that cached binary: the
16
+ * binary is fetched at runtime, after this file is already on PATH, so a
17
+ * symlink would be dangling exactly when a new user first types the command.
18
+ * This can explain itself instead — and it reaches `adopt` / `unadopt` /
19
+ * `uninstall`, which are JS-dispatched and have no counterpart in the binary.
20
+ *
21
+ * No logic lives here. See scripts/cli-entry.js for why there is one dispatcher
22
+ * behind both PATH names, and for why this entry point passes no
23
+ * `findBinaryRoot`.
24
+ */
25
+ require('../scripts/cli-entry').main();
@@ -0,0 +1,11 @@
1
+ @echo off
2
+ REM Windows PATHEXT sibling of the extensionless launcher beside it: cmd.exe
3
+ REM will not execute a file with no extension, so the same PATH entry that works
4
+ REM on POSIX resolves nothing here without this shim. Runs the very same file
5
+ REM through node rather than duplicating its three lines.
6
+ node "%~dp0code-graph-mcp" %*
7
+ REM Propagate the exit code explicitly. cmd.exe leaves %ERRORLEVEL% set after the
8
+ REM last command, but a caller that runs this through `cmd /c` gets the SCRIPT's
9
+ REM status, and relying on the implicit path is what makes a non-zero `doctor` or
10
+ REM a failed search look like success to a wrapper.
11
+ exit /b %ERRORLEVEL%
@@ -97,6 +97,91 @@ function sanitizeSearchPath(searchPath) {
97
97
  return kept || undefined;
98
98
  }
99
99
 
100
+ /**
101
+ * The same split, but keeping the half `sanitizeSearchPath` throws away.
102
+ *
103
+ * Widening `tests/*.mjs` to `tests` keeps rg from exiting 1, and for years that
104
+ * was the whole story — but the deny then PRINTS the widened command above an
105
+ * answer covering files the user excluded (field report 2026-09-08: a
106
+ * `tests/*.mjs` grep answered with 25+ hits from all of `tests/`). The glob has
107
+ * a faithful destination: `code-graph-mcp grep -g <glob>`, which matches
108
+ * basename-style across nested directories.
109
+ *
110
+ * `{scope, glob}`, either of which may be undefined: `tests/*.mjs` →
111
+ * `{scope:'tests', glob:'*.mjs'}`, `src/**` + `/*.rs` → `{scope:'src',
112
+ * glob:'**\/*.rs'}`, a leading glob (`*.py`) → `{scope:undefined,
113
+ * glob:'*.py'}` (repo-wide, filtered), no glob → `{scope:<path>}`.
114
+ */
115
+ function splitSearchPathGlob(searchPath) {
116
+ if (!searchPath || typeof searchPath !== 'string') return {};
117
+ const segs = searchPath.split('/');
118
+ const i = segs.findIndex((s) => /[*?[\]{}]/.test(s));
119
+ if (i === -1) return { scope: searchPath };
120
+ const scope = segs.slice(0, i).join('/') || undefined;
121
+ const glob = segs.slice(i).join('/') || undefined;
122
+ return { scope, glob };
123
+ }
124
+
125
+ /**
126
+ * The argv for the grep answer — the ONE place the answer's shape is decided.
127
+ *
128
+ * Both the child process and the command string the deny prints are built from
129
+ * this array, because two renderings of one command are two things that drift:
130
+ * v0.142.0 shipped a fix for two printers of a single `unadopt` string that had
131
+ * done exactly that. Here the drift was live and user-visible in a different
132
+ * form — the printed command never carried the grep's flags at all, so a
133
+ * `grep -l` deny announced a file-list command and delivered hit lines.
134
+ *
135
+ * `flags` are already translated to cg spellings by the caller
136
+ * (pre-grep-guide.extractCgFlags). A `-g` among them came from `--include` and
137
+ * wins over one derived from the path: the user spelled that filter explicitly.
138
+ */
139
+ function buildGrepArgs({ pattern, searchPath, flags = [] } = {}) {
140
+ const { scope, glob } = splitSearchPathGlob(searchPath);
141
+ const args = ['grep', ...flags, pattern];
142
+ if (scope) args.push(scope);
143
+ // `hasGlobFlag`, not `flags.includes('-g')`: `flags` is a flat argv fragment,
144
+ // so a VALUE can equal a flag name. `rg -t -g "sym" src/*.rs` yields
145
+ // `['-t','-g']` — `-g` there is the VALUE of `-t` — and the bare membership
146
+ // test dropped the path-derived glob, widening `src/*.rs` to `src`. That is
147
+ // the defect this function exists to fix, reintroduced one layer up (round 2
148
+ // of pre-ship review).
149
+ if (glob && !hasGlobFlag(flags)) args.push('-g', glob);
150
+ return args;
151
+ }
152
+
153
+ // True when `flags` carries a real `-g` (an explicit `--include`/`--glob`), as
154
+ // opposed to the string `-g` sitting in a value position.
155
+ function hasGlobFlag(flags) {
156
+ for (let i = 0; i < (flags || []).length; i++) {
157
+ if (flags[i] === '-g') return true;
158
+ if (flags[i] === '-t') i++; // skip its value
159
+ }
160
+ return false;
161
+ }
162
+
163
+ // Render an argv as the command a human can paste. Single-quote anything that
164
+ // would not survive a shell verbatim — the glob especially, which is the whole
165
+ // point of passing it (`-g *.mjs` unquoted is expanded by the user's shell).
166
+ // Characters that survive a shell verbatim. A glob does not, which is the whole
167
+ // reason the argument is worth quoting: `-g *.mjs` unquoted is expanded by the
168
+ // reader's own shell before cg ever sees it.
169
+ const SHELL_SAFE_ARG = /^[A-Za-z0-9_.,:@%+=/-]+$/;
170
+ // POSIX single-quote escape: close, emit an escaped quote, reopen.
171
+ const SQ_ESCAPED = "'\\''";
172
+
173
+ function formatCgCommand(args) {
174
+ // Deliberately no nested template literal, and no regex holding a quote.
175
+ // windows-hide.test.js masks literals with a character scanner that pairs
176
+ // backticks like any other quote, so a `${…`…`…}` inside a template closes the
177
+ // outer one early and swallows the rest of the file — which took all three of
178
+ // THIS file's spawn call sites out of that guard's view until it was written
179
+ // this way. Code a guard cannot parse is code the guard is not checking.
180
+ const shown = args.map((a) =>
181
+ (SHELL_SAFE_ARG.test(a) ? a : "'" + a.split("'").join(SQ_ESCAPED) + "'"));
182
+ return 'code-graph-mcp ' + shown.join(' ');
183
+ }
184
+
100
185
  /**
101
186
  * The four runners below each spawned the binary and mapped the result by hand
102
187
  * — the same fifteen lines, four times (audit 2026-08-29 ARC-07). Hoisted into
@@ -206,6 +291,7 @@ function runGrepAnswer(opts = {}) {
206
291
  cwd,
207
292
  pattern,
208
293
  searchPath,
294
+ flags = [],
209
295
  timeoutMs = DEFAULT_TIMEOUT_MS,
210
296
  maxBytes = DEFAULT_MAX_BYTES,
211
297
  } = opts;
@@ -216,11 +302,10 @@ function runGrepAnswer(opts = {}) {
216
302
  const binary = resolveAnswerBinary(opts);
217
303
  if (!binary) return { status: 'no-binary' };
218
304
 
219
- // Defensive re-sanitize: callers should pass a clean path, but a glob
220
- // reaching argv is a guaranteed nonzero exit (see sanitizeSearchPath).
221
- const scope = sanitizeSearchPath(searchPath);
222
- const args = ['grep', pattern];
223
- if (scope) args.push(scope);
305
+ // buildGrepArgs does the glob split itself, so a caller that passes a raw
306
+ // `dir/*.py` still cannot put a literal glob into argv (the guaranteed
307
+ // nonzero exit sanitizeSearchPath exists for) — it becomes scope + `-g`.
308
+ const args = buildGrepArgs({ pattern, searchPath, flags });
224
309
  const res = runCg(binary, args, { cwd, timeoutMs });
225
310
  // Older binaries exit 0 on no-match with NO_MATCH_PREFIX on stdout — that
226
311
  // shape resolves to 'no-hits' through isEmptyAnswer below.
@@ -411,6 +496,9 @@ function runCallgraphAnswer(opts = {}) {
411
496
  module.exports = {
412
497
  runGrepAnswer, runShowAnswer, runOverviewAnswer, runCallgraphAnswer,
413
498
  truncateAtLine, sanitizeSearchPath,
499
+ // The one argv builder + its renderer, so the deny copy and the child process
500
+ // cannot describe different commands.
501
+ splitSearchPathGlob, buildGrepArgs, formatCgCommand,
414
502
  // Exported so the integer property can be asserted where it is ESTABLISHED.
415
503
  // Asserting it end-to-end instead passes either way: `remainingMs` floors
416
504
  // again downstream, so such a test cannot fail and proves nothing (NEW-02).
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+ /**
3
+ * The dispatcher behind every `code-graph-mcp <subcommand>` a user can type.
4
+ *
5
+ * Two files put that name on a PATH: the npm package's bin entry
6
+ * (`bin/cli.js`, from `npm i -g @sdsrs/code-graph`) and this plugin's launcher
7
+ * (`claude-plugin/bin/code-graph-mcp`, which Claude Code puts on PATH for every
8
+ * enabled plugin). Both are three lines that call `main()` here.
9
+ *
10
+ * Why one module and not two entry points: v0.142.0 shipped a fix for two
11
+ * printers of the same `unadopt` string that had drifted apart, and the release
12
+ * before it shipped a fix for the first of them. Two dispatchers for the same
13
+ * six subcommands would be that defect with a much larger surface — `adopt`
14
+ * writes the user's CLAUDE.md and `uninstall` deletes their cache, so a drift
15
+ * between them is a drift between two destructive paths.
16
+ *
17
+ * `adopt` / `unadopt` / `uninstall` are intercepted here rather than forwarded:
18
+ * they write `<cwd>/CLAUDE.md`, `<cwd>/.claude/` and `~/.cache/code-graph`, and
19
+ * have no counterpart in the Rust binary. Everything else is forwarded verbatim.
20
+ */
21
+ const { spawn } = require('child_process');
22
+ const path = require('path');
23
+ const { hidden } = require('./proc-opts');
24
+
25
+ /**
26
+ * Reject unknown flags on an intercepted subcommand BEFORE doing its work.
27
+ *
28
+ * `--help` was already guarded, but every OTHER token was ignored, so
29
+ * `code-graph-mcp adopt --helpp` ran adopt and wrote the user's CLAUDE.md — a
30
+ * typo away from the very side effect the --help guard exists to prevent. This
31
+ * is the fourth entry point onto the same "ignore what you don't recognise"
32
+ * idiom (doctor.js, lifecycle.js doctor, src/main.rs were the first three); it
33
+ * is the surface both PATH names reach, so it is the one most users hit.
34
+ */
35
+ function rejectUnknownFlags(argv, name, known) {
36
+ const unknown = argv.slice(3).filter((a) => !known.has(a));
37
+ if (unknown.length) {
38
+ process.stderr.write(
39
+ `code-graph-mcp ${name}: unknown argument(s): ${unknown.join(" ")}\n` +
40
+ `Run \`code-graph-mcp ${name} --help\` for usage.\n`);
41
+ process.exit(2);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @param {object} [opts]
47
+ * @param {string[]} [opts.argv] process.argv-shaped: [node, entry, sub, ...]
48
+ * @param {string} [opts.findBinaryRoot] package root for find-binary's dev-mode and
49
+ * bundled-`bin/` tiers. The npm entry passes its package root. The plugin
50
+ * launcher passes NOTHING on purpose: find-binary derives the right root from
51
+ * its own `__dirname` in every install shape, and pointing this at the plugin
52
+ * root would add the launcher's own directory to the bundled-binary tier.
53
+ */
54
+ function main({ argv = process.argv, findBinaryRoot = null } = {}) {
55
+ if (findBinaryRoot) process.env._FIND_BINARY_ROOT = path.resolve(findBinaryRoot);
56
+
57
+ const sub = argv[2];
58
+
59
+ if (sub === "adopt" || sub === "unadopt") {
60
+ // `--help`/`-h` must be side-effect-free: adopt() writes the managed block
61
+ // into CLAUDE.md, unadopt() removes it. The Rust binary guards this for
62
+ // direct invocation, but both PATH names route through this wrapper, which
63
+ // intercepts adopt/unadopt *before* the binary — so the guard must be
64
+ // repeated here, or `code-graph-mcp adopt --help` adopts the project (the
65
+ // common new-user path).
66
+ if (argv.slice(3).some((a) => a === "--help" || a === "-h")) {
67
+ process.stdout.write(sub === "adopt"
68
+ // Kept in sync with src/main.rs's adopt/unadopt help. This text described
69
+ // the pre-v0.74 scheme (a sentinel in the ~/.claude memory dir) for three
70
+ // releases after the target moved to the project's own CLAUDE.md, so npm
71
+ // users were told this command edits a file it has not touched since.
72
+ ? "code-graph-mcp adopt — install the code-graph steering block into the project CLAUDE.md\n\n" +
73
+ "USAGE:\n code-graph-mcp adopt\n\n" +
74
+ "Writes a sentinel-wrapped managed block into <cwd>/CLAUDE.md plus a\n" +
75
+ "<cwd>/.claude/plugin_code_graph_mcp.md detail doc, so Claude Code loads the\n" +
76
+ "decision table each session. Run `code-graph-mcp unadopt` to remove it.\n"
77
+ : "code-graph-mcp unadopt — remove the code-graph steering block\n\n" +
78
+ "USAGE:\n code-graph-mcp unadopt\n\n" +
79
+ "Reverses `code-graph-mcp adopt`: strips the managed block from\n" +
80
+ "<cwd>/CLAUDE.md and deletes <cwd>/.claude/plugin_code_graph_mcp.md.\n" +
81
+ "User content outside the sentinel is kept.\n");
82
+ process.exit(0);
83
+ }
84
+ rejectUnknownFlags(argv, sub, new Set(["--help", "-h"]));
85
+ const { adopt, unadopt, formatResult } = require("./adopt");
86
+ const result = sub === "unadopt" ? unadopt() : adopt();
87
+ process.stdout.write(formatResult(sub, result) + "\n");
88
+ process.exit(result.ok === false ? 1 : 0);
89
+ }
90
+
91
+ // Full local teardown (restore prior statusline, strip code-graph hooks from
92
+ // settings.json, delete ~/.cache/code-graph, and unadopt the CURRENT project).
93
+ // Node-only; no Rust counterpart. Claude Code's `/plugin uninstall` fires no
94
+ // uninstall hook, so this is the user's one-shot CLI teardown. Guard `--help`
95
+ // before the destructive work (same discipline as adopt/unadopt above).
96
+ if (sub === "uninstall") {
97
+ if (argv.slice(3).some((a) => a === "--help" || a === "-h")) {
98
+ process.stdout.write(
99
+ "code-graph-mcp uninstall — remove code-graph config + cache from this machine\n\n" +
100
+ "USAGE:\n code-graph-mcp uninstall [--unadopt-all] [--purge-global]\n\n" +
101
+ "Restores your prior statusline, strips code-graph hooks from settings.json,\n" +
102
+ "deletes ~/.cache/code-graph, and removes this project's CLAUDE.md adoption\n" +
103
+ "block. --unadopt-all also removes the managed block + detail file from every\n" +
104
+ "registered adopted project; --purge-global removes the globally-installed\n" +
105
+ "@sdsrs npm packages even without the plugin-install marker. Also run\n" +
106
+ "`/plugin uninstall code-graph-mcp` in Claude Code to sync its UI.\n");
107
+ process.exit(0);
108
+ }
109
+ rejectUnknownFlags(argv, "uninstall", new Set(["--help", "-h", "--unadopt-all", "--purge-global"]));
110
+ const lifecycle = require("./lifecycle");
111
+ const { unadopt } = require("./adopt");
112
+ const r = lifecycle.uninstall({
113
+ purgeGlobal: argv.slice(3).includes("--purge-global"),
114
+ unadoptAll: argv.slice(3).includes("--unadopt-all"),
115
+ });
116
+ let ua = { ok: false };
117
+ try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
118
+ const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
119
+ let out =
120
+ `Uninstalled code-graph-mcp | settings cleaned=${r.settingsChanged}` +
121
+ ` | this project unadopted=${projectUnadopted}\n`;
122
+ if (r.globalPkgsRemoved.length) {
123
+ out += ` Removed global npm package(s): ${r.globalPkgsRemoved.join(", ")}\n`;
124
+ }
125
+ if (r.globalPkgsRemaining.length) {
126
+ out += ` Global npm package(s) still installed: ${r.globalPkgsRemaining.join(", ")}\n` +
127
+ ` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(" ")}` +
128
+ (r.pluginInstalledGlobals ? "\n" : " (or re-run with --purge-global)\n");
129
+ }
130
+ if (r.unadopted.length) {
131
+ const cleaned = r.unadopted.filter((u) => u.cleaned).length;
132
+ out += ` Unadopted ${cleaned}/${r.unadopted.length} registered project(s) (--unadopt-all).\n`;
133
+ }
134
+ const otherAdopted = r.adoptedProjects.filter((p) => p !== process.cwd());
135
+ if (otherAdopted.length) {
136
+ out += " Other adopted project(s) — re-run with --unadopt-all, or in each:" +
137
+ " `code-graph-mcp unadopt` + `rm -rf .code-graph`\n" +
138
+ otherAdopted.map((p) => ` ${p}\n`).join("");
139
+ }
140
+ out += " Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its UI state.\n";
141
+ process.stdout.write(out);
142
+ process.exit(0);
143
+ }
144
+
145
+ // `doctor` is the third JS-dispatched subcommand, and the one that used to be
146
+ // reached the long way round: forwarded to the binary, which re-execs a
147
+ // `doctor.js` it looks for beside itself. That works for a source build and
148
+ // for the npm package; it does NOT work for the install this launcher exists
149
+ // for, where the binary is the downloaded one in `~/.cache/code-graph/bin/`
150
+ // and answers `doctor.js not found. Looked in: …`. The script is right here,
151
+ // so ask it directly.
152
+ //
153
+ // `runDoctorCli` rather than a private re-parse: it is the function
154
+ // `node doctor.js …` and `node lifecycle.js doctor …` already share, and it
155
+ // exists as one function because the first version of doctor's unknown-flag
156
+ // guard lived in only one of them and a typo'd flag kept running the repair
157
+ // pass on the other. A third entry point re-implementing the parse would
158
+ // restore exactly that.
159
+ if (sub === "doctor") {
160
+ const { runDoctorCli } = require("./doctor");
161
+ process.exit(runDoctorCli(argv.slice(3)));
162
+ }
163
+
164
+ const { findBinary, unsupportedPlatformHint } = require("./find-binary");
165
+
166
+ const binary = findBinary();
167
+
168
+ if (!binary) {
169
+ const hint = unsupportedPlatformHint();
170
+ console.error(
171
+ "Error: code-graph-mcp binary not found.\n\n" +
172
+ (hint ? hint + "\n\n" : "") +
173
+ "To install:\n" +
174
+ " npm install -g @sdsrs/code-graph\n\n" +
175
+ "To build from source:\n" +
176
+ " cargo install code-graph-mcp --features embed-model\n"
177
+ );
178
+ process.exit(1);
179
+ }
180
+
181
+ // Forward stdio so MCP JSON-RPC (`serve`) and interactive output both work.
182
+ const child = spawn(binary, argv.slice(2), hidden({
183
+ stdio: "inherit",
184
+ env: process.env,
185
+ }));
186
+
187
+ child.on("error", (err) => {
188
+ console.error(`Failed to start code-graph-mcp: ${err.message}`);
189
+ // A glibc binary installed on musl (older npm ignores the `libc` field) is present
190
+ // but fails to exec — surface the actionable platform hint instead of a bare error.
191
+ const hint = unsupportedPlatformHint();
192
+ if (hint) console.error("\n" + hint);
193
+ process.exit(1);
194
+ });
195
+
196
+ child.on("exit", (code, signal) => {
197
+ if (signal) {
198
+ process.kill(process.pid, signal);
199
+ } else {
200
+ process.exit(code ?? 1);
201
+ }
202
+ });
203
+ }
204
+
205
+ module.exports = { main, rejectUnknownFlags };
@@ -190,11 +190,58 @@ function nvmNodeModulesDirs(base = path.join(os.homedir(), '.nvm', 'versions', '
190
190
  .filter((d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } });
191
191
  }
192
192
 
193
+ /**
194
+ * The plugin's own `bin/`, resolved once through realpath (plugin caches can sit
195
+ * behind a symlinked home) and remembered.
196
+ *
197
+ * Claude Code puts this directory on the Bash tool's PATH, and the file in it is
198
+ * NAMED `code-graph-mcp` — that is the whole point of it (issue #41). See
199
+ * `isNativeBinary` for why that name has to be excluded here.
200
+ */
201
+ let launcherDirMemo;
202
+ function launcherDir() {
203
+ if (launcherDirMemo === undefined) {
204
+ const dir = path.resolve(__dirname, '..', 'bin');
205
+ try { launcherDirMemo = fs.realpathSync(dir); } catch { launcherDirMemo = dir; }
206
+ }
207
+ return launcherDirMemo;
208
+ }
209
+
210
+ /**
211
+ * Is this candidate the native binary, as opposed to a wrapper that resolves it?
212
+ *
213
+ * The basename test is what rejects the npm shim: `npm i -g` installs
214
+ * `code-graph-mcp` as a symlink whose realpath is `.../bin/cli.js`, and running
215
+ * that instead of the binary would make the wrapper spawn itself.
216
+ *
217
+ * The plugin launcher is the same hazard under a name the basename test cannot
218
+ * catch, because PATH resolution requires it to BE `code-graph-mcp`. Two tiers
219
+ * of the discovery chain offer it: the PATH probe (`which code-graph-mcp`) and
220
+ * the bundled-`bin/` tier, which `mcp-launcher.js` points at the plugin root.
221
+ * It resolves the binary by calling `findBinary()` — so accepting it here is a
222
+ * launcher that execs the launcher, and a `binary-path` cache entry that
223
+ * remembers to keep doing it. Excluded by directory rather than by content
224
+ * (a magic-byte test would be the more general guard and would reject the
225
+ * planted fixtures this module's own suite is built on).
226
+ */
193
227
  function isNativeBinary(candidate) {
194
228
  if (!candidate) return false;
195
229
  try {
196
230
  if (!fs.existsSync(candidate)) return false;
197
231
  const realPath = fs.realpathSync(candidate);
232
+ const dir = path.dirname(realPath);
233
+ // Our OWN launcher directory, and — pre-ship review — any OTHER copy's.
234
+ // Matching only `launcherDir()` covers the copy this module was loaded from,
235
+ // so a machine with BOTH `npm i -g` and the plugin had the npm entry's PATH
236
+ // tier resolve the PLUGIN's launcher as the binary: accepted by the gate,
237
+ // written into the shared `~/.cache/code-graph/binary-path`, and thereafter
238
+ // one extra node process per call plus cache thrash between the two copies.
239
+ // The `.cmd` sibling is the marker because it is what makes a directory a
240
+ // launcher directory: `claude-plugin/bin/` always ships both spellings, and
241
+ // nothing that ships an actual native binary ships a `.cmd` beside it.
242
+ // Not a magic-byte test on purpose — see the note in tasks/specs.
243
+ if (dir === launcherDir()) return false;
244
+ if (fs.existsSync(path.join(dir, 'code-graph-mcp.cmd'))) return false;
198
245
  return path.basename(realPath) === BINARY_NAME;
199
246
  } catch {
200
247
  return false;
@@ -23,22 +23,28 @@ if (require.main === module) require('./hook-fail-open').installHookFailOpen('Po
23
23
  //
24
24
  // Reuses the PreToolUse pure predicates wholesale (feedback_hook_class_bug_sweep —
25
25
  // no inline copies of the grep gate): splitTopLevelSegments + classifyBlock pick
26
- // the foldable segment; pickBlockPattern / translateBreToRg / extractSearchPath +
27
- // sanitizeSearchPath + runGrepAnswer / runShowAnswer run the exact same answer the
28
- // deny path would have. Best-effort: any miss (no hits / unavailable / no binary)
26
+ // the foldable segment; pickBlockPattern / translateBreToRg / extractCgFlags +
27
+ // extractSearchPath + runGrepAnswer / runShowAnswer run the exact same answer the
28
+ // deny path would have — including its flags and glob scope, which this hook did
29
+ // NOT carry until v0.144 even as the deny path stopped taking these commands. Best-effort: any miss (no hits / unavailable / no binary)
29
30
  // exits silently with NO injection — an enhancement, never a new failure mode.
30
31
 
31
32
  const fs = require('fs');
32
33
  const path = require('path');
33
34
  const { cgTmpDir, cwdHash, makeCooldown } = require('./tmp-dir');
34
35
  const { recordRecommendation } = require('./recommendation-log');
35
- const { runGrepAnswer, runShowAnswer, runCallgraphAnswer, sanitizeSearchPath } = require('./cg-answer');
36
+ // `sanitizeSearchPath` is deliberately NOT imported any more: this hook passes
37
+ // the RAW path so buildGrepArgs can split a glob into scope + `-g` instead of
38
+ // widening it away. Leaving the require behind would read as "still used".
39
+ const { runGrepAnswer, runShowAnswer, runCallgraphAnswer } = require('./cg-answer');
36
40
  const { emitPostToolContext } = require('./hook-emit');
37
41
  const {
38
42
  splitTopLevelSegments,
39
43
  classifyBlock,
40
44
  pickBlockPattern,
41
45
  translateBreToRg,
46
+ extractCgFlags,
47
+ cgFlagSet,
42
48
  extractSearchPath,
43
49
  normalizeCommandPaths,
44
50
  rebaseRelativePaths,
@@ -265,9 +271,70 @@ function runMain() {
265
271
  // ~1KB context tax. Only a grep that found NOTHING (or an unreadable output —
266
272
  // no regression on older CC) proceeds: then cg's structural answer (the real
267
273
  // location / cross-file callers a failed grep never showed) is genuinely additive.
268
- if (grepFoundPattern(extractGrepOutput(input), rawPattern)) return;
269
- const pattern = translateBreToRg(segment, rawPattern);
270
- const searchPath = sanitizeSearchPath(extractSearchPath(segment));
274
+ // v0.144 — RECORD the suppression instead of returning silently. PreToolUse
275
+ // stopped denying compound commands in this release, which moves a measured
276
+ // 27% of former denies (23 of 85 in this repo's own log carried `tail:true`,
277
+ // every one of them `answered:true`) onto this path — and this `return` is
278
+ // where they end when the model's own grep hit. That was the right trade only
279
+ // if it stays visible: an unrecorded skip makes the whole class disappear from
280
+ // the funnel, so "the compound case is covered by the inject" becomes a claim
281
+ // nobody can check after ship. Pre-ship review found this before release.
282
+ // `hook:'grep'` and `action:'observe'`, NOT a new hook name — round 2 of
283
+ // pre-ship review caught round 1 inventing `hook:'grep-inject'/action:'skip'`
284
+ // and thereby breaking the metric this record was added to protect.
285
+ // `aggregate_recommendations_jsonl` (src/cli/usage.rs) clears `armed` on EVERY
286
+ // line it reads, but only scores lines whose hook is `grep`/`read`, so an
287
+ // unrecognized hook consumed the arm set by the preceding answered deny
288
+ // without scoring it — measured on a crafted log: one such line between an
289
+ // answered deny and its follow-up took `fallthrough_rate` from 1.0 to 0.0.
290
+ // `skip` was also absent from the `observe`/`use`/`live_impact` exclusions, so
291
+ // it inflated `s.total` and shifted `tool_calls_per_rec` across releases.
292
+ //
293
+ // `observe` is the honest bucket as well as the safe one: the grep ran and
294
+ // produced its own hits, so nothing was recommended. The aggregator already
295
+ // reads it as "acting on the answer — neither sustained nor fall-through" and
296
+ // excludes it from the recommendation total. `reason` distinguishes this skip
297
+ // from the cooldown observes when grepping the JSONL; no counter reads it.
298
+ //
299
+ // KNOWN CONSEQUENCE, measured rather than reasoned — and round 3 corrected
300
+ // round 2's version of this paragraph, which described the wrong branch. The
301
+ // funnel scores only the IMMEDIATELY-next event after an answered deny, and
302
+ // `usage.rs` tests SAME-PATTERN first. So on the shape this record actually
303
+ // measures — a re-grep of the denied pattern — the outcome is unchanged
304
+ // (`fallthrough_after_answer` 1, rate 1.0, because a verbatim re-grep is
305
+ // fall-through whether it is spelled as this observe or as the deny that used
306
+ // to follow it). Only when the compound grep searches a DIFFERENT pattern does
307
+ // the window move onto it: there the rate goes 1.0 → 0.0, since a different
308
+ // query is not evidence the answer failed. `total` is unaffected either way
309
+ // (`observe` is excluded). Measured on the built binary, both branches.
310
+ const flags = extractCgFlags(segment);
311
+ // `-F` means the user asked for a LITERAL pattern, so the BRE→rust-regex
312
+ // unescape must not also run: `grep -F 'x\|y'` searches for `x\|y`, and
313
+ // unescaping it to `x|y` searches for something else (verified against GNU
314
+ // grep: `grep -Fc 'x\|y'` is 1 and `grep -Fc 'x|y'` is 0 on the same file).
315
+ const pattern = cgFlagSet(flags).has('-F') ? rawPattern : translateBreToRg(segment, rawPattern);
316
+ // The redundancy gate matches against the model's OWN grep output, which
317
+ // carries the pattern as the user wrote it → `rawPattern`. The RECORD carries
318
+ // the translated `pattern`, because that is the vocabulary every sibling
319
+ // record uses (pre-grep-guide's deny and observe, this file's two injects) and
320
+ // the funnel compares those strings for equality. Round 2 recorded the raw
321
+ // form: a deny of `"fn foo\|fn bar"` files `fn foo|fn bar` while the follow-up
322
+ // filed `fn foo\|fn bar`, so a verbatim re-grep scored neutral instead of
323
+ // fall-through. Round 1's record never reached that comparison at all (wrong
324
+ // hook name), so promoting it in is what exposed the mismatch.
325
+ if (grepFoundPattern(extractGrepOutput(input), rawPattern)) {
326
+ recordRecommendation(root, {
327
+ hook: 'grep', action: 'observe', reason: 'grep-hit-redundant',
328
+ ...(pattern ? { pattern } : {}),
329
+ });
330
+ return;
331
+ }
332
+ // RAW path, not `sanitizeSearchPath`: buildGrepArgs splits `tests/*.mjs` into
333
+ // scope + `-g`. The deny path got that in this release and this one did not,
334
+ // while the same release routed MORE traffic here — so `grep -rln "X"
335
+ // tests/*.mjs && echo done` ran without `-l` and against all of `tests/`,
336
+ // exactly the infidelity the deny path had just stopped committing.
337
+ const searchPath = extractSearchPath(segment);
271
338
  let answer = { status: 'unavailable' };
272
339
  let answeredMode = block.mode;
273
340
 
@@ -293,11 +360,11 @@ function runMain() {
293
360
  answer = runShowAnswer({ cwd: root, symbols: block.symbols });
294
361
  if (answer.status !== 'hits' && pattern) {
295
362
  answeredMode = 'grep';
296
- answer = runGrepAnswer({ cwd: root, pattern, searchPath });
363
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
297
364
  }
298
365
  } else if (pattern) {
299
366
  answeredMode = 'grep';
300
- answer = runGrepAnswer({ cwd: root, pattern, searchPath });
367
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
301
368
  }
302
369
  }
303
370
 
@@ -48,7 +48,7 @@ const fs = require('fs');
48
48
  const path = require('path');
49
49
  const { cgTmpDir, cwdHash, makeCooldown } = require('./tmp-dir');
50
50
  const { recordRecommendation } = require('./recommendation-log');
51
- const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
51
+ const { runGrepAnswer, runShowAnswer, sanitizeSearchPath, buildGrepArgs, formatCgCommand } = require('./cg-answer');
52
52
 
53
53
  // --- Pure logic (testable) ---
54
54
 
@@ -248,6 +248,7 @@ function classifyBlock(cmd) {
248
248
  // must not disqualify the answerable head grep).
249
249
  const clause = firstShellClause(cmd);
250
250
  if (UNANSWERABLE_FLAGS.test(clause)) return null; // intent the answer can't honor
251
+ if (isAgFilenameSearch(clause)) return null; // a filename search, not a content one
251
252
  if (MARKER_ONLY.test(clause)) return null; // bare TODO/FIXME — no cg equivalent
252
253
  const patterns = extractPatterns(clause);
253
254
  if (patterns.length === 0) return null; // unquoted pattern — conservative, hint
@@ -266,6 +267,215 @@ function classifyBlock(cmd) {
266
267
  return { mode: 'grep' };
267
268
  }
268
269
 
270
+ // Flags of the user's grep that `code-graph-mcp grep` can honor, in its own
271
+ // spelling. Verified against the built binary rather than against help text:
272
+ // `-l` prints bare paths, `-c` prints `path:count` exactly as `grep -c` does.
273
+ //
274
+ // Everything absent from this map is absent on purpose. `-r`/`-R`, `-n` and
275
+ // `-H` ARE accepted by cg as grep-parity no-ops (it is always recursive and
276
+ // always prints path and line number), so forwarding them would be harmless
277
+ // rather than an error — they are simply nothing to say. `-h` is NOT the same
278
+ // flag in the two tools: cg reads it as `--help`, so forwarding it would print
279
+ // usage instead of searching. `-L`/`-v`/`--exclude*` never arrive here —
280
+ // UNANSWERABLE_FLAGS sends them to the hint tier — and `-A/-B/-C` are routed to
281
+ // show-mode or hint before this runs.
282
+ const CG_SHORT_FLAGS = { i: '-i', w: '-w', F: '-F', l: '-l', c: '-c' };
283
+ const CG_LONG_FLAGS = {
284
+ '--ignore-case': '-i',
285
+ '--word-regexp': '-w',
286
+ '--fixed-strings': '-F',
287
+ '--files-with-matches': '-l',
288
+ '--count': '-c',
289
+ };
290
+ // Flags that carry a VALUE and select which files are searched. All three spell
291
+ // the same filter cg spells `-g` / `-t`, and dropping one silently widens the
292
+ // answer to files the user excluded — the defect this whole flag map exists to
293
+ // stop. `rg`'s spellings matter because rg is a folded verb: `rg -g '*.rs' Sym
294
+ // src/` reached the answer as a bare `grep Sym src/`. cg takes `-g` repeatably,
295
+ // so every occurrence is forwarded rather than only the first.
296
+ const CG_VALUE_FLAGS = {
297
+ '--include': '-g', // grep
298
+ '--glob': '-g', // rg
299
+ '--type': '-t', // rg
300
+ };
301
+ // Short forms of the value-carrying flags. RIPGREP ONLY, and the verb is
302
+ // checked: `ag -t Sym src/` means "all text files" and takes no value, and ag's
303
+ // `-g` prints matching FILENAMES rather than filtering — mapping either would
304
+ // hand cg an argument the user never wrote. (Round 2 of pre-ship review; the
305
+ // symptom was mild — cg exits 2 on an unknown file type, the answer degrades to
306
+ // `unavailable` and the deny becomes an allow — but it is a fabricated
307
+ // argument.) grep has no short spelling for `--include`.
308
+ const CG_VALUE_SHORT = { g: '-g', t: '-t' };
309
+ const RG_VERB = /(?:^|\s)rg$/;
310
+ const AG_VERB = /(?:^|\s)ag$/;
311
+
312
+ /**
313
+ * `ag -g PATTERN` searches FILENAMES, not file contents.
314
+ *
315
+ * Round 2 gated the short `-g`/`-t` map on ripgrep so ag's spellings would stop
316
+ * being folded into cg arguments the user never wrote. Round 3 found what that
317
+ * left behind: with the fabricated argument gone, `ag -g some_symbol src/` is a
318
+ * plain answerable content grep as far as `classifyBlock` is concerned, so the
319
+ * hook now DENIES it and answers with matching LINES — a different question
320
+ * from the one asked. Before round 2 the fabricated `-g some_symbol` matched no
321
+ * file, the answer came back empty and the deny degraded to an allow; the bug
322
+ * was accidentally its own safety valve.
323
+ *
324
+ * cg has no filename-search mode, so this belongs in the hint tier with the
325
+ * other intents the answer cannot honor: the user's `ag` runs untouched.
326
+ */
327
+ function isAgFilenameSearch(clause) {
328
+ if (typeof clause !== 'string') return false;
329
+ if (!AG_VERB.test((clause.match(GREP_HEAD) || [])[1] || '')) return false;
330
+ // A TOKEN scan, not a regex over the whole clause (round 4 of pre-ship
331
+ // review). The first spelling of this guard matched `-g` only when it was
332
+ // whitespace-delimited, so `ag -g"some_symbol" src/` — the attached form
333
+ // `extractCgFlags` twelve lines below explicitly handles — still reached the
334
+ // deny and was answered with content lines. Scanning the raw clause instead
335
+ // had the mirror-image fault: a `-g` inside a quoted pattern
336
+ // (`ag "some_symbol -g x" src/`) demoted a real content search. Same
337
+ // flag-name-in-a-value-position class the sibling repairs fixed with
338
+ // cgFlagSet/hasGlobFlag, so this uses the same convention: a token that
339
+ // STARTS quoted is an argument, never a flag.
340
+ // Quoted spans are blanked BEFORE tokenizing, not skipped after: a quoted
341
+ // argument can contain whitespace, so `ag "some_symbol -g x" src/` splits into
342
+ // three tokens and the middle one looks exactly like a flag. Blanking leaves
343
+ // an attached value's flag behind (`-g"x"` → `-g`), which is what we want.
344
+ const scan = clause.replace(VERB_STRIP, '').replace(/"[^"]*"|'[^']*'/g, ' ');
345
+ for (const tok of scan.trim().split(/\s+/)) {
346
+ if (!tok || tok[0] !== '-') continue;
347
+ if (tok.startsWith('--')) {
348
+ if (/^--(?:filename-pattern|file-search-regex)(?:=|$)/.test(tok)) return true;
349
+ continue;
350
+ }
351
+ // Short cluster: ag's `-g` takes a value, so it ends the cluster — an
352
+ // attached value (`-g'x'`, `-g=x`) or the next token. Capital `-G` is a
353
+ // different ag flag (limit by filename) and must NOT match.
354
+ if (/^-[a-zA-Z]*g/.test(tok)) return true;
355
+ }
356
+ return false;
357
+ }
358
+
359
+ /**
360
+ * The boolean flags in an `extractCgFlags` result, without the VALUES.
361
+ *
362
+ * The result is a flat argv fragment (`['-i','-g','*.rs']`), which is what
363
+ * `buildGrepArgs` needs — but it means a membership test sees value tokens too.
364
+ * Round 2 of pre-ship review found both live consequences: `rg -t -g "sym"
365
+ * src/*.rs` yields `['-t','-g']`, whose bare `includes('-g')` made buildGrepArgs
366
+ * drop the path-derived glob and widen the scope — the exact defect this change
367
+ * exists to fix — and `grep --include -F "sym" src/` yields `['-g','-F']`, where
368
+ * `-F` is a filename glob and firing the literal-pattern guard on it is wrong.
369
+ */
370
+ function cgFlagSet(flags) {
371
+ const out = new Set();
372
+ for (let i = 0; i < (flags || []).length; i++) {
373
+ const f = flags[i];
374
+ if (f === '-g' || f === '-t') { i++; continue; } // skip its value
375
+ out.add(f);
376
+ }
377
+ return out;
378
+ }
379
+ // Canonical emission order, so the argv (and therefore the printed command) is a
380
+ // function of WHICH flags were given, never of the order the user typed them.
381
+ const CG_FLAG_ORDER = ['-i', '-w', '-F', '-l', '-c'];
382
+
383
+ /**
384
+ * The grep's flags, translated to cg's.
385
+ *
386
+ * Until v0.144 the answer ran `['grep', pattern, scope]` and the deny printed
387
+ * the same three tokens, so every flag was dropped in both places at once — a
388
+ * `grep -rln` deny announced "the AST-aware equivalent already ran for you"
389
+ * above a command that was not the equivalent and returned hit lines where a
390
+ * file list was asked for (field report 2026-09-08).
391
+ *
392
+ * Scanning rules that matter: only the grep's OWN clause (a tail command's
393
+ * `-l` is not this grep's), and a token that STARTS quoted is an argument, not
394
+ * a flag — `grep "-l" src/` searches for the string `-l`.
395
+ */
396
+ function extractCgFlags(cmd) {
397
+ if (!cmd || typeof cmd !== 'string') return [];
398
+ const clause = firstShellClause(cmd);
399
+ const isRg = RG_VERB.test((clause.match(GREP_HEAD) || [])[1] || '');
400
+ const toks = clause.replace(VERB_STRIP, '').trim().split(/\s+/);
401
+ const found = new Set();
402
+ const filters = []; // [cgFlag, value] pairs, in the order the user wrote them
403
+ const unquote = (s) => (s || '').replace(/^["']|["']$/g, '');
404
+ const addFilter = (flag, value) => {
405
+ if (!value) return;
406
+ // `-g '*.rs' -g '*.rs'` is one filter written twice; cg would honour both
407
+ // identically, but the printed command should not look like a mistake.
408
+ if (!filters.some(([f, v]) => f === flag && v === value)) filters.push([flag, value]);
409
+ };
410
+ for (let i = 0; i < toks.length; i++) {
411
+ const tok = toks[i];
412
+ if (!tok || tok[0] === '"' || tok[0] === "'" || tok[0] !== '-') continue;
413
+ if (tok.startsWith('--')) {
414
+ const eq = tok.indexOf('=');
415
+ const name = eq === -1 ? tok : tok.slice(0, eq);
416
+ if (CG_VALUE_FLAGS[name]) {
417
+ addFilter(CG_VALUE_FLAGS[name], unquote(eq === -1 ? toks[i + 1] : tok.slice(eq + 1)));
418
+ if (eq === -1) i++; // the value was a separate token — do not rescan it
419
+ continue;
420
+ }
421
+ if (CG_LONG_FLAGS[name]) found.add(CG_LONG_FLAGS[name]);
422
+ continue;
423
+ }
424
+ // A short cluster: `-rln` is r + l + n, and only `l` has a cg spelling. A
425
+ // value-carrying short flag ends the cluster and takes what follows —
426
+ // attached (`-g*.rs`) or as the next token (`-g '*.rs'`).
427
+ const letters = tok.slice(1);
428
+ let consumed = false;
429
+ for (let k = 0; k < letters.length; k++) {
430
+ const ch = letters[k];
431
+ if (isRg && CG_VALUE_SHORT[ch]) {
432
+ const attached = letters.slice(k + 1);
433
+ addFilter(CG_VALUE_SHORT[ch], attached ? unquote(attached) : unquote(toks[i + 1]));
434
+ // Consume the value token so the next pass cannot read it as a flag
435
+ // cluster: `rg -g -i 'sym' src/` must be `-g` with value `-i`, not `-g`
436
+ // plus a phantom `-i` (round 2 of pre-ship review — the test that named
437
+ // this property used a quoted value, which the quote guard already
438
+ // skipped, so it passed with the increment removed).
439
+ if (!attached) { i++; }
440
+ consumed = true;
441
+ break;
442
+ }
443
+ if (CG_SHORT_FLAGS[ch]) found.add(CG_SHORT_FLAGS[ch]);
444
+ }
445
+ if (consumed) continue;
446
+ }
447
+ const out = CG_FLAG_ORDER.filter((f) => found.has(f));
448
+ for (const [flag, value] of filters) out.push(flag, value);
449
+ return out;
450
+ }
451
+
452
+ /**
453
+ * The deny gate, as runMain applies it — strictly narrower than classifyBlock.
454
+ *
455
+ * classifyBlock answers "can the inline answer cover this grep". Denying asks
456
+ * something else: "may we cancel the WHOLE command". A top-level `;`/`&&` tail
457
+ * makes those different questions, because the deny cancels a `sed`/`wc`/`echo`
458
+ * the answer says nothing about. Until v0.144 the tail was detected and spent on
459
+ * a NOTE apologising for the loss; the deny fired anyway.
460
+ *
461
+ * It is the same incompleteness the ≥2-named-paths rule above already refuses to
462
+ * deny on ("only DENY when the inline grep answer can cover the SAME scope"),
463
+ * and the fallback was already built: post-grep-inject.js is a PostToolUse hook
464
+ * for compound greps whose segment walk has no head-is-grep exclusion, carries
465
+ * its own redundancy gate, and uses a distinct cooldown prefix. These commands
466
+ * reach it as soon as they are allowed to run.
467
+ *
468
+ * `|` and `||` are NOT tails: a pipe is one pipeline that the answer replaces
469
+ * whole, and the `||` branch would not have run given the answer carried hits.
470
+ *
471
+ * classifyBlock itself is deliberately left alone — post-grep-inject calls it
472
+ * per SEGMENT, where a top-level tail cannot exist by construction.
473
+ */
474
+ function classifyDeny(cmd) {
475
+ if (extractUnansweredTail(cmd)) return null;
476
+ return classifyBlock(cmd);
477
+ }
478
+
269
479
  function shouldBlock(cmd) {
270
480
  return classifyBlock(cmd) !== null;
271
481
  }
@@ -393,27 +603,12 @@ function extractUnansweredTail(cmd) {
393
603
  return null;
394
604
  }
395
605
 
396
- // Shared deny-copy footer for the unanswered compound tail. Budget-conscious:
397
- // two lines, verbatim command so the model can re-issue without thinking.
398
- // Wording is path-neutral — it must stay true for BOTH the answered deny
399
- // ("grep half answered, tail wasn't") and the static fallback (nothing was).
400
- // Head-line marker paired with the NOTE below: the NOTE sits at the END of a
401
- // long deny message and Claude Code's transcript view folds long tool errors,
402
- // so a human reading the truncated view saw a clean "answered" deny with no
403
- // clue the compound's tail was dropped (2026-07-18: two such denies were
404
- // misdiagnosed as product bugs). The model gets the full reason either way.
405
- function tailFlagSuffix(tail) {
406
- return tail ? ' (compound tail NOT run — see NOTE at end)' : '';
407
- }
408
-
409
- function appendUnansweredTailNote(lines, tail) {
410
- if (!tail) return;
411
- const shown = tail.length > 300 ? tail.slice(0, 300) + '…' : tail;
412
- lines.push(
413
- 'NOTE: the rest of this compound command (after the grep) did NOT run — re-issue it separately:',
414
- `$ ${shown}`,
415
- );
416
- }
606
+ // v0.144 — the compound-tail apparatus that lived here is GONE, together with
607
+ // the deny it apologised for. It was a head-line marker plus a closing NOTE
608
+ // telling the model to re-issue the `; sed …` the deny had just cancelled, and
609
+ // the honest version of that message is not to cancel it: `classifyDeny` now
610
+ // refuses to deny a command with a top-level tail at all, so no deny reaching
611
+ // these builders can carry one. `extractUnansweredTail` survives as that gate.
417
612
 
418
613
  // v0.47.0 — pull the first source-tree path token out of the denied command so
419
614
  // the inline answer can scope its search the same way the raw grep would have.
@@ -547,22 +742,20 @@ function buildHint() {
547
742
  ].join('\n');
548
743
  }
549
744
 
550
- function buildBlockReason(unansweredTail) {
745
+ function buildBlockReason() {
551
746
  // Shown to Claude via PreToolUse `decision: block` reason. Must give a
552
747
  // concrete alternate command Claude can re-issue without further thinking.
553
748
  // v0.49 — NO escape-hatch line anywhere in deny copy: the daagu 2026-06-12
554
749
  // night proved even the "THIS command only" scoping reads as a teachable
555
750
  // permanent prefix (adopted in 8s, reused 11×, incl. on the exact identifier
556
751
  // searches this hook targets). The env opt-out stays documented in README.
557
- const lines = [
558
- `[code-graph] Raw \`grep -rn\` on indexed source — denied by code-graph hook.${tailFlagSuffix(unansweredTail)}`,
752
+ return [
753
+ '[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
559
754
  'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
560
755
  ' code-graph-mcp grep "<pattern>" [paths...] # AST context per hit; -F literal, -i, -w, -l, -C N, --max-count 0',
561
756
  ' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
562
757
  ' code-graph-mcp callgraph SYMBOL # callers + callees',
563
- ];
564
- appendUnansweredTailNote(lines, unansweredTail);
565
- return lines.join('\n');
758
+ ].join('\n');
566
759
  }
567
760
 
568
761
  // v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
@@ -572,10 +765,15 @@ function buildBlockReason(unansweredTail) {
572
765
  // advertising the bypass taught it a permanent prefix within 5 seconds (daagu
573
766
  // 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
574
767
  // escape because there we give no answer.
575
- function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail) {
576
- const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
768
+ // `cmdShown` is rendered by the caller from the SAME argv array it handed the
769
+ // child process (cg-answer.buildGrepArgs → formatCgCommand). It used to be
770
+ // re-assembled here from `(pattern, searchPath)` alone, which is how a deny came
771
+ // to print `code-graph-mcp grep "X" tests` above an answer that had actually run
772
+ // with different arguments — the flags nobody forwarded, and a glob silently
773
+ // widened to its parent directory.
774
+ function buildBlockReasonWithAnswer(cmdShown, answer) {
577
775
  const lines = [
578
- `[code-graph] Raw \`grep\` on indexed source — denied; the AST-aware equivalent already ran for you:${tailFlagSuffix(unansweredTail)}`,
776
+ '[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:',
579
777
  `$ ${cmdShown}`,
580
778
  answer.text,
581
779
  ];
@@ -591,7 +789,6 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail)
591
789
  // (deny-with-answer already satisfies in-place, 5/5 in the daagu replay). The
592
790
  // engagement nudge is kept only where it pays — the pre-edit impact summary,
593
791
  // a true before-you-edit reconciliation moment. Re-add here only behind an A/B.
594
- appendUnansweredTailNote(lines, unansweredTail);
595
792
  return lines.join('\n');
596
793
  }
597
794
 
@@ -599,9 +796,9 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail)
599
796
  // context flags (-A/-B/-C = "show me the body"); the answer IS the bodies,
600
797
  // fetched via `code-graph-mcp show`. answer.text already carries per-symbol
601
798
  // `$ code-graph-mcp show <sym>` headers.
602
- function buildShowDenyReason(answer, unansweredTail) {
799
+ function buildShowDenyReason(answer) {
603
800
  const lines = [
604
- `[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:${tailFlagSuffix(unansweredTail)}`,
801
+ '[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:',
605
802
  answer.text,
606
803
  ];
607
804
  if (answer.truncated) {
@@ -610,7 +807,6 @@ function buildShowDenyReason(answer, unansweredTail) {
610
807
  lines.push('Use these directly instead of re-running the search.');
611
808
  // NOTE (v0.63): forced-ack salience trialed + removed here too — see
612
809
  // buildBlockReasonWithAnswer. The definitions are already delivered.
613
- appendUnansweredTailNote(lines, unansweredTail);
614
810
  return lines.join('\n');
615
811
  }
616
812
 
@@ -624,7 +820,16 @@ function translateBreToRg(cmd, pattern) {
624
820
  const verb = (cmd.match(GREP_HEAD) || [])[1];
625
821
  // git grep speaks BRE like plain grep; rg/ag are already extended-regex.
626
822
  if (!verb || !/grep$/.test(verb)) return pattern;
627
- if (/(?:^|\s)-[a-zA-Z]*[EP][a-zA-Z]*(?:\s|=|\d|$)|--(?:extended-regexp|perl-regexp)\b/.test(cmd)) {
823
+ // The grep's OWN clause, not the whole command. This was the one flag check
824
+ // in this module that v0.96 did not clause-scope, and round 4 of pre-ship
825
+ // review found what it costs: `grep -rln "a\|b" src/ | xargs -P4 wc -l` read
826
+ // the tail's `-P4` as "this grep speaks Perl regex", so the pattern was left
827
+ // escaped. On its own that is a wrong dialect decision; it also desynchronised
828
+ // the two hooks, because post-grep-inject passes a SEGMENT here while the deny
829
+ // path passes the whole command — the same pattern then filed under two
830
+ // spellings and the funnel scored a verbatim re-grep as neutral.
831
+ const clause = firstShellClause(cmd);
832
+ if (/(?:^|\s)-[a-zA-Z]*[EP][a-zA-Z]*(?:\s|=|\d|$)|--(?:extended-regexp|perl-regexp)\b/.test(clause)) {
628
833
  return pattern;
629
834
  }
630
835
  return pattern.replace(/\\([|(){}+?])/g, '$1');
@@ -725,7 +930,17 @@ function runMain() {
725
930
  // of an answered deny (inline answer ignored → fall-through) from a deeper
726
931
  // drill-down. undefined when there's no identifier-like pattern (unquoted / prose
727
932
  // grep) → omitted from the event, so the funnel stays back-compatible.
728
- const grepPattern = translateBreToRg(cmd, pickBlockPattern(cmd));
933
+ // `-F` asks for a LITERAL pattern, and the BRE→rust-regex unescape must not
934
+ // also run on it: `grep -F 'x\|y'` searches for the five characters `x\|y`,
935
+ // while the unescaped `x|y` is a different string (GNU grep on a file holding
936
+ // the literal: `grep -Fc 'x\|y'` is 1, `grep -Fc 'x|y'` is 0). Before this
937
+ // release the flag was dropped entirely, so the answer was merely broader;
938
+ // forwarding `-F` without this guard would make it search the wrong text.
939
+ const cgFlags = extractCgFlags(cmd);
940
+ const rawGrepPattern = pickBlockPattern(cmd);
941
+ const grepPattern = cgFlagSet(cgFlags).has('-F')
942
+ ? rawGrepPattern
943
+ : translateBreToRg(cmd, rawGrepPattern);
729
944
 
730
945
  // v0.48 — deliberate escape: record it (funnel visibility) and stay silent.
731
946
  // Before GREP_HEAD accepted bare KEY=VALUE prefixes these were invisible.
@@ -744,7 +959,26 @@ function runMain() {
744
959
 
745
960
  markCooldown(rawCmd, root);
746
961
 
747
- const block = isBlockDisabled() ? null : classifyBlock(cmd);
962
+ // classifyDeny, not classifyBlock: a command with a top-level `;`/`&&` tail is
963
+ // never denied, because cancelling it would cancel the tail too. Those run and
964
+ // are answered by post-grep-inject instead — which is also where they are
965
+ // RECORDED.
966
+ //
967
+ // Round 1 added an `observe compound:true` row here. Round 2 showed no counter
968
+ // reads the field, and round 3 showed the row double-counts: a head-grep
969
+ // compound whose grep hits writes this row AND post-grep-inject's redundancy
970
+ // observe, so one Bash call became two entries in a counter `usage.rs`
971
+ // documents as the model's raw search fan-out. The post-side rows carry more
972
+ // (they say what happened to the answer), so this side stays silent — exactly
973
+ // as it did before the compound change.
974
+ //
975
+ // One configuration where that leaves NO trace, named because round 4 caught
976
+ // the claim overstated: under `CODE_GRAPH_NO_INJECT=1` the post side does not
977
+ // run either, so a first-run compound is invisible to the funnel. A repeat
978
+ // within 60 s still lands in the cooldown `observe` branch above. A kill
979
+ // switch silencing the hook that carries the telemetry is coherent; silently
980
+ // claiming coverage it does not have is not.
981
+ const block = isBlockDisabled() ? null : classifyDeny(cmd);
748
982
  if (block) {
749
983
  // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
750
984
  // results in the deny reason ("answer in the deny"). Degrades to the
@@ -754,19 +988,24 @@ function runMain() {
754
988
  // falling back to the grep answer, then the static deny.
755
989
  let answer = { status: 'unavailable' };
756
990
  const pattern = grepPattern; // computed once above; reused for the answer + deny fingerprint
757
- // v0.48 — glob-truncated once, shared by the run and the deny message
758
- // (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
759
- const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
991
+ // The path is passed RAW (globs and all). buildGrepArgs splits `tests/*.mjs`
992
+ // into scope `tests` + `-g '*.mjs'`, which is both what keeps a literal glob
993
+ // out of argv (the exit-1 shape sanitizeSearchPath was added for) and what
994
+ // stops the answer from quietly searching files the user excluded.
995
+ const searchPath = extractSearchPath(cmd);
996
+ const flags = cgFlags; // computed once above, beside the -F pattern guard
997
+ // ONE argv, used to run the child AND to render the command the deny prints.
998
+ const args = buildGrepArgs({ pattern, searchPath, flags });
760
999
  let answeredMode = block.mode;
761
1000
  if (!isAnswerDisabled()) {
762
1001
  if (block.mode === 'show') {
763
1002
  answer = runShowAnswer({ cwd: root, symbols: block.symbols });
764
1003
  if (answer.status !== 'hits' && pattern) {
765
1004
  answeredMode = 'grep';
766
- answer = runGrepAnswer({ cwd: root, pattern, searchPath });
1005
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
767
1006
  }
768
1007
  } else if (pattern) {
769
- answer = runGrepAnswer({ cwd: root, pattern, searchPath });
1008
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
770
1009
  }
771
1010
  }
772
1011
 
@@ -802,9 +1041,6 @@ function runMain() {
802
1041
  // is the documented modern path. Exit 0 — this is a routing decision, not
803
1042
  // a hook failure (exit 2 would mark the tool call as "hook errored").
804
1043
  const answered = answer.status === 'hits';
805
- // v0.50 — flag the unanswered `;`/`&&` tail. Extracted from rawCmd: its
806
- // paths are valid in the model's shell cwd, where the re-issue will run.
807
- const unansweredTail = extractUnansweredTail(rawCmd);
808
1044
  recordRecommendation(root, {
809
1045
  hook: 'grep', action: 'deny', answered,
810
1046
  // pattern fingerprints the denied search so the funnel can score a verbatim
@@ -817,19 +1053,16 @@ function runMain() {
817
1053
  // 'unavailable' (binary ran but failed/timed out). Without this the two
818
1054
  // are indistinguishable in the funnel ("broken" looks like "no hits").
819
1055
  ...(answered ? {} : { reason: answer.status }),
820
- // tail segments compound-command denies — lets the funnel compare
821
- // re-issue behavior for denies that carried a tail note.
822
- ...(unansweredTail ? { tail: true } : {}),
823
1056
  });
824
1057
  process.stdout.write(JSON.stringify({
825
1058
  hookSpecificOutput: {
826
1059
  hookEventName: 'PreToolUse',
827
1060
  permissionDecision: 'deny',
828
1061
  permissionDecisionReason: !answered
829
- ? buildBlockReason(unansweredTail)
1062
+ ? buildBlockReason()
830
1063
  : answeredMode === 'show'
831
- ? buildShowDenyReason(answer, unansweredTail)
832
- : buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail),
1064
+ ? buildShowDenyReason(answer)
1065
+ : buildBlockReasonWithAnswer(formatCgCommand(args), answer),
833
1066
  },
834
1067
  }) + '\n');
835
1068
  return;
@@ -853,6 +1086,9 @@ module.exports = {
853
1086
  shouldHint,
854
1087
  shouldBlock,
855
1088
  classifyBlock, // v0.49 — intent-aware block tiers
1089
+ classifyDeny, // v0.144 — the deny gate: block-tier AND nothing discarded
1090
+ extractCgFlags, // v0.144 — the grep's flags in cg's spelling
1091
+ cgFlagSet, // v0.144 — the boolean flags of that result, without values
856
1092
  splitTopLevelSegments, // compound-grep — quote-aware top-level segment splitter (PostToolUse reuse)
857
1093
  firstShellClause, // v0.96 — grep's own clause (up to first top-level separator)
858
1094
  extractDeclSymbols, // v0.49 — show-mode symbol extraction
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
  - name: Build snapshot
37
37
  run: |
38
- npx -y -p @sdsrs/code-graph@0.143.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.145.0 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.143.0",
3
+ "version": "0.145.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.143.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.143.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.143.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.143.0",
42
- "@sdsrs/code-graph-win32-x64": "0.143.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.145.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.145.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.145.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.145.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.145.0"
43
43
  }
44
44
  }