@sdsrs/code-graph 0.48.0 → 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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.48.0",
7
+ "version": "0.49.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -45,8 +45,8 @@ const INDEX_LINE =
45
45
  '- [code-graph-mcp](plugin_code_graph_mcp.md) ' +
46
46
  '[impact-analysis, callgraph, find-references, module-overview, semantic-search, ast-search, dead-code, find-similar-code, dependency-graph, trace-http-chain] — ' +
47
47
  '改 X 影响面/谁调用 X/X 被谁用/看 X 源码/Y 模块长啥样/概念查询 优先于 Grep;字面匹配走 Grep。' +
48
- '核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map)' +
49
- '+ 进阶 5impact_analysis/trace_http_chain/dependency_graph/find_similar_code/find_dead_code),决策表见全文';
48
+ 'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
49
+ 'MCP 核心 7get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map),决策表见全文';
50
50
 
51
51
  // memdir L1 升格 (per sdscc 重构方案 §5.0): the INDEX_LINE that lands in
52
52
  // MEMORY.md is what Claude sees first on every keyword match. Tailoring it
@@ -234,9 +234,12 @@ function detectProjectType(cwd = process.cwd(), env = process.env) {
234
234
  // most for THIS project.
235
235
  function buildIndexLine(projectType = 'generic') {
236
236
  const prefix = '- [code-graph-mcp](plugin_code_graph_mcp.md) ';
237
+ // v0.49 — CLI form leads: in Claude Code the MCP tools are deferred (need a
238
+ // ToolSearch load before first call) while Bash is always live; the only
239
+ // conversions observed in real coding nights were CLI invocations.
237
240
  const coreSuffix =
238
- '核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map)' +
239
- '+ 进阶 5impact_analysis/trace_http_chain/dependency_graph/find_similar_code/find_dead_code),决策表见全文';
241
+ 'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
242
+ 'MCP 核心 7get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map),决策表见全文';
240
243
  switch (projectType) {
241
244
  case 'web-rs':
242
245
  case 'web-node':
@@ -110,6 +110,9 @@ function runGrepAnswer(opts = {}) {
110
110
  encoding: 'utf8',
111
111
  maxBuffer: 4 * 1024 * 1024,
112
112
  stdio: ['ignore', 'pipe', 'ignore'],
113
+ // Hook-internal run: a delivered answer, not a model-initiated conversion.
114
+ // The CLI skips its recommendations.jsonl `use` record when this is set.
115
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
113
116
  });
114
117
  if (res.error || res.signal || res.status !== 0) {
115
118
  return { status: 'unavailable' };
@@ -125,4 +128,95 @@ function runGrepAnswer(opts = {}) {
125
128
  }
126
129
  }
127
130
 
128
- module.exports = { runGrepAnswer, truncateAtLine, sanitizeSearchPath };
131
+ /**
132
+ * v0.49 — Run `code-graph-mcp show <symbol>` for up to 3 declaration symbols
133
+ * and concatenate the bodies. Powers the show-mode deny (declaration-anchor +
134
+ * context-flag greps: the model wants to READ the functions, so hand it the
135
+ * functions). Same bounded/best-effort posture as runGrepAnswer; symbols that
136
+ * fail to resolve are skipped, all-fail → no-hits (caller falls back to grep).
137
+ */
138
+ function runShowAnswer(opts = {}) {
139
+ const {
140
+ cwd,
141
+ symbols,
142
+ timeoutMs = DEFAULT_TIMEOUT_MS,
143
+ maxBytes = DEFAULT_MAX_BYTES,
144
+ } = opts;
145
+ try {
146
+ if (!Array.isArray(symbols) || symbols.length === 0) {
147
+ return { status: 'unavailable' };
148
+ }
149
+ let binary = opts.binary;
150
+ if (binary === undefined) {
151
+ binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
152
+ }
153
+ if (!binary) return { status: 'unavailable' };
154
+
155
+ const parts = [];
156
+ for (const sym of symbols.slice(0, 3)) {
157
+ if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
158
+ const res = spawnSync(binary, ['show', sym], {
159
+ cwd,
160
+ timeout: timeoutMs,
161
+ encoding: 'utf8',
162
+ maxBuffer: 4 * 1024 * 1024,
163
+ stdio: ['ignore', 'pipe', 'ignore'],
164
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
165
+ });
166
+ if (res.error || res.signal || res.status !== 0) continue;
167
+ const out = (res.stdout || '').trim();
168
+ if (!out || out.startsWith(NO_MATCH_PREFIX)) continue;
169
+ parts.push(`$ code-graph-mcp show ${sym}\n${out}`);
170
+ }
171
+ if (parts.length === 0) return { status: 'no-hits' };
172
+ const { text, truncated } = truncateAtLine(parts.join('\n\n'), maxBytes);
173
+ return { status: 'hits', text, truncated };
174
+ } catch {
175
+ return { status: 'unavailable' };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * v0.49 — Run `code-graph-mcp overview <dir>` for the read-fanout hint, so the
181
+ * hint DELIVERS the module map instead of advising a tool call (hints measured
182
+ * 0/40 transfer on 2026-06-12; delivered answers satisfied 5/5 in place).
183
+ */
184
+ function runOverviewAnswer(opts = {}) {
185
+ const {
186
+ cwd,
187
+ dir,
188
+ timeoutMs = DEFAULT_TIMEOUT_MS,
189
+ maxBytes = DEFAULT_MAX_BYTES,
190
+ } = opts;
191
+ try {
192
+ if (!dir || typeof dir !== 'string' || dir.length > 300) {
193
+ return { status: 'unavailable' };
194
+ }
195
+ let binary = opts.binary;
196
+ if (binary === undefined) {
197
+ binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
198
+ }
199
+ if (!binary) return { status: 'unavailable' };
200
+ const res = spawnSync(binary, ['overview', dir], {
201
+ cwd,
202
+ timeout: timeoutMs,
203
+ encoding: 'utf8',
204
+ maxBuffer: 4 * 1024 * 1024,
205
+ stdio: ['ignore', 'pipe', 'ignore'],
206
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
207
+ });
208
+ if (res.error || res.signal || res.status !== 0) {
209
+ return { status: 'unavailable' };
210
+ }
211
+ const out = (res.stdout || '').trim();
212
+ if (!out || out.startsWith(NO_MATCH_PREFIX)) return { status: 'no-hits' };
213
+ const { text, truncated } = truncateAtLine(out, maxBytes);
214
+ return { status: 'hits', text, truncated };
215
+ } catch {
216
+ return { status: 'unavailable' };
217
+ }
218
+ }
219
+
220
+ module.exports = {
221
+ runGrepAnswer, runShowAnswer, runOverviewAnswer, truncateAtLine, sanitizeSearchPath,
222
+ };
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
- const { runGrepAnswer, truncateAtLine } = require('./cg-answer');
7
+ const { runGrepAnswer, runShowAnswer, truncateAtLine } = require('./cg-answer');
8
8
 
9
9
  // Stub "binary": a node script that reacts to its first real arg so one stub
10
10
  // covers hits / no-hits / error / timeout cases.
@@ -58,6 +58,19 @@ test('runGrepAnswer: passes grep subcommand, pattern and path as argv', () => {
58
58
  assert.match(r.text, /args=\["grep","fts5_search","src\/storage\/"\]/);
59
59
  });
60
60
 
61
+ test('runGrepAnswer: child env carries CODE_GRAPH_INTERNAL=1 (not a funnel conversion)', () => {
62
+ // Stub variant that echoes the marker back in its output.
63
+ const envStub = path.join(stubDir, 'cg-env-stub.js');
64
+ fs.writeFileSync(envStub, `#!/usr/bin/env node
65
+ process.stdout.write('internal=' + (process.env.CODE_GRAPH_INTERNAL || '') + '\\n');
66
+ `);
67
+ fs.chmodSync(envStub, 0o755);
68
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'whatever', binary: envStub });
69
+ assert.equal(r.status, 'hits');
70
+ assert.match(r.text, /internal=1/,
71
+ 'hook-internal CLI runs must be marked so record_cli_use skips them');
72
+ });
73
+
61
74
  test('runGrepAnswer: omits path argv when no searchPath', () => {
62
75
  const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: stubBinary() });
63
76
  assert.match(r.text, /args=\["grep","fts5_search"\]/);
@@ -160,3 +173,34 @@ test('runGrepAnswer: glob searchPath is truncated before spawn (defensive layer)
160
173
  assert.equal(r.status, 'hits');
161
174
  assert.match(r.text, /args=\["grep","fts5_search","src\/storage"\]/);
162
175
  });
176
+
177
+ // ── runShowAnswer (v0.49) — show-mode deny bodies ────────────────────
178
+
179
+ test('runShowAnswer: concatenates per-symbol show output with $ headers', () => {
180
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['alpha_one', 'beta_two'], binary: stubBinary() });
181
+ assert.equal(r.status, 'hits');
182
+ assert.match(r.text, /\$ code-graph-mcp show alpha_one/);
183
+ assert.match(r.text, /\$ code-graph-mcp show beta_two/);
184
+ });
185
+
186
+ test('runShowAnswer: skips non-identifier symbols, all-skipped → unavailable-safe no-hits', () => {
187
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['$(rm -rf)', 'a|b'], binary: stubBinary() });
188
+ assert.equal(r.status, 'no-hits');
189
+ });
190
+
191
+ test('runShowAnswer: caps at 3 symbols', () => {
192
+ const r = runShowAnswer({
193
+ cwd: stubDir, symbols: ['s_one', 's_two', 's_three', 's_four'], binary: stubBinary(),
194
+ });
195
+ assert.equal(r.status, 'hits');
196
+ assert.doesNotMatch(r.text, /show s_four/);
197
+ });
198
+
199
+ test('runShowAnswer: empty symbol list → unavailable', () => {
200
+ assert.equal(runShowAnswer({ cwd: stubDir, symbols: [], binary: stubBinary() }).status, 'unavailable');
201
+ });
202
+
203
+ test('runShowAnswer: failing binary → no-hits (caller falls back to grep answer)', () => {
204
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['ExplodePlease'], binary: stubBinary() });
205
+ assert.equal(r.status, 'no-hits');
206
+ });
@@ -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,29 @@ 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
+
209
245
  // v0.47.0 — pull the first source-tree path token out of the denied command so
210
246
  // the inline answer can scope its search the same way the raw grep would have.
211
247
  function extractSearchPath(cmd) {
@@ -257,14 +293,16 @@ function buildHint() {
257
293
  function buildBlockReason() {
258
294
  // Shown to Claude via PreToolUse `decision: block` reason. Must give a
259
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.
260
300
  return [
261
301
  '[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
262
302
  'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
263
303
  ' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
264
304
  ' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
265
305
  ' 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
306
  ].join('\n');
269
307
  }
270
308
 
@@ -291,6 +329,37 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
291
329
  return lines.join('\n');
292
330
  }
293
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
+
294
363
  // v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
295
364
  // ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead.
296
365
  // Let the raw grep through with an honest one-liner.
@@ -338,6 +407,24 @@ function runMain() {
338
407
  } catch { return; }
339
408
 
340
409
  const rawCmd = (input.tool_input && input.tool_input.command) || '';
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
+
341
428
  // v0.47.1 — match against the root-stripped form so absolute paths under the
342
429
  // project root behave exactly like their relative spelling. v0.48 — then
343
430
  // rebase bare subdir-relative tokens onto the root. Cooldown stays keyed on
@@ -358,18 +445,30 @@ function runMain() {
358
445
 
359
446
  markCooldown(rawCmd);
360
447
 
361
- if (!isBlockDisabled() && shouldBlock(cmd)) {
448
+ const block = isBlockDisabled() ? null : classifyBlock(cmd);
449
+ if (block) {
362
450
  // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
363
451
  // results in the deny reason ("answer in the deny"). Degrades to the
364
452
  // v0.46 static deny on any failure; downgrades to allow+FYI on 0 hits
365
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.
366
456
  let answer = { status: 'unavailable' };
367
- const pattern = pickBlockPattern(cmd);
457
+ const pattern = translateBreToRg(cmd, pickBlockPattern(cmd));
368
458
  // v0.48 — glob-truncated once, shared by the run and the deny message
369
459
  // (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
370
460
  const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
371
- if (!isAnswerDisabled() && pattern) {
372
- answer = runGrepAnswer({ cwd: root, pattern, searchPath });
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
+ }
373
472
  }
374
473
 
375
474
  if (answer.status === 'no-hits') {
@@ -383,16 +482,21 @@ function runMain() {
383
482
  // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
384
483
  // is the documented modern path. Exit 0 — this is a routing decision, not
385
484
  // a hook failure (exit 2 would mark the tool call as "hook errored").
485
+ const answered = answer.status === 'hits';
386
486
  recordRecommendation(root, {
387
- hook: 'grep', action: 'deny', answered: answer.status === 'hits',
487
+ hook: 'grep', action: 'deny', answered,
488
+ // mode segments which answer type converts (show=bodies, grep=hits).
489
+ ...(answered ? { mode: answeredMode } : {}),
388
490
  });
389
491
  process.stdout.write(JSON.stringify({
390
492
  hookSpecificOutput: {
391
493
  hookEventName: 'PreToolUse',
392
494
  permissionDecision: 'deny',
393
- permissionDecisionReason: answer.status === 'hits'
394
- ? buildBlockReasonWithAnswer(pattern, searchPath, answer)
395
- : buildBlockReason(),
495
+ permissionDecisionReason: !answered
496
+ ? buildBlockReason()
497
+ : answeredMode === 'show'
498
+ ? buildShowDenyReason(answer)
499
+ : buildBlockReasonWithAnswer(pattern, searchPath, answer),
396
500
  },
397
501
  }) + '\n');
398
502
  return;
@@ -409,6 +513,11 @@ if (require.main === module) {
409
513
  module.exports = {
410
514
  shouldHint,
411
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
412
521
  extractPatterns, // v0.32.1 — exposed for tests
413
522
  extractSearchPath, // v0.47.0 — deny-with-answer
414
523
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
@@ -4,6 +4,11 @@ const assert = require('node:assert/strict');
4
4
  const {
5
5
  shouldHint,
6
6
  shouldBlock,
7
+ classifyBlock,
8
+ extractDeclSymbols,
9
+ translateBreToRg,
10
+ buildShowDenyReason,
11
+ extractSedReadTargets,
7
12
  extractPatterns,
8
13
  extractSearchPath,
9
14
  normalizeCommandPaths,
@@ -317,30 +322,81 @@ test('shouldBlock: rg with CamelCase on lib/', () => {
317
322
 
318
323
  // ── shouldBlock: should NOT block (downgrade to hint) — precision flags ─
319
324
 
320
- test('shouldBlock: grep -l (files-with-matches) → hint only', () => {
321
- assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), false);
325
+ test('shouldBlock: grep -l (files-with-matches) → deny, grep answer covers file lists (v0.49)', () => {
326
+ assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), true);
327
+ assert.deepEqual(classifyBlock('grep -rl "EmbeddingModel" src/'), { mode: 'grep' });
322
328
  });
323
329
 
324
- test('shouldBlock: --include=*.rs → user already filtering, hint only', () => {
325
- assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), false);
330
+ test('shouldBlock: --include=*.rs → deny, path-scoped grep answer covers it (v0.49)', () => {
331
+ assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
326
332
  });
327
333
 
328
- test('shouldBlock: --exclude-dir=tests → hint only', () => {
334
+ test('shouldBlock: --exclude=tests → hint only (answer cannot honor exclusion)', () => {
329
335
  assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
330
336
  });
331
337
 
332
- test('shouldBlock: -A 3 context flag → hint only', () => {
338
+ test('shouldBlock: -L / -v inverted intents → hint only', () => {
339
+ assert.equal(shouldBlock('grep -rL "EmbeddingModel" src/'), false);
340
+ assert.equal(shouldBlock('grep -rnv "EmbeddingModel" src/'), false);
341
+ });
342
+
343
+ test('shouldBlock: -A 3 with bare identifier → hint only (cannot honor ±N lines)', () => {
333
344
  assert.equal(shouldBlock('grep -rn -A 3 "EmbeddingModel" src/'), false);
334
345
  });
335
346
 
336
- test('shouldBlock: -B 2 context flag → hint only', () => {
347
+ test('shouldBlock: -B 2 with bare identifier → hint only', () => {
337
348
  assert.equal(shouldBlock('grep -rn -B 2 "EmbeddingModel" src/'), false);
338
349
  });
339
350
 
340
- test('shouldBlock: -C 5 context flag → hint only', () => {
351
+ test('shouldBlock: -C 5 with bare identifier → hint only', () => {
341
352
  assert.equal(shouldBlock('grep -rn -C 5 "EmbeddingModel" src/'), false);
342
353
  });
343
354
 
355
+ // ── translateBreToRg (v0.49) — BRE→rust-regex dialect bridge ─────────
356
+
357
+ test('translateBreToRg: plain grep BRE alternation unescaped', () => {
358
+ assert.equal(
359
+ translateBreToRg('grep -rn "UnifiedPickerEngine\\|engine.run" src/', 'UnifiedPickerEngine\\|engine.run'),
360
+ 'UnifiedPickerEngine|engine.run');
361
+ });
362
+
363
+ test('translateBreToRg: rg patterns untouched (already extended)', () => {
364
+ assert.equal(translateBreToRg('rg "a\\|b" src/', 'a\\|b'), 'a\\|b');
365
+ });
366
+
367
+ test('translateBreToRg: grep -E untouched', () => {
368
+ assert.equal(translateBreToRg('grep -rnE "a\\|b" src/', 'a\\|b'), 'a\\|b');
369
+ });
370
+
371
+ test('translateBreToRg: unescapes groups/braces/quantifiers for plain grep', () => {
372
+ assert.equal(translateBreToRg('grep "fn \\(x\\)\\+" src/', 'fn \\(x\\)\\+'), 'fn (x)+');
373
+ });
374
+
375
+ // ── classifyBlock: show mode (v0.49) — the daagu 22/128 function-body reads ──
376
+
377
+ test('classifyBlock: declaration anchor + -A → show mode with symbols', () => {
378
+ assert.deepEqual(
379
+ classifyBlock('rg -n "def cascade_failure|def reset_task" -A 25 backend/app/'),
380
+ { mode: 'show', symbols: ['cascade_failure', 'reset_task'] });
381
+ });
382
+
383
+ test('classifyBlock: multi-decl alternation caps at 3 symbols', () => {
384
+ const c = classifyBlock('rg -n "def a_one|def b_two|class CThree|fn d_four" -A 10 src/');
385
+ assert.equal(c.mode, 'show');
386
+ assert.deepEqual(c.symbols, ['a_one', 'b_two', 'CThree']);
387
+ });
388
+
389
+ test('classifyBlock: declaration anchor WITHOUT context flag → plain grep deny', () => {
390
+ assert.deepEqual(classifyBlock('grep -rn "def fetch_user" backend/app/services/'),
391
+ { mode: 'grep' });
392
+ });
393
+
394
+ test('extractDeclSymbols: dedupes and spans fn/def/class/struct anchors', () => {
395
+ assert.deepEqual(
396
+ extractDeclSymbols(['fn alpha_one', 'struct BetaTwo', 'fn alpha_one']),
397
+ ['alpha_one', 'BetaTwo']);
398
+ });
399
+
344
400
  // ── shouldBlock: should NOT block — marker-only patterns ────────────
345
401
 
346
402
  test('shouldBlock: bare TODO marker → hint only (no cg equivalent)', () => {
@@ -402,8 +458,8 @@ test('buildBlockReason: lists cg grep + ast-search + callgraph', () => {
402
458
  assert.match(out, /code-graph-mcp callgraph/);
403
459
  });
404
460
 
405
- test('buildBlockReason: documents the escape hatch env var', () => {
406
- assert.match(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP=1/);
461
+ test('buildBlockReason: NEVER documents the escape hatch (v0.49 — the "THIS command only" scoping was adopted as a permanent prefix in 8s on 2026-06-12)', () => {
462
+ assert.doesNotMatch(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP/);
407
463
  });
408
464
 
409
465
  test('buildBlockReason: under 700-byte budget (single CC message)', () => {
@@ -583,11 +639,11 @@ test('replay: real abs-path symbol grep → BLOCK after normalization', () => {
583
639
  assert.equal(shouldBlock(norm), true);
584
640
  });
585
641
 
586
- test('replay: real abs-path -rln grep → HINT only after normalization (precision flag)', () => {
642
+ test('replay: real abs-path -rln grep → DENY after normalization (v0.49: file lists answerable)', () => {
587
643
  const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`;
588
644
  const norm = normalizeCommandPaths(cmd, DAAGU);
589
645
  assert.equal(shouldHint(norm), true);
590
- assert.equal(shouldBlock(norm), false); // -l cluster disqualifies block
646
+ assert.deepEqual(classifyBlock(norm), { mode: 'grep' }); // grep answer lists files per hit
591
647
  });
592
648
 
593
649
  test('replay: abs-path config-only grep stays silent after normalization', () => {
@@ -671,13 +727,6 @@ test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one den
671
727
  assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
672
728
  });
673
729
 
674
- test('buildBlockReason: scopes the escape to THIS command only', () => {
675
- const reason = buildBlockReason();
676
- assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
677
- assert.match(reason, /THIS command only/);
678
- assert.match(reason, /not a default/);
679
- });
680
-
681
730
  test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
682
731
  const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
683
732
  status: 'hits', text: 'hit', truncated: false,
@@ -1040,3 +1089,30 @@ test('rebaseRelativePaths: glob token rebases when its glob-truncated dir exists
1040
1089
  assert.equal(
1041
1090
  sanitizeSearchPath(extractSearchPath(rebased)), 'backend/app/services/llm_engine');
1042
1091
  });
1092
+
1093
+ // ── extractSedReadTargets (v0.49) — sed-range reads feed read-fanout ──
1094
+
1095
+ test('extractSedReadTargets: plain and quoted ranges, abs and rel paths', () => {
1096
+ assert.deepEqual(
1097
+ extractSedReadTargets('sed -n 620,700p /abs/proj/backend/app/services/market.py'),
1098
+ ['/abs/proj/backend/app/services/market.py']);
1099
+ assert.deepEqual(
1100
+ extractSedReadTargets("sed -n '230,310p' backend/app/services/tushare.py"),
1101
+ ['backend/app/services/tushare.py']);
1102
+ });
1103
+
1104
+ test('extractSedReadTargets: multiple segments in one command, deduped', () => {
1105
+ const cmd = 'sed -n 60,200p src/a.py; echo ===; sed -n 250,300p src/b.py && sed -n 250,300p src/b.py';
1106
+ assert.deepEqual(extractSedReadTargets(cmd), ['src/a.py', 'src/b.py']);
1107
+ });
1108
+
1109
+ test('extractSedReadTargets: non-range sed (substitution) ignored', () => {
1110
+ assert.deepEqual(extractSedReadTargets("sed -i 's/a/b/' src/a.py"), []);
1111
+ assert.deepEqual(extractSedReadTargets('sed -n /pattern/p src/a.py'), []);
1112
+ });
1113
+
1114
+ test('extractSedReadTargets: pipeline sed after grep still extracted', () => {
1115
+ assert.deepEqual(
1116
+ extractSedReadTargets('grep -n "x" src/a.py | sed -n 1,5p src/b.py'),
1117
+ ['src/b.py']);
1118
+ });
@@ -27,6 +27,8 @@ const path = require('path');
27
27
  const crypto = require('crypto');
28
28
  const { cgTmpDir } = require('./tmp-dir');
29
29
  const { recordRecommendation } = require('./recommendation-log');
30
+ const { resolveProjectRoot } = require('./project-root');
31
+ const { runOverviewAnswer } = require('./cg-answer');
30
32
 
31
33
  // --- Configuration ---
32
34
 
@@ -119,50 +121,87 @@ function buildHint(dir) {
119
121
  return `[code-graph] 5+ Reads into ${dir}/ — \`code-graph-mcp overview ${dir}/\` gives symbols+callers in one call (MCP: \`module_overview path=${dir}\`). Skip if you need raw file contents.`;
120
122
  }
121
123
 
124
+ // v0.49 — the hint DELIVERS the overview instead of advising a tool call
125
+ // (advice measured 0/40 transfer on 2026-06-12; delivered answers satisfied
126
+ // 5/5 in place). Falls back to the advice-only line when the CLI is
127
+ // unavailable or the dir has no overview.
128
+ function buildHintWithAnswer(dir, answer) {
129
+ const lines = [
130
+ `[code-graph] 5+ Reads into ${dir}/ — module overview from the AST index (saves the remaining file-by-file reads):`,
131
+ answer.text,
132
+ ];
133
+ if (answer.truncated) {
134
+ lines.push(`(truncated — \`code-graph-mcp overview ${dir}/\` for the full map)`);
135
+ }
136
+ return lines.join('\n');
137
+ }
138
+
122
139
  function isSilenced(env = process.env) {
123
140
  return env.CODE_GRAPH_QUIET_HOOKS === '1';
124
141
  }
125
142
 
143
+ // v0.49 — answer tier opt-out, shared name with the grep hook's deny-answer
144
+ // opt-out: =1 restores advice-only hints (no CLI run inside the hook).
145
+ function isAnswerDisabled(env = process.env) {
146
+ return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1';
147
+ }
148
+
149
+ // --- Shared tracking core (also driven by pre-grep-guide's sed-range path) ---
150
+
151
+ /// Record one read of `rel` (project-root-relative source path) and fire the
152
+ /// fanout hint when the threshold crosses. Emits to stdout + records the
153
+ /// recommendation. Returns true when a hint fired.
154
+ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
155
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
156
+ const dir = path.dirname(rel);
157
+ if (!dir || dir === '.' || dir === '') return false; // top-level file: not fanout
158
+
159
+ const state = loadState(root, now);
160
+ recordRead(state, dir, now);
161
+ let fired = false;
162
+ if (shouldHint(state, dir, now)) {
163
+ markHint(state, dir, now);
164
+ fired = true;
165
+ }
166
+ saveState(root, state);
167
+ if (!fired) return false;
168
+
169
+ let answer = { status: 'unavailable' };
170
+ if (!isAnswerDisabled()) {
171
+ answer = runOverviewAnswer({ cwd: root, dir });
172
+ }
173
+ const answered = answer.status === 'hits';
174
+ recordRecommendation(root, { hook: 'read', action: 'hint', answered });
175
+ process.stdout.write((answered ? buildHintWithAnswer(dir, answer) : buildHint(dir)) + '\n');
176
+ return true;
177
+ }
178
+
126
179
  // --- Main execution ---
127
180
 
128
181
  function runMain() {
129
182
  if (isSilenced()) return;
130
- const cwd = process.cwd();
131
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
132
- if (!fs.existsSync(dbPath)) return;
183
+ // v0.49 walk up from the shell cwd (subdir-cwd fix; the read hook had
184
+ // recorded NOTHING in daagu history because sessions sat in backend/).
185
+ const root = resolveProjectRoot(process.cwd());
186
+ if (root === null) return;
133
187
 
134
188
  let input;
135
189
  try {
136
- input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
190
+ // fd 0, not '/dev/stdin': the path form fails ENXIO on socketpair stdin.
191
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
137
192
  } catch { return; }
138
193
 
139
194
  const filePath = (input.tool_input && input.tool_input.file_path) || '';
140
195
  if (!isSourceFile(filePath)) return;
141
196
 
142
- // Normalize to a cwd-relative path. If the file is outside cwd, skip —
143
- // a hint pointing at an unrelated dir helps no one.
197
+ // Normalize to a root-relative path. Read sends absolute paths; files
198
+ // outside the project (other repos, ~/.claude/) stay silent.
144
199
  let rel;
145
200
  try {
146
- rel = path.relative(cwd, filePath);
201
+ rel = path.relative(root, filePath);
147
202
  } catch { return; }
148
- if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
149
-
150
- const dir = path.dirname(rel);
151
- if (!dir || dir === '.' || dir === '') return; // top-level file: not fanout
152
203
 
153
- const now = Date.now();
154
- const state = loadState(cwd, now);
155
- recordRead(state, dir, now);
156
- let fired = false;
157
- if (shouldHint(state, dir, now)) {
158
- markHint(state, dir, now);
159
- fired = true;
160
- }
161
- saveState(cwd, state);
162
- if (fired) {
163
- recordRecommendation(cwd, { hook: 'read', action: 'hint' });
164
- process.stdout.write(buildHint(dir) + '\n');
165
- }
204
+ trackReadAndMaybeHint(root, rel);
166
205
  }
167
206
 
168
207
  if (require.main === module) {
@@ -172,6 +211,7 @@ if (require.main === module) {
172
211
  module.exports = {
173
212
  isSourceFile, dirOf, cwdHash, statePath,
174
213
  loadState, saveState, recordRead, shouldHint, markHint,
175
- buildHint, isSilenced,
214
+ buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
215
+ trackReadAndMaybeHint, // v0.49 — shared with pre-grep-guide's sed-range path
176
216
  FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
177
217
  };
@@ -8,7 +8,8 @@ const crypto = require('crypto');
8
8
 
9
9
  const {
10
10
  isSourceFile, dirOf, recordRead, shouldHint, markHint,
11
- buildHint, isSilenced,
11
+ buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
12
+ trackReadAndMaybeHint,
12
13
  FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS,
13
14
  loadState, saveState, statePath,
14
15
  } = require('./pre-read-guide');
@@ -243,3 +244,60 @@ test('flow: 5 reads to same dir → hint, 6th read same dir → no hint (cooldow
243
244
  recordRead(s, 'src/foo', 1005);
244
245
  assert.equal(shouldHint(s, 'src/foo', 1005), false);
245
246
  });
247
+
248
+ // ── v0.49: answer-in-hint + shared tracking core ─────────────────────
249
+
250
+ test('buildHintWithAnswer: embeds overview text and truncation pointer', () => {
251
+ const out = buildHintWithAnswer('src/storage', { text: 'Module src/storage\n conn (57 callers)', truncated: true });
252
+ assert.match(out, /^\[code-graph\] 5\+ Reads into src\/storage\//);
253
+ assert.match(out, /conn \(57 callers\)/);
254
+ assert.match(out, /truncated — `code-graph-mcp overview src\/storage\/`/);
255
+ });
256
+
257
+ test('isAnswerDisabled: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → advice-only hints', () => {
258
+ assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '1' }), true);
259
+ assert.equal(isAnswerDisabled({}), false);
260
+ });
261
+
262
+ test('trackReadAndMaybeHint: fires on 5th read with stubbed overview answer', () => {
263
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-track-'));
264
+ // Stub CLI: prints a fake overview (hook resolves binary via _CG_ANSWER_BINARY).
265
+ const stub = path.join(root, 'stub.js');
266
+ fs.writeFileSync(stub, '#!/usr/bin/env node\nprocess.stdout.write("Module overview stub: 3 symbols\\n");');
267
+ fs.chmodSync(stub, 0o755);
268
+ const oldEnv = process.env._CG_ANSWER_BINARY;
269
+ process.env._CG_ANSWER_BINARY = stub;
270
+ // .code-graph present so recordRecommendation appends.
271
+ fs.mkdirSync(path.join(root, '.code-graph'), { recursive: true });
272
+
273
+ const written = [];
274
+ const origWrite = process.stdout.write.bind(process.stdout);
275
+ process.stdout.write = (chunk) => { written.push(String(chunk)); return true; };
276
+ try {
277
+ let fired = false;
278
+ for (let i = 0; i < 5; i++) {
279
+ fired = trackReadAndMaybeHint(root, 'src/storage/file' + i + '.rs');
280
+ }
281
+ assert.equal(fired, true, '5th same-dir read must fire');
282
+ assert.match(written.join(''), /Module overview stub/, 'hint must EMBED the overview answer');
283
+ const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
284
+ assert.match(recs, /"hook":"read"/);
285
+ assert.match(recs, /"answered":true/);
286
+ } finally {
287
+ process.stdout.write = origWrite;
288
+ if (oldEnv === undefined) delete process.env._CG_ANSWER_BINARY;
289
+ else process.env._CG_ANSWER_BINARY = oldEnv;
290
+ fs.rmSync(root, { recursive: true, force: true });
291
+ }
292
+ });
293
+
294
+ test('trackReadAndMaybeHint: top-level and outside-root paths never fire', () => {
295
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-skip-'));
296
+ try {
297
+ assert.equal(trackReadAndMaybeHint(root, 'main.rs'), false);
298
+ assert.equal(trackReadAndMaybeHint(root, '../other/file.rs'), false);
299
+ assert.equal(trackReadAndMaybeHint(root, '/abs/file.rs'), false);
300
+ } finally {
301
+ fs.rmSync(root, { recursive: true, force: true });
302
+ }
303
+ });
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ // Shared project-root resolution for the PreToolUse hooks (v0.49 — extracted
3
+ // from pre-grep-guide so pre-read-guide gets the same subdir-cwd fix without a
4
+ // circular require).
5
+ //
6
+ // The hook's process.cwd() follows the PERSISTENT shell, not the project root:
7
+ // after the model runs `cd backend/`, every per-cwd gate (index.db existence,
8
+ // relative-path matching) fails silently for the rest of the session (daagu
9
+ // 2026-06-11: 38/40 head-greps dark; the read hook never recorded AT ALL).
10
+ // Walk up to the nearest ancestor holding `.code-graph/index.db`; stop at
11
+ // $HOME (checked, not crossed) and fs root.
12
+
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ function resolveProjectRoot(startDir, opts = {}) {
18
+ const home = opts.home !== undefined ? opts.home : os.homedir();
19
+ const exists = opts.exists || fs.existsSync;
20
+ let dir = path.resolve(startDir || '.');
21
+ for (;;) {
22
+ if (exists(path.join(dir, '.code-graph', 'index.db'))) return dir;
23
+ if (dir === home) return null;
24
+ const parent = path.dirname(dir);
25
+ if (parent === dir) return null;
26
+ dir = parent;
27
+ }
28
+ }
29
+
30
+ module.exports = { resolveProjectRoot };
@@ -38,6 +38,11 @@ type: reference
38
38
 
39
39
  ## 何时调用 MCP/CLI(替代多步 Grep/Read)
40
40
 
41
+ > **v0.49 起 CLI 优先**:Claude Code 里 MCP 工具是 deferred(首次调用前要
42
+ > ToolSearch 加载),而 Bash 永远在线——真实编程夜(2026-06-12)观测到的全部
43
+ > 转化都是 CLI 调用。结构化查询的最快路径是 Bash 直呼
44
+ > `code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`。
45
+ >
41
46
  > v0.10.0 起:tools/list 默认只暴露 7 个核心工具;下表"进阶 5"中的工具
42
47
  > 已从 tools/list 隐藏以节省 session 启动 tokens。**Claude Code 里请走 CLI
43
48
  > 子命令**(MCP schema 不在 list,Claude Code 的 ToolSearch 不会加载,直接
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.48.0",
3
+ "version": "0.49.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.48.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.48.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.48.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.48.0",
42
- "@sdsrs/code-graph-win32-x64": "0.48.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.49.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.49.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.49.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.49.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.49.0"
43
43
  }
44
44
  }