@sdsrs/code-graph 0.48.0 → 0.50.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.
@@ -75,6 +75,48 @@ function hasInstalledPluginRecord() {
75
75
  return !!(installed && installed.plugins && Array.isArray(installed.plugins[PLUGIN_ID]) && installed.plugins[PLUGIN_ID].length > 0);
76
76
  }
77
77
 
78
+ /** The installPath Claude Code currently considers active (installed_plugins.json), or null. */
79
+ function activeInstallPath() {
80
+ const installed = readJson(installedPluginsPath());
81
+ const recs = installed && installed.plugins && installed.plugins[PLUGIN_ID];
82
+ if (!Array.isArray(recs) || !recs[0] || typeof recs[0].installPath !== 'string') return null;
83
+ return recs[0].installPath;
84
+ }
85
+
86
+ /**
87
+ * Stale-relic detection: is THIS script running from an old plugin-cache
88
+ * version dir while installed_plugins.json points at a different (active)
89
+ * install that exists on disk?
90
+ *
91
+ * Why: a still-running Claude Code process keeps firing SessionStart from the
92
+ * install path it loaded at startup. After auto-update installs vN+1 and
93
+ * re-anchors manifest + settings.json, the next SessionStart in that old
94
+ * process runs the vN scripts, whose syncLifecycleConfig sees
95
+ * `manifest.version !== currentVersion` and — direction-blind — calls
96
+ * update(), dragging manifest and every settings.json hook path back to the
97
+ * vN dir. The two versions then ping-pong (observed live 2026-06-12: manifest
98
+ * 0.49.0 → rewritten 0.48.0 fifteen minutes after a successful update).
99
+ *
100
+ * The authority is installed_plugins.json, not version direction: a deliberate
101
+ * downgrade via /plugin lands installPath == the old dir, so the old scripts
102
+ * keep full self-heal rights. Dev checkouts and npm installs are exempt
103
+ * (pluginRoot not under the plugins cache).
104
+ */
105
+ function isStaleRelicContext({
106
+ pluginRoot = PLUGIN_ROOT,
107
+ cacheRoot = pluginsCacheDir(),
108
+ activePath = activeInstallPath(),
109
+ existsSync = fs.existsSync,
110
+ } = {}) {
111
+ if (!activePath) return false;
112
+ const root = path.resolve(pluginRoot);
113
+ const cache = path.resolve(cacheRoot);
114
+ if (root !== cache && !root.startsWith(cache + path.sep)) return false;
115
+ const active = path.resolve(activePath);
116
+ if (active === root) return false;
117
+ return existsSync(path.join(active, 'scripts', 'lifecycle.js'));
118
+ }
119
+
78
120
  function isOurComposite(settings) {
79
121
  return settings.statusLine &&
80
122
  settings.statusLine.command &&
@@ -377,6 +419,50 @@ function registerHooksToSettings(settings) {
377
419
  return before !== JSON.stringify(settings.hooks);
378
420
  }
379
421
 
422
+ // Inventory of (event, matcher) tuples we expect to find in settings.json after
423
+ // install. Consumed by doctor (report + fix) and session-init (self-heal):
424
+ // `missing` = entry absent; `stale` = present but the registered command no
425
+ // longer matches what we'd write now (points at an old plugin-cache version
426
+ // dir / moved path). A stale path can run pre-recordRecommendation hook code,
427
+ // so the hook fires but the conversion metric stays dark — invisible to a
428
+ // present/absent check. This is the 0.45.1-registered-while-0.45.4-active
429
+ // case the RCA surfaced.
430
+ function surveyHookCoverage(settings) {
431
+ const desired = buildSettingsHookEntries();
432
+ const expected = [];
433
+ const desiredCmd = {}; // key -> command string we would write now
434
+ for (const [event, entries] of Object.entries(desired)) {
435
+ for (const e of entries) {
436
+ const key = `${event}:${e.matcher || '*'}`;
437
+ expected.push(key);
438
+ desiredCmd[key] = e.hooks && e.hooks[0] && e.hooks[0].command;
439
+ }
440
+ }
441
+
442
+ const present = new Set();
443
+ const presentCmd = {}; // key -> command currently registered
444
+ if (settings && settings.hooks) {
445
+ for (const [event, entries] of Object.entries(settings.hooks)) {
446
+ if (!Array.isArray(entries)) continue;
447
+ for (const entry of entries) {
448
+ if (isOurHookEntry(entry)) {
449
+ const key = `${event}:${entry.matcher || '*'}`;
450
+ present.add(key);
451
+ if (entry.hooks && entry.hooks[0] && entry.hooks[0].command) {
452
+ presentCmd[key] = entry.hooks[0].command;
453
+ }
454
+ }
455
+ }
456
+ }
457
+ }
458
+
459
+ const missing = expected.filter(k => !present.has(k));
460
+ const stale = expected.filter(k =>
461
+ present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
462
+ );
463
+ return { expected, present: [...present], missing, stale };
464
+ }
465
+
380
466
  // --- Install (idempotent) ---
381
467
 
382
468
  function install() {
@@ -673,6 +759,8 @@ module.exports = {
673
759
  getPluginVersion, cleanupOldCacheVersions,
674
760
  removeHooksFromSettings, isOurHookEntry,
675
761
  registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
762
+ surveyHookCoverage, compositeCommand, // v0.49.1 — version-aware self-heal
763
+ activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
676
764
  SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
677
765
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers
678
766
  registerStatuslineProvider, unregisterStatuslineProvider,
@@ -660,4 +660,43 @@ test('scanForBrokenPaths is exported and returns the issue structure', (t) => {
660
660
  assert.ok(Array.isArray(issues));
661
661
  assert.ok(issues.some(i => i.type === 'hook' && i.event === 'PreToolUse' && i.path.includes('/nonexistent/')),
662
662
  'scanForBrokenPaths must report the seeded broken hook entry');
663
- });
663
+ });
664
+ // ── isStaleRelicContext (v0.49.1 downgrade-war guard) ──────────────────────
665
+
666
+ test('isStaleRelicContext: relic in plugins cache defers to a different active install', (t) => {
667
+ const { isStaleRelicContext } = require('./lifecycle');
668
+ const cacheRoot = '/home/u/.claude/plugins/cache';
669
+ const relicRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.48.0`;
670
+ const activeRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.49.0`;
671
+
672
+ // The downgrade-war case: running from old cache dir, active points elsewhere.
673
+ assert.equal(isStaleRelicContext({
674
+ pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
675
+ existsSync: () => true,
676
+ }), true);
677
+
678
+ // Running FROM the active install → full self-heal rights.
679
+ assert.equal(isStaleRelicContext({
680
+ pluginRoot: activeRoot, cacheRoot, activePath: activeRoot,
681
+ existsSync: () => true,
682
+ }), false);
683
+
684
+ // Dev checkout / npm install (pluginRoot outside the plugins cache) → exempt.
685
+ assert.equal(isStaleRelicContext({
686
+ pluginRoot: '/repo/code-graph-mcp/claude-plugin', cacheRoot, activePath: activeRoot,
687
+ existsSync: () => true,
688
+ }), false);
689
+
690
+ // No installed_plugins record → exempt (nothing authoritative to defer to).
691
+ assert.equal(isStaleRelicContext({
692
+ pluginRoot: relicRoot, cacheRoot, activePath: null,
693
+ existsSync: () => true,
694
+ }), false);
695
+
696
+ // Active path recorded but its lifecycle.js is gone (cache wiped) → the
697
+ // relic is the only working copy left; keep self-heal rights.
698
+ assert.equal(isStaleRelicContext({
699
+ pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
700
+ existsSync: () => false,
701
+ }), false);
702
+ });
@@ -11,10 +11,14 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { findBinary } = require('./find-binary');
13
13
  const { cgTmpDir } = require('./tmp-dir');
14
+ const { resolveProjectRoot } = require('./project-root');
15
+ const { recordRecommendation } = require('./recommendation-log');
14
16
 
15
- const cwd = process.cwd();
16
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
17
- if (!fs.existsSync(dbPath)) process.exit(0);
17
+ // v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
18
+ // gate kept this hook dark for entire sessions after `cd backend/` — daagu
19
+ // 2026-06-12: 115 edits, zero impact injections.
20
+ const cwd = resolveProjectRoot(process.cwd());
21
+ if (cwd === null) process.exit(0);
18
22
 
19
23
  // Resolve binary the same way the other hooks do — bare PATH lookup misses
20
24
  // npm-global installs on systems where the global bin dir isn't on PATH for
@@ -22,10 +26,15 @@ if (!fs.existsSync(dbPath)) process.exit(0);
22
26
  const binary = findBinary();
23
27
  if (!binary) process.exit(0);
24
28
 
29
+ // Hook-internal CLI runs are deliveries, not model-initiated conversions —
30
+ // the marker keeps them out of the recommendations.jsonl `use` funnel leg.
31
+ const internalEnv = { ...process.env, CODE_GRAPH_INTERNAL: '1' };
32
+
25
33
  // --- Parse tool input ---
26
34
  let input;
27
35
  try {
28
- input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
36
+ // fd 0, not '/dev/stdin': the path form fails ENXIO on socketpair stdin.
37
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
29
38
  } catch { process.exit(0); }
30
39
 
31
40
  const oldStr = (input.tool_input && input.tool_input.old_string) || '';
@@ -72,6 +81,7 @@ if (!symbol || symbol.length < 3) {
72
81
  try {
73
82
  const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], {
74
83
  cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
84
+ env: internalEnv,
75
85
  });
76
86
  const grepResult = JSON.parse(raw);
77
87
  // Pick this candidate if it has few matches (precise location)
@@ -122,11 +132,14 @@ let jsonResult;
122
132
  try {
123
133
  const args = ['impact', symbol, '--json'];
124
134
  if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
125
- const raw = execFileSync('code-graph-mcp', args, {
135
+ // v0.49 use the resolved binary (bare 'code-graph-mcp' was PATH-dependent,
136
+ // diverging from the findBinary() result the rest of the hook trusts).
137
+ const raw = execFileSync(binary, args, {
126
138
  cwd,
127
139
  timeout: 2500,
128
140
  encoding: 'utf8',
129
141
  stdio: ['pipe', 'pipe', 'pipe'],
142
+ env: internalEnv,
130
143
  });
131
144
  jsonResult = JSON.parse(raw);
132
145
  } catch {
@@ -154,6 +167,9 @@ if (directCallers < 1) process.exit(0);
154
167
  // Mark cooldown
155
168
  try { fs.writeFileSync(cooldownFile, ''); } catch { /* ok */ }
156
169
 
170
+ // Funnel visibility (v0.49): an injected impact summary is a delivered answer.
171
+ recordRecommendation(cwd, { hook: 'edit', action: 'hint', answered: true });
172
+
157
173
  // --- Inject compact impact summary ---
158
174
  const routeCount = jsonResult.affected_routes || 0;
159
175
  const testCount = jsonResult.tests_affected || 0;
@@ -18,12 +18,15 @@
18
18
  // 2026-06-11 replay: 38/40 head-greps dark to this)
19
19
  // 6. Same command-hash not hinted within last 60s (per-command cooldown)
20
20
  //
21
- // BLOCK fires when shouldHint AND (shouldBlock):
22
- // 7. No precision flag in the command (-l / -A / -B / -C / --include / --exclude)
23
- // 8. Pattern looks identifier-like (CamelCase ≥4ch, or snake_case with _, or
24
- // a declaration anchor like `fn X` / `class X` / `def X`)
25
- // 9. Pattern is not a bare marker word (TODO/FIXME/XXX/HACK/WARN/ERROR/NOTE)
21
+ // BLOCK fires when shouldHint AND (classifyBlock, v0.49 intent-aware):
22
+ // 7. Pattern looks identifier-like (CamelCase ≥4ch, or snake_case with _, or
23
+ // a declaration anchor like `fn X` / `class X` / `def X`), quoted
24
+ // 8. Pattern is not a bare marker word (TODO/FIXME/XXX/HACK/WARN/ERROR/NOTE)
25
+ // 9. No unanswerable-intent flag (-L / -v / --exclude*) — those stay hint
26
26
  // 10. CODE_GRAPH_NO_BLOCK_GREP != "1" (block escape, independent of QUIET_HOOKS)
27
+ // Mode: context flags (-A/-B/-C) + declaration anchors → 'show' (deny carries
28
+ // the symbol BODIES via `cg show`); context flags without named decls → hint;
29
+ // everything else (incl. -l / --include) → 'grep' (deny carries the hits).
27
30
  //
28
31
  // A `CODE_GRAPH_NO_BLOCK_GREP=1`-prefixed grep that would otherwise hint is
29
32
  // recorded as `action:'bypass'` and allowed silently (v0.48) — previously the
@@ -34,12 +37,11 @@
34
37
  // lookups, or the rare legitimate use of raw grep on indexed source.
35
38
 
36
39
  const fs = require('fs');
37
- const os = require('os');
38
40
  const path = require('path');
39
41
  const crypto = require('crypto');
40
42
  const { cgTmpDir } = require('./tmp-dir');
41
43
  const { recordRecommendation } = require('./recommendation-log');
42
- const { runGrepAnswer, sanitizeSearchPath } = require('./cg-answer');
44
+ const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
43
45
 
44
46
  // --- Pure logic (testable) ---
45
47
 
@@ -83,17 +85,21 @@ function shouldHint(cmd) {
83
85
  return true;
84
86
  }
85
87
 
86
- // v0.32.0 block tier strictly narrower than shouldHint. The disqualifying
87
- // flags (-l, -A, -B, -C, --include, --exclude) mean the user is already doing
88
- // precise filtering and a blanket "use cg" suggestion would be wrong. The
89
- // identifier-like check restricts blocks to "I'm looking for a symbol" — the
90
- // exact use case cg replaces. Marker-only patterns (TODO/FIXME) are legit raw
91
- // text scans with no cg equivalent.
92
- // Match any short-flag cluster containing l/L/A/B/C (e.g. `-l`, `-rl`, `-rln`,
93
- // `-A`, `-rA3`). Combined flag clusters are common in real-world usage and the
94
- // "precision intent" applies as soon as ANY of these letters appears.
95
- const BLOCK_DISQUALIFYING_FLAGS =
96
- /(?:^|\s)-[a-zA-Z]*[lLABC][a-zA-Z]*(?:\s|=|\d|$)|--(?:files-with-matches|files-without-match|include|exclude|exclude-dir|after-context|before-context|context)\b/;
88
+ // v0.49 intent-aware block tiers. The v0.32 rationale ("precision flags mean
89
+ // the user is filtering a blanket *suggestion* would be wrong") was written
90
+ // for the suggestion era; in the answer era the deny CARRIES the result, so a
91
+ // flag only disqualifies when the answer cannot honor its intent. 2026-06-12
92
+ // daagu replay: 22/128 head-greps were `rg "def X|class Y" -A 25` — function-
93
+ // body reads the old rule exempted to (ignored) hints; `cg show` answers them.
94
+ //
95
+ // Context flags (-A/-B/-C): intent = read surrounding code. Answerable via
96
+ // `show` only when the pattern names declarations; bare-identifier + context
97
+ // stays hint (a grep-style answer can't honor ±N lines).
98
+ const CONTEXT_FLAG =
99
+ /(?:^|\s)-[a-zA-Z]*[ABC][a-zA-Z]*(?:\s|=|\d|$)|--(?:after-context|before-context|context)\b/;
100
+ // Intents the cg answer cannot honor: inverted file lists, exclusion scoping.
101
+ const UNANSWERABLE_FLAGS =
102
+ /(?:^|\s)-[a-zA-Z]*[Lv][a-zA-Z]*(?:\s|=|\d|$)|--(?:files-without-match|invert-match|exclude|exclude-dir)\b/;
97
103
  // v0.32.1: drop the `type` declaration keyword (too common in English prose
98
104
  // like "# type checking") and anchor declaration anchors to pattern start
99
105
  // (otherwise `"some type X"` matches). CamelCase and snake_case still match
@@ -120,13 +126,41 @@ function extractPatterns(cmd) {
120
126
  return matches.map(m => m[1] !== undefined ? m[1] : m[2]);
121
127
  }
122
128
 
123
- function shouldBlock(cmd) {
124
- if (!shouldHint(cmd)) return false; // narrower than hint
125
- if (BLOCK_DISQUALIFYING_FLAGS.test(cmd)) return false;
126
- if (MARKER_ONLY.test(cmd)) return false; // bare TODO/FIXME — no cg equivalent
129
+ // Declaration anchors inside a pattern (`def cascade_failure|class TaskState`)
130
+ // name the exact symbols the model wants to READ — extract them for `show`.
131
+ const DECL_SYMBOL = /(?:fn|def|class|function|struct|impl|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
132
+
133
+ function extractDeclSymbols(patterns) {
134
+ const out = [];
135
+ for (const p of patterns) {
136
+ for (const m of p.matchAll(DECL_SYMBOL)) {
137
+ if (!out.includes(m[1])) out.push(m[1]);
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /// Block-tier classification (strictly narrower than shouldHint):
144
+ /// {mode:'show', symbols} — declaration anchors + context flags → deliver bodies
145
+ /// {mode:'grep'} — symbol search (incl. -l / --include) → deliver hits
146
+ /// null — hint tier (marker scans, unquoted, unanswerable flags)
147
+ function classifyBlock(cmd) {
148
+ if (!shouldHint(cmd)) return null; // narrower than hint
149
+ if (UNANSWERABLE_FLAGS.test(cmd)) return null; // intent the answer can't honor
150
+ if (MARKER_ONLY.test(cmd)) return null; // bare TODO/FIXME — no cg equivalent
127
151
  const patterns = extractPatterns(cmd);
128
- if (patterns.length === 0) return false; // unquoted pattern — conservative, hint
129
- return patterns.some(p => IDENTIFIER_LIKE.test(p));
152
+ if (patterns.length === 0) return null; // unquoted pattern — conservative, hint
153
+ if (!patterns.some(p => IDENTIFIER_LIKE.test(p))) return null;
154
+ if (CONTEXT_FLAG.test(cmd)) {
155
+ const symbols = extractDeclSymbols(patterns);
156
+ if (symbols.length === 0) return null; // context read without named decls
157
+ return { mode: 'show', symbols: symbols.slice(0, 3) };
158
+ }
159
+ return { mode: 'grep' };
160
+ }
161
+
162
+ function shouldBlock(cmd) {
163
+ return classifyBlock(cmd) !== null;
130
164
  }
131
165
 
132
166
  // v0.47.1 — CC harness steers Bash toward ABSOLUTE paths (cd in compound
@@ -143,23 +177,9 @@ function normalizeCommandPaths(cmd, cwd) {
143
177
  return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
144
178
  }
145
179
 
146
- // v0.48 — the hook's process.cwd() follows the PERSISTENT shell, not the
147
- // project root: after the model runs `cd backend/`, every later bare grep used
148
- // to fail the index.db gate silently for the rest of the session (daagu
149
- // 2026-06-11: 38/40 head-greps dark). Walk up to the nearest ancestor holding
150
- // `.code-graph/index.db`; stop at $HOME (checked, not crossed) and fs root.
151
- function resolveProjectRoot(startDir, opts = {}) {
152
- const home = opts.home !== undefined ? opts.home : os.homedir();
153
- const exists = opts.exists || fs.existsSync;
154
- let dir = path.resolve(startDir || '.');
155
- for (;;) {
156
- if (exists(path.join(dir, '.code-graph', 'index.db'))) return dir;
157
- if (dir === home) return null;
158
- const parent = path.dirname(dir);
159
- if (parent === dir) return null;
160
- dir = parent;
161
- }
162
- }
180
+ // v0.48 — subdir-cwd fix; v0.49 extracted to project-root.js so the read
181
+ // hook shares it. Re-exported below for test/back-compat.
182
+ const { resolveProjectRoot } = require('./project-root');
163
183
 
164
184
  // v0.48 — companion to resolveProjectRoot: when the shell sits in a subdir,
165
185
  // bare relative path args (`app --include=*.py` from backend/) are
@@ -199,13 +219,67 @@ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
199
219
  }).join('');
200
220
  }
201
221
 
202
- // v0.48 — bypass detection on the RAW command. The deny message documents
203
- // `CODE_GRAPH_NO_BLOCK_GREP=1` as a per-command escape; record its use so the
204
- // conversion funnel can see escape adoption instead of going dark.
222
+ // v0.48 — bypass detection on the RAW command. (The deny copy stopped teaching
223
+ // the escape in v0.49, but models that already know it — or learned it from a
224
+ // session summary must stay visible to the funnel.)
205
225
  function commandHasBypass(cmd) {
206
226
  return typeof cmd === 'string' && /(?:^|\s)CODE_GRAPH_NO_BLOCK_GREP=1(?:\s|$)/.test(cmd);
207
227
  }
208
228
 
229
+ // v0.49 — `sed -n X,Yp file.py` is a Read the Read hook can't see; the
230
+ // 2026-06-12 night used it heavily for structure exploration (four sed-range
231
+ // reads of stock_picker/ in 3 min). Extract targets so they count toward the
232
+ // shared read-fanout state.
233
+ const SED_RANGE = /(?:^|[|;&]\s*)sed\s+-n\s+(?:['"]\d+,\d+p['"]|\d+,\d+p)\s+("[^"]+"|'[^']+'|[^\s;|&]+)/g;
234
+
235
+ function extractSedReadTargets(cmd) {
236
+ if (!cmd || typeof cmd !== 'string' || cmd.length > 2000) return [];
237
+ const out = [];
238
+ for (const m of cmd.matchAll(SED_RANGE)) {
239
+ const tok = m[1].replace(/^["']|["']$/g, '');
240
+ if (tok && !out.includes(tok)) out.push(tok);
241
+ }
242
+ return out;
243
+ }
244
+
245
+ // v0.50 — a compound command (`grep …; sed -n 1,60p f` / `grep … && wc`) is
246
+ // denied WHOLE, but the answer covers only the grep. The 2026-06-13 mem-project
247
+ // deny swallowed a `; sed` read while the copy said "use these results directly
248
+ // instead of re-running" — the tail's intent was silently dropped. Extract the
249
+ // first top-level `;`/`&&` tail (quote-aware) so the deny can flag it for
250
+ // re-issue. `||` tails are skipped: the answer delivered hits, so the on-failure
251
+ // branch would not have run anyway. Pipes/redirects are the same pipeline.
252
+ function extractUnansweredTail(cmd) {
253
+ if (!cmd || typeof cmd !== 'string' || cmd.length > 2000) return null;
254
+ let quote = null;
255
+ for (let i = 0; i < cmd.length; i++) {
256
+ const c = cmd[i];
257
+ if (quote) {
258
+ if (c === quote) quote = null;
259
+ continue;
260
+ }
261
+ if (c === '"' || c === "'") { quote = c; continue; }
262
+ if (c === ';' || (c === '&' && cmd[i + 1] === '&')) {
263
+ const tail = cmd.slice(i + (c === ';' ? 1 : 2)).trim();
264
+ return tail || null;
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+
270
+ // Shared deny-copy footer for the unanswered compound tail. Budget-conscious:
271
+ // two lines, verbatim command so the model can re-issue without thinking.
272
+ // Wording is path-neutral — it must stay true for BOTH the answered deny
273
+ // ("grep half answered, tail wasn't") and the static fallback (nothing was).
274
+ function appendUnansweredTailNote(lines, tail) {
275
+ if (!tail) return;
276
+ const shown = tail.length > 300 ? tail.slice(0, 300) + '…' : tail;
277
+ lines.push(
278
+ 'NOTE: the rest of this compound command (after the grep) did NOT run — re-issue it separately:',
279
+ `$ ${shown}`,
280
+ );
281
+ }
282
+
209
283
  // v0.47.0 — pull the first source-tree path token out of the denied command so
210
284
  // the inline answer can scope its search the same way the raw grep would have.
211
285
  function extractSearchPath(cmd) {
@@ -246,7 +320,7 @@ function buildHint() {
246
320
  // Terse, no banner spam. Single message budget ~600 bytes.
247
321
  return [
248
322
  '[code-graph] Raw `grep`/`rg` on indexed source — consider AST-aware equivalents:',
249
- ' • code-graph-mcp grep "<pat>" <path> # grep + containing fn/module per hit',
323
+ ' • code-graph-mcp grep "<pat>" [paths...] # grep + containing fn/module per hit (-F literal, -i, -w, -l, -C N)',
250
324
  ' • code-graph-mcp ast-search "<pat>" --type fn # filter by type/returns/params',
251
325
  ' • code-graph-mcp callgraph SYMBOL # callers + callees, repo-wide',
252
326
  ' • code-graph-mcp show SYMBOL # one symbol: signature + source',
@@ -254,18 +328,22 @@ function buildHint() {
254
328
  ].join('\n');
255
329
  }
256
330
 
257
- function buildBlockReason() {
331
+ function buildBlockReason(unansweredTail) {
258
332
  // Shown to Claude via PreToolUse `decision: block` reason. Must give a
259
333
  // concrete alternate command Claude can re-issue without further thinking.
260
- return [
334
+ // v0.49 — NO escape-hatch line anywhere in deny copy: the daagu 2026-06-12
335
+ // night proved even the "THIS command only" scoping reads as a teachable
336
+ // permanent prefix (adopted in 8s, reused 11×, incl. on the exact identifier
337
+ // searches this hook targets). The env opt-out stays documented in README.
338
+ const lines = [
261
339
  '[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
262
340
  'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
263
- ' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
341
+ ' code-graph-mcp grep "<pattern>" [paths...] # AST context per hit; -F literal, -i, -w, -l, -C N, --max-count 0',
264
342
  ' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
265
343
  ' code-graph-mcp callgraph SYMBOL # callers + callees',
266
- 'If this specific search truly needs raw-text regex (log/comment scan), prepend',
267
- '`CODE_GRAPH_NO_BLOCK_GREP=1` to THIS command only — a per-command escape, not a default prefix.',
268
- ].join('\n');
344
+ ];
345
+ appendUnansweredTailNote(lines, unansweredTail);
346
+ return lines.join('\n');
269
347
  }
270
348
 
271
349
  // v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
@@ -275,7 +353,7 @@ function buildBlockReason() {
275
353
  // advertising the bypass taught it a permanent prefix within 5 seconds (daagu
276
354
  // 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
277
355
  // escape because there we give no answer.
278
- function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
356
+ function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail) {
279
357
  const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
280
358
  const lines = [
281
359
  '[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:',
@@ -288,14 +366,47 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
288
366
  lines.push(
289
367
  'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
290
368
  );
369
+ appendUnansweredTailNote(lines, unansweredTail);
370
+ return lines.join('\n');
371
+ }
372
+
373
+ // v0.49 — show-mode deny: the model grepped for symbol DEFINITIONS with
374
+ // context flags (-A/-B/-C = "show me the body"); the answer IS the bodies,
375
+ // fetched via `code-graph-mcp show`. answer.text already carries per-symbol
376
+ // `$ code-graph-mcp show <sym>` headers.
377
+ function buildShowDenyReason(answer, unansweredTail) {
378
+ const lines = [
379
+ '[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:',
380
+ answer.text,
381
+ ];
382
+ if (answer.truncated) {
383
+ lines.push('(truncated — re-run the `code-graph-mcp show <symbol>` command above for full source)');
384
+ }
385
+ lines.push('Use these directly instead of re-running the search.');
386
+ appendUnansweredTailNote(lines, unansweredTail);
291
387
  return lines.join('\n');
292
388
  }
293
389
 
390
+ // v0.49 — plain `grep` speaks BRE: alternation/grouping arrive escaped
391
+ // (`a\|b`, `\(x\)`) and 0-hit against cg grep's rust-regex dialect, wasting
392
+ // the answer on the ALLOW fallthrough (2026-06-12: both answered:false denies
393
+ // were dialect/path-shape misses). Unescape for plain grep only — rg/ag and
394
+ // grep -E/-P are already extended.
395
+ function translateBreToRg(cmd, pattern) {
396
+ if (typeof pattern !== 'string' || !pattern) return pattern;
397
+ const verb = (cmd.match(GREP_HEAD) || [])[1];
398
+ if (verb !== 'grep') return pattern;
399
+ if (/(?:^|\s)-[a-zA-Z]*[EP][a-zA-Z]*(?:\s|=|\d|$)|--(?:extended-regexp|perl-regexp)\b/.test(cmd)) {
400
+ return pattern;
401
+ }
402
+ return pattern.replace(/\\([|(){}+?])/g, '$1');
403
+ }
404
+
294
405
  // v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
295
406
  // ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead.
296
407
  // Let the raw grep through with an honest one-liner.
297
408
  function buildNoHitsFyi(pattern) {
298
- return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding.`;
409
+ return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding. (Regex-metachar patterns: \`code-graph-mcp grep -F\` searches literally.)`;
299
410
  }
300
411
 
301
412
  // --- Main execution (only when run directly) ---
@@ -338,6 +449,24 @@ function runMain() {
338
449
  } catch { return; }
339
450
 
340
451
  const rawCmd = (input.tool_input && input.tool_input.command) || '';
452
+
453
+ // v0.49 — sed-range reads count toward the read-fanout state (the Read hook
454
+ // never sees Bash-side file reads). A fired fanout hint already delivered an
455
+ // overview — skip grep hinting for this command to avoid double output.
456
+ const sedTargets = extractSedReadTargets(rawCmd);
457
+ if (sedTargets.length > 0) {
458
+ const readGuide = require('./pre-read-guide');
459
+ let fanoutFired = false;
460
+ for (const t of sedTargets) {
461
+ if (!readGuide.isSourceFile(t)) continue;
462
+ const abs = path.isAbsolute(t) ? t : path.resolve(shellCwd, t);
463
+ if (readGuide.trackReadAndMaybeHint(root, path.relative(root, abs))) {
464
+ fanoutFired = true;
465
+ }
466
+ }
467
+ if (fanoutFired) return;
468
+ }
469
+
341
470
  // v0.47.1 — match against the root-stripped form so absolute paths under the
342
471
  // project root behave exactly like their relative spelling. v0.48 — then
343
472
  // rebase bare subdir-relative tokens onto the root. Cooldown stays keyed on
@@ -358,18 +487,30 @@ function runMain() {
358
487
 
359
488
  markCooldown(rawCmd);
360
489
 
361
- if (!isBlockDisabled() && shouldBlock(cmd)) {
490
+ const block = isBlockDisabled() ? null : classifyBlock(cmd);
491
+ if (block) {
362
492
  // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
363
493
  // results in the deny reason ("answer in the deny"). Degrades to the
364
494
  // v0.46 static deny on any failure; downgrades to allow+FYI on 0 hits
365
495
  // (regex-dialect differences mean 0 hits ≠ proof of absence).
496
+ // v0.49 — intent-aware: declaration+context greps get `show` bodies,
497
+ // falling back to the grep answer, then the static deny.
366
498
  let answer = { status: 'unavailable' };
367
- const pattern = pickBlockPattern(cmd);
499
+ const pattern = translateBreToRg(cmd, pickBlockPattern(cmd));
368
500
  // v0.48 — glob-truncated once, shared by the run and the deny message
369
501
  // (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
370
502
  const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
371
- if (!isAnswerDisabled() && pattern) {
372
- answer = runGrepAnswer({ cwd: root, pattern, searchPath });
503
+ let answeredMode = block.mode;
504
+ if (!isAnswerDisabled()) {
505
+ if (block.mode === 'show') {
506
+ answer = runShowAnswer({ cwd: root, symbols: block.symbols });
507
+ if (answer.status !== 'hits' && pattern) {
508
+ answeredMode = 'grep';
509
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath });
510
+ }
511
+ } else if (pattern) {
512
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath });
513
+ }
373
514
  }
374
515
 
375
516
  if (answer.status === 'no-hits') {
@@ -383,16 +524,27 @@ function runMain() {
383
524
  // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
384
525
  // is the documented modern path. Exit 0 — this is a routing decision, not
385
526
  // a hook failure (exit 2 would mark the tool call as "hook errored").
527
+ const answered = answer.status === 'hits';
528
+ // v0.50 — flag the unanswered `;`/`&&` tail. Extracted from rawCmd: its
529
+ // paths are valid in the model's shell cwd, where the re-issue will run.
530
+ const unansweredTail = extractUnansweredTail(rawCmd);
386
531
  recordRecommendation(root, {
387
- hook: 'grep', action: 'deny', answered: answer.status === 'hits',
532
+ hook: 'grep', action: 'deny', answered,
533
+ // mode segments which answer type converts (show=bodies, grep=hits).
534
+ ...(answered ? { mode: answeredMode } : {}),
535
+ // tail segments compound-command denies — lets the funnel compare
536
+ // re-issue behavior for denies that carried a tail note.
537
+ ...(unansweredTail ? { tail: true } : {}),
388
538
  });
389
539
  process.stdout.write(JSON.stringify({
390
540
  hookSpecificOutput: {
391
541
  hookEventName: 'PreToolUse',
392
542
  permissionDecision: 'deny',
393
- permissionDecisionReason: answer.status === 'hits'
394
- ? buildBlockReasonWithAnswer(pattern, searchPath, answer)
395
- : buildBlockReason(),
543
+ permissionDecisionReason: !answered
544
+ ? buildBlockReason(unansweredTail)
545
+ : answeredMode === 'show'
546
+ ? buildShowDenyReason(answer, unansweredTail)
547
+ : buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail),
396
548
  },
397
549
  }) + '\n');
398
550
  return;
@@ -409,6 +561,12 @@ if (require.main === module) {
409
561
  module.exports = {
410
562
  shouldHint,
411
563
  shouldBlock,
564
+ classifyBlock, // v0.49 — intent-aware block tiers
565
+ extractDeclSymbols, // v0.49 — show-mode symbol extraction
566
+ translateBreToRg, // v0.49 — BRE→rust-regex dialect bridge
567
+ buildShowDenyReason, // v0.49 — show-mode deny copy
568
+ extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
569
+ extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
412
570
  extractPatterns, // v0.32.1 — exposed for tests
413
571
  extractSearchPath, // v0.47.0 — deny-with-answer
414
572
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix