@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +7 -4
- package/claude-plugin/scripts/cg-answer.js +117 -2
- package/claude-plugin/scripts/cg-answer.test.js +72 -1
- package/claude-plugin/scripts/pre-edit-guide.js +21 -5
- package/claude-plugin/scripts/pre-grep-guide.js +255 -49
- package/claude-plugin/scripts/pre-grep-guide.test.js +295 -16
- package/claude-plugin/scripts/pre-read-guide.js +65 -25
- package/claude-plugin/scripts/pre-read-guide.test.js +59 -1
- package/claude-plugin/scripts/project-root.js +30 -0
- package/claude-plugin/templates/plugin_code_graph_mcp.md +5 -0
- package/package.json +6 -6
|
@@ -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 (
|
|
19
|
-
// 7.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// 9.
|
|
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
|
-
|
|
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.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
117
|
-
|
|
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.
|
|
198
|
-
//
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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(
|
|
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
|
-
|
|
293
|
-
|
|
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:
|
|
300
|
-
?
|
|
301
|
-
:
|
|
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(
|
|
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,
|