@sdsrs/code-graph 0.47.1 → 0.49.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.
@@ -12,15 +12,26 @@
12
12
  // 2. Args include an indexed source-tree path (src/ tests/ lib/ scripts/ ...)
13
13
  // 3. Not searching only a config/lockfile (Cargo.toml/.gitignore/*.md/*.json)
14
14
  // 4. Command doesn't already invoke code-graph-mcp (no double-suggest)
15
- // 5. .code-graph/index.db exists in CWD
15
+ // 5. .code-graph/index.db exists in CWD or a parent up to $HOME (v0.48: the
16
+ // hook's cwd follows the persistent shell — after `cd backend/` every
17
+ // gate used to fail silently for the rest of the session; daagu
18
+ // 2026-06-11 replay: 38/40 head-greps dark to this)
16
19
  // 6. Same command-hash not hinted within last 60s (per-command cooldown)
17
20
  //
18
- // BLOCK fires when shouldHint AND (shouldBlock):
19
- // 7. No precision flag in the command (-l / -A / -B / -C / --include / --exclude)
20
- // 8. Pattern looks identifier-like (CamelCase ≥4ch, or snake_case with _, or
21
- // a declaration anchor like `fn X` / `class X` / `def X`)
22
- // 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
23
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).
30
+ //
31
+ // A `CODE_GRAPH_NO_BLOCK_GREP=1`-prefixed grep that would otherwise hint is
32
+ // recorded as `action:'bypass'` and allowed silently (v0.48) — previously the
33
+ // bare KEY=VALUE prefix failed GREP_HEAD and the escape was invisible to the
34
+ // conversion funnel (daagu 2026-06-11: 14 bypassed greps, 0 recorded).
24
35
  //
25
36
  // Exits silently otherwise — zero noise for build greps, log filters, config
26
37
  // lookups, or the rare legitimate use of raw grep on indexed source.
@@ -30,11 +41,14 @@ const path = require('path');
30
41
  const crypto = require('crypto');
31
42
  const { cgTmpDir } = require('./tmp-dir');
32
43
  const { recordRecommendation } = require('./recommendation-log');
33
- const { runGrepAnswer } = require('./cg-answer');
44
+ const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
34
45
 
35
46
  // --- Pure logic (testable) ---
36
47
 
37
- const GREP_HEAD = /^\s*(?:env\s+\S+=\S+\s+)*(grep|rg|ag)\b/;
48
+ // v0.48: also match bare `KEY=VALUE grep` prefixes (no `env` verb) — the shape
49
+ // the deny message itself teaches (`CODE_GRAPH_NO_BLOCK_GREP=1 grep …`). With
50
+ // the old `env`-only form those commands failed gate 1 and were invisible.
51
+ const GREP_HEAD = /^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(grep|rg|ag)\b/;
38
52
  // Source-tree prefix list. Expanded v0.27+ Phase C: original `src/tests/lib/...`
39
53
  // missed real-world backend conventions where the prefix list term is preceded
40
54
  // by something else (`backend/app/...` — `app/` doesn't match because `/` isn't
@@ -71,17 +85,21 @@ function shouldHint(cmd) {
71
85
  return true;
72
86
  }
73
87
 
74
- // v0.32.0 block tier strictly narrower than shouldHint. The disqualifying
75
- // flags (-l, -A, -B, -C, --include, --exclude) mean the user is already doing
76
- // precise filtering and a blanket "use cg" suggestion would be wrong. The
77
- // identifier-like check restricts blocks to "I'm looking for a symbol" — the
78
- // exact use case cg replaces. Marker-only patterns (TODO/FIXME) are legit raw
79
- // text scans with no cg equivalent.
80
- // Match any short-flag cluster containing l/L/A/B/C (e.g. `-l`, `-rl`, `-rln`,
81
- // `-A`, `-rA3`). Combined flag clusters are common in real-world usage and the
82
- // "precision intent" applies as soon as ANY of these letters appears.
83
- const BLOCK_DISQUALIFYING_FLAGS =
84
- /(?:^|\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/;
85
103
  // v0.32.1: drop the `type` declaration keyword (too common in English prose
86
104
  // like "# type checking") and anchor declaration anchors to pattern start
87
105
  // (otherwise `"some type X"` matches). CamelCase and snake_case still match
@@ -99,8 +117,8 @@ const MARKER_ONLY =
99
117
  // symbol-shaped target".
100
118
  function extractPatterns(cmd) {
101
119
  if (!cmd || typeof cmd !== 'string') return [];
102
- // Strip leading verb + env prefix
103
- const stripped = cmd.replace(/^\s*(?:env\s+\S+=\S+\s+)*(?:grep|rg|ag)\s+/, '');
120
+ // Strip leading verb + env/assignment prefix (kept in sync with GREP_HEAD)
121
+ const stripped = cmd.replace(/^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:grep|rg|ag)\s+/, '');
104
122
  // Collect every quoted argument — first one is the pattern in standard grep
105
123
  // usage; subsequent ones (e.g. `-e "second"`) are also patterns or filter
106
124
  // expressions and worth screening too.
@@ -108,13 +126,41 @@ function extractPatterns(cmd) {
108
126
  return matches.map(m => m[1] !== undefined ? m[1] : m[2]);
109
127
  }
110
128
 
111
- function shouldBlock(cmd) {
112
- if (!shouldHint(cmd)) return false; // narrower than hint
113
- if (BLOCK_DISQUALIFYING_FLAGS.test(cmd)) return false;
114
- 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
115
151
  const patterns = extractPatterns(cmd);
116
- if (patterns.length === 0) return false; // unquoted pattern — conservative, hint
117
- 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;
118
164
  }
119
165
 
120
166
  // v0.47.1 — CC harness steers Bash toward ABSOLUTE paths (cd in compound
@@ -131,6 +177,71 @@ function normalizeCommandPaths(cmd, cwd) {
131
177
  return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
132
178
  }
133
179
 
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');
183
+
184
+ // v0.48 — companion to resolveProjectRoot: when the shell sits in a subdir,
185
+ // bare relative path args (`app --include=*.py` from backend/) are
186
+ // subdir-relative and never match the root-relative SRC_PATH prefixes. Rebase
187
+ // each candidate token onto the project root; a token only counts as a path
188
+ // when the rebased form EXISTS under the root — quoted patterns, flags,
189
+ // operators, absolute and traversal tokens are never touched. Existence is the
190
+ // workhorse gate: it keeps unquoted pattern words from masquerading as paths
191
+ // (the exact shape that would re-create the answered:false glob failure).
192
+ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
193
+ if (!cmd || typeof cmd !== 'string' || !relPrefix || !rootDir) return cmd;
194
+ const prefix = relPrefix.split(path.sep).join('/');
195
+ // Shell sits outside any known source dir (docs/, target/, …) — don't guess.
196
+ if (!SRC_PATH_TOKEN.test(prefix + '/')) return cmd;
197
+ let verbSeen = false;
198
+ return cmd.split(/(\s+)/).map((tok) => {
199
+ if (!tok || /^\s+$/.test(tok)) return tok;
200
+ if (!verbSeen) {
201
+ if (/^(?:env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(tok)) return tok;
202
+ verbSeen = true; // the verb itself (grep/rg/ag) — never a path
203
+ return tok;
204
+ }
205
+ if (/^["']/.test(tok)) return tok; // quoted → pattern
206
+ if (tok.startsWith('-')) return tok; // flag
207
+ if (tok.startsWith('/')) return tok; // absolute (foreign — root strip already ran)
208
+ if (tok.includes('..')) return tok; // traversal
209
+ if (/[|;&<>=\\$`'"]/.test(tok)) return tok; // operators / redirects / assignments / escapes
210
+ const candidate = prefix + '/' + tok;
211
+ // Probe existence on the glob-truncated form: `app/…/llm_engine/*.py`
212
+ // must still rebase (its dir exists) or the deny-answer would run a
213
+ // subdir-relative path from the root and fail (answered:false again).
214
+ const probe = sanitizeSearchPath(candidate);
215
+ try {
216
+ if (!probe || !exists(path.join(rootDir, probe))) return tok;
217
+ } catch { return tok; }
218
+ return candidate;
219
+ }).join('');
220
+ }
221
+
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.)
225
+ function commandHasBypass(cmd) {
226
+ return typeof cmd === 'string' && /(?:^|\s)CODE_GRAPH_NO_BLOCK_GREP=1(?:\s|$)/.test(cmd);
227
+ }
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
+
134
245
  // v0.47.0 — pull the first source-tree path token out of the denied command so
135
246
  // the inline answer can scope its search the same way the raw grep would have.
136
247
  function extractSearchPath(cmd) {
@@ -182,20 +293,26 @@ function buildHint() {
182
293
  function buildBlockReason() {
183
294
  // Shown to Claude via PreToolUse `decision: block` reason. Must give a
184
295
  // concrete alternate command Claude can re-issue without further thinking.
296
+ // v0.49 — NO escape-hatch line anywhere in deny copy: the daagu 2026-06-12
297
+ // night proved even the "THIS command only" scoping reads as a teachable
298
+ // permanent prefix (adopted in 8s, reused 11×, incl. on the exact identifier
299
+ // searches this hook targets). The env opt-out stays documented in README.
185
300
  return [
186
301
  '[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
187
302
  'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
188
303
  ' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
189
304
  ' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
190
305
  ' code-graph-mcp callgraph SYMBOL # callers + callees',
191
- 'For raw-text scans (log/comment/marker), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.',
192
306
  ].join('\n');
193
307
  }
194
308
 
195
309
  // v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
196
310
  // deny still asks the model to initiate a new tool call; embedding the actual
197
- // results removes that choice entirely. Keep the escape hatch line — raw-text
198
- // regex (BRE alternation, log scans) remains a legitimate need.
311
+ // results removes that choice entirely.
312
+ // v0.48 NO escape-hatch line here: the model already has the results, and
313
+ // advertising the bypass taught it a permanent prefix within 5 seconds (daagu
314
+ // 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
315
+ // escape because there we give no answer.
199
316
  function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
200
317
  const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
201
318
  const lines = [
@@ -208,11 +325,41 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
208
325
  }
209
326
  lines.push(
210
327
  'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
211
- 'For raw-text regex (alternation, log/comment scans), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.',
212
328
  );
213
329
  return lines.join('\n');
214
330
  }
215
331
 
332
+ // v0.49 — show-mode deny: the model grepped for symbol DEFINITIONS with
333
+ // context flags (-A/-B/-C = "show me the body"); the answer IS the bodies,
334
+ // fetched via `code-graph-mcp show`. answer.text already carries per-symbol
335
+ // `$ code-graph-mcp show <sym>` headers.
336
+ function buildShowDenyReason(answer) {
337
+ const lines = [
338
+ '[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:',
339
+ answer.text,
340
+ ];
341
+ if (answer.truncated) {
342
+ lines.push('(truncated — re-run the `code-graph-mcp show <symbol>` command above for full source)');
343
+ }
344
+ lines.push('Use these directly instead of re-running the search.');
345
+ return lines.join('\n');
346
+ }
347
+
348
+ // v0.49 — plain `grep` speaks BRE: alternation/grouping arrive escaped
349
+ // (`a\|b`, `\(x\)`) and 0-hit against cg grep's rust-regex dialect, wasting
350
+ // the answer on the ALLOW fallthrough (2026-06-12: both answered:false denies
351
+ // were dialect/path-shape misses). Unescape for plain grep only — rg/ag and
352
+ // grep -E/-P are already extended.
353
+ function translateBreToRg(cmd, pattern) {
354
+ if (typeof pattern !== 'string' || !pattern) return pattern;
355
+ const verb = (cmd.match(GREP_HEAD) || [])[1];
356
+ if (verb !== 'grep') return pattern;
357
+ if (/(?:^|\s)-[a-zA-Z]*[EP][a-zA-Z]*(?:\s|=|\d|$)|--(?:extended-regexp|perl-regexp)\b/.test(cmd)) {
358
+ return pattern;
359
+ }
360
+ return pattern.replace(/\\([|(){}+?])/g, '$1');
361
+ }
362
+
216
363
  // v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
217
364
  // ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead.
218
365
  // Let the raw grep through with an honest one-liner.
@@ -245,9 +392,11 @@ function isAnswerDisabled(env = process.env) {
245
392
 
246
393
  function runMain() {
247
394
  if (isSilenced()) return;
248
- const cwd = process.cwd();
249
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
250
- if (!fs.existsSync(dbPath)) return; // no index — no hint
395
+ // v0.48 process.cwd() follows the persistent shell; resolve the project
396
+ // root by walking up so `cd backend/` no longer darkens the whole session.
397
+ const shellCwd = process.cwd();
398
+ const root = resolveProjectRoot(shellCwd);
399
+ if (root === null) return; // no index anywhere up to $HOME — no hint
251
400
 
252
401
  let input;
253
402
  try {
@@ -258,28 +407,72 @@ function runMain() {
258
407
  } catch { return; }
259
408
 
260
409
  const rawCmd = (input.tool_input && input.tool_input.command) || '';
261
- // v0.47.1 — match against the cwd-stripped form so absolute paths under the
262
- // project root behave exactly like their relative spelling. Cooldown stays
263
- // keyed on the raw command (what Claude actually sent).
264
- const cmd = normalizeCommandPaths(rawCmd, cwd);
410
+
411
+ // v0.49 sed-range reads count toward the read-fanout state (the Read hook
412
+ // never sees Bash-side file reads). A fired fanout hint already delivered an
413
+ // overview skip grep hinting for this command to avoid double output.
414
+ const sedTargets = extractSedReadTargets(rawCmd);
415
+ if (sedTargets.length > 0) {
416
+ const readGuide = require('./pre-read-guide');
417
+ let fanoutFired = false;
418
+ for (const t of sedTargets) {
419
+ if (!readGuide.isSourceFile(t)) continue;
420
+ const abs = path.isAbsolute(t) ? t : path.resolve(shellCwd, t);
421
+ if (readGuide.trackReadAndMaybeHint(root, path.relative(root, abs))) {
422
+ fanoutFired = true;
423
+ }
424
+ }
425
+ if (fanoutFired) return;
426
+ }
427
+
428
+ // v0.47.1 — match against the root-stripped form so absolute paths under the
429
+ // project root behave exactly like their relative spelling. v0.48 — then
430
+ // rebase bare subdir-relative tokens onto the root. Cooldown stays keyed on
431
+ // the raw command (what Claude actually sent).
432
+ let cmd = normalizeCommandPaths(rawCmd, root);
433
+ const relPrefix = path.relative(root, shellCwd);
434
+ if (relPrefix) cmd = rebaseRelativePaths(cmd, relPrefix, root);
265
435
  if (!shouldHint(cmd)) return;
436
+
437
+ // v0.48 — deliberate escape: record it (funnel visibility) and stay silent.
438
+ // Before GREP_HEAD accepted bare KEY=VALUE prefixes these were invisible.
439
+ if (commandHasBypass(rawCmd)) {
440
+ recordRecommendation(root, { hook: 'grep', action: 'bypass' });
441
+ return;
442
+ }
443
+
266
444
  if (isOnCooldown(rawCmd)) return;
267
445
 
268
446
  markCooldown(rawCmd);
269
447
 
270
- if (!isBlockDisabled() && shouldBlock(cmd)) {
448
+ const block = isBlockDisabled() ? null : classifyBlock(cmd);
449
+ if (block) {
271
450
  // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
272
451
  // results in the deny reason ("answer in the deny"). Degrades to the
273
452
  // v0.46 static deny on any failure; downgrades to allow+FYI on 0 hits
274
453
  // (regex-dialect differences mean 0 hits ≠ proof of absence).
454
+ // v0.49 — intent-aware: declaration+context greps get `show` bodies,
455
+ // falling back to the grep answer, then the static deny.
275
456
  let answer = { status: 'unavailable' };
276
- const pattern = pickBlockPattern(cmd);
277
- if (!isAnswerDisabled() && pattern) {
278
- answer = runGrepAnswer({ cwd, pattern, searchPath: extractSearchPath(cmd) });
457
+ const pattern = translateBreToRg(cmd, pickBlockPattern(cmd));
458
+ // v0.48 glob-truncated once, shared by the run and the deny message
459
+ // (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
460
+ const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
461
+ let answeredMode = block.mode;
462
+ if (!isAnswerDisabled()) {
463
+ if (block.mode === 'show') {
464
+ answer = runShowAnswer({ cwd: root, symbols: block.symbols });
465
+ if (answer.status !== 'hits' && pattern) {
466
+ answeredMode = 'grep';
467
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath });
468
+ }
469
+ } else if (pattern) {
470
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath });
471
+ }
279
472
  }
280
473
 
281
474
  if (answer.status === 'no-hits') {
282
- recordRecommendation(cwd, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
475
+ recordRecommendation(root, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
283
476
  process.stdout.write(buildNoHitsFyi(pattern) + '\n');
284
477
  return;
285
478
  }
@@ -289,22 +482,27 @@ function runMain() {
289
482
  // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
290
483
  // is the documented modern path. Exit 0 — this is a routing decision, not
291
484
  // a hook failure (exit 2 would mark the tool call as "hook errored").
292
- recordRecommendation(cwd, {
293
- hook: 'grep', action: 'deny', answered: answer.status === 'hits',
485
+ const answered = answer.status === 'hits';
486
+ recordRecommendation(root, {
487
+ hook: 'grep', action: 'deny', answered,
488
+ // mode segments which answer type converts (show=bodies, grep=hits).
489
+ ...(answered ? { mode: answeredMode } : {}),
294
490
  });
295
491
  process.stdout.write(JSON.stringify({
296
492
  hookSpecificOutput: {
297
493
  hookEventName: 'PreToolUse',
298
494
  permissionDecision: 'deny',
299
- permissionDecisionReason: answer.status === 'hits'
300
- ? buildBlockReasonWithAnswer(pattern, extractSearchPath(cmd), answer)
301
- : buildBlockReason(),
495
+ permissionDecisionReason: !answered
496
+ ? buildBlockReason()
497
+ : answeredMode === 'show'
498
+ ? buildShowDenyReason(answer)
499
+ : buildBlockReasonWithAnswer(pattern, searchPath, answer),
302
500
  },
303
501
  }) + '\n');
304
502
  return;
305
503
  }
306
504
 
307
- recordRecommendation(cwd, { hook: 'grep', action: 'hint' });
505
+ recordRecommendation(root, { hook: 'grep', action: 'hint' });
308
506
  process.stdout.write(buildHint() + '\n');
309
507
  }
310
508
 
@@ -315,9 +513,17 @@ if (require.main === module) {
315
513
  module.exports = {
316
514
  shouldHint,
317
515
  shouldBlock,
516
+ classifyBlock, // v0.49 — intent-aware block tiers
517
+ extractDeclSymbols, // v0.49 — show-mode symbol extraction
518
+ translateBreToRg, // v0.49 — BRE→rust-regex dialect bridge
519
+ buildShowDenyReason, // v0.49 — show-mode deny copy
520
+ extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
318
521
  extractPatterns, // v0.32.1 — exposed for tests
319
522
  extractSearchPath, // v0.47.0 — deny-with-answer
320
523
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
524
+ resolveProjectRoot, // v0.48 — subdir-cwd dark fix
525
+ rebaseRelativePaths, // v0.48 — subdir-cwd dark fix
526
+ commandHasBypass, // v0.48 — bypass funnel visibility
321
527
  pickBlockPattern,
322
528
  buildHint,
323
529
  buildBlockReason,