@sdsrs/code-graph 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +7 -4
- package/claude-plugin/scripts/auto-update.js +49 -7
- package/claude-plugin/scripts/auto-update.test.js +93 -1
- package/claude-plugin/scripts/cg-answer.js +105 -2
- package/claude-plugin/scripts/cg-answer.test.js +55 -2
- package/claude-plugin/scripts/doctor.js +24 -45
- package/claude-plugin/scripts/doctor.test.js +13 -0
- package/claude-plugin/scripts/lifecycle.js +88 -0
- package/claude-plugin/scripts/lifecycle.test.js +40 -1
- package/claude-plugin/scripts/pre-edit-guide.js +21 -5
- package/claude-plugin/scripts/pre-grep-guide.js +219 -61
- package/claude-plugin/scripts/pre-grep-guide.test.js +237 -19
- 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/scripts/session-init.js +38 -14
- package/claude-plugin/templates/plugin_code_graph_mcp.md +8 -0
- package/package.json +6 -6
|
@@ -75,6 +75,48 @@ function hasInstalledPluginRecord() {
|
|
|
75
75
|
return !!(installed && installed.plugins && Array.isArray(installed.plugins[PLUGIN_ID]) && installed.plugins[PLUGIN_ID].length > 0);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** The installPath Claude Code currently considers active (installed_plugins.json), or null. */
|
|
79
|
+
function activeInstallPath() {
|
|
80
|
+
const installed = readJson(installedPluginsPath());
|
|
81
|
+
const recs = installed && installed.plugins && installed.plugins[PLUGIN_ID];
|
|
82
|
+
if (!Array.isArray(recs) || !recs[0] || typeof recs[0].installPath !== 'string') return null;
|
|
83
|
+
return recs[0].installPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stale-relic detection: is THIS script running from an old plugin-cache
|
|
88
|
+
* version dir while installed_plugins.json points at a different (active)
|
|
89
|
+
* install that exists on disk?
|
|
90
|
+
*
|
|
91
|
+
* Why: a still-running Claude Code process keeps firing SessionStart from the
|
|
92
|
+
* install path it loaded at startup. After auto-update installs vN+1 and
|
|
93
|
+
* re-anchors manifest + settings.json, the next SessionStart in that old
|
|
94
|
+
* process runs the vN scripts, whose syncLifecycleConfig sees
|
|
95
|
+
* `manifest.version !== currentVersion` and — direction-blind — calls
|
|
96
|
+
* update(), dragging manifest and every settings.json hook path back to the
|
|
97
|
+
* vN dir. The two versions then ping-pong (observed live 2026-06-12: manifest
|
|
98
|
+
* 0.49.0 → rewritten 0.48.0 fifteen minutes after a successful update).
|
|
99
|
+
*
|
|
100
|
+
* The authority is installed_plugins.json, not version direction: a deliberate
|
|
101
|
+
* downgrade via /plugin lands installPath == the old dir, so the old scripts
|
|
102
|
+
* keep full self-heal rights. Dev checkouts and npm installs are exempt
|
|
103
|
+
* (pluginRoot not under the plugins cache).
|
|
104
|
+
*/
|
|
105
|
+
function isStaleRelicContext({
|
|
106
|
+
pluginRoot = PLUGIN_ROOT,
|
|
107
|
+
cacheRoot = pluginsCacheDir(),
|
|
108
|
+
activePath = activeInstallPath(),
|
|
109
|
+
existsSync = fs.existsSync,
|
|
110
|
+
} = {}) {
|
|
111
|
+
if (!activePath) return false;
|
|
112
|
+
const root = path.resolve(pluginRoot);
|
|
113
|
+
const cache = path.resolve(cacheRoot);
|
|
114
|
+
if (root !== cache && !root.startsWith(cache + path.sep)) return false;
|
|
115
|
+
const active = path.resolve(activePath);
|
|
116
|
+
if (active === root) return false;
|
|
117
|
+
return existsSync(path.join(active, 'scripts', 'lifecycle.js'));
|
|
118
|
+
}
|
|
119
|
+
|
|
78
120
|
function isOurComposite(settings) {
|
|
79
121
|
return settings.statusLine &&
|
|
80
122
|
settings.statusLine.command &&
|
|
@@ -377,6 +419,50 @@ function registerHooksToSettings(settings) {
|
|
|
377
419
|
return before !== JSON.stringify(settings.hooks);
|
|
378
420
|
}
|
|
379
421
|
|
|
422
|
+
// Inventory of (event, matcher) tuples we expect to find in settings.json after
|
|
423
|
+
// install. Consumed by doctor (report + fix) and session-init (self-heal):
|
|
424
|
+
// `missing` = entry absent; `stale` = present but the registered command no
|
|
425
|
+
// longer matches what we'd write now (points at an old plugin-cache version
|
|
426
|
+
// dir / moved path). A stale path can run pre-recordRecommendation hook code,
|
|
427
|
+
// so the hook fires but the conversion metric stays dark — invisible to a
|
|
428
|
+
// present/absent check. This is the 0.45.1-registered-while-0.45.4-active
|
|
429
|
+
// case the RCA surfaced.
|
|
430
|
+
function surveyHookCoverage(settings) {
|
|
431
|
+
const desired = buildSettingsHookEntries();
|
|
432
|
+
const expected = [];
|
|
433
|
+
const desiredCmd = {}; // key -> command string we would write now
|
|
434
|
+
for (const [event, entries] of Object.entries(desired)) {
|
|
435
|
+
for (const e of entries) {
|
|
436
|
+
const key = `${event}:${e.matcher || '*'}`;
|
|
437
|
+
expected.push(key);
|
|
438
|
+
desiredCmd[key] = e.hooks && e.hooks[0] && e.hooks[0].command;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const present = new Set();
|
|
443
|
+
const presentCmd = {}; // key -> command currently registered
|
|
444
|
+
if (settings && settings.hooks) {
|
|
445
|
+
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
446
|
+
if (!Array.isArray(entries)) continue;
|
|
447
|
+
for (const entry of entries) {
|
|
448
|
+
if (isOurHookEntry(entry)) {
|
|
449
|
+
const key = `${event}:${entry.matcher || '*'}`;
|
|
450
|
+
present.add(key);
|
|
451
|
+
if (entry.hooks && entry.hooks[0] && entry.hooks[0].command) {
|
|
452
|
+
presentCmd[key] = entry.hooks[0].command;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const missing = expected.filter(k => !present.has(k));
|
|
460
|
+
const stale = expected.filter(k =>
|
|
461
|
+
present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
|
|
462
|
+
);
|
|
463
|
+
return { expected, present: [...present], missing, stale };
|
|
464
|
+
}
|
|
465
|
+
|
|
380
466
|
// --- Install (idempotent) ---
|
|
381
467
|
|
|
382
468
|
function install() {
|
|
@@ -673,6 +759,8 @@ module.exports = {
|
|
|
673
759
|
getPluginVersion, cleanupOldCacheVersions,
|
|
674
760
|
removeHooksFromSettings, isOurHookEntry,
|
|
675
761
|
registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
|
|
762
|
+
surveyHookCoverage, compositeCommand, // v0.49.1 — version-aware self-heal
|
|
763
|
+
activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
|
|
676
764
|
SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
|
|
677
765
|
PLUGIN_ROOT, // v0.32.1 — for tests / consumers
|
|
678
766
|
registerStatuslineProvider, unregisterStatuslineProvider,
|
|
@@ -660,4 +660,43 @@ test('scanForBrokenPaths is exported and returns the issue structure', (t) => {
|
|
|
660
660
|
assert.ok(Array.isArray(issues));
|
|
661
661
|
assert.ok(issues.some(i => i.type === 'hook' && i.event === 'PreToolUse' && i.path.includes('/nonexistent/')),
|
|
662
662
|
'scanForBrokenPaths must report the seeded broken hook entry');
|
|
663
|
-
});
|
|
663
|
+
});
|
|
664
|
+
// ── isStaleRelicContext (v0.49.1 downgrade-war guard) ──────────────────────
|
|
665
|
+
|
|
666
|
+
test('isStaleRelicContext: relic in plugins cache defers to a different active install', (t) => {
|
|
667
|
+
const { isStaleRelicContext } = require('./lifecycle');
|
|
668
|
+
const cacheRoot = '/home/u/.claude/plugins/cache';
|
|
669
|
+
const relicRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.48.0`;
|
|
670
|
+
const activeRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.49.0`;
|
|
671
|
+
|
|
672
|
+
// The downgrade-war case: running from old cache dir, active points elsewhere.
|
|
673
|
+
assert.equal(isStaleRelicContext({
|
|
674
|
+
pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
|
|
675
|
+
existsSync: () => true,
|
|
676
|
+
}), true);
|
|
677
|
+
|
|
678
|
+
// Running FROM the active install → full self-heal rights.
|
|
679
|
+
assert.equal(isStaleRelicContext({
|
|
680
|
+
pluginRoot: activeRoot, cacheRoot, activePath: activeRoot,
|
|
681
|
+
existsSync: () => true,
|
|
682
|
+
}), false);
|
|
683
|
+
|
|
684
|
+
// Dev checkout / npm install (pluginRoot outside the plugins cache) → exempt.
|
|
685
|
+
assert.equal(isStaleRelicContext({
|
|
686
|
+
pluginRoot: '/repo/code-graph-mcp/claude-plugin', cacheRoot, activePath: activeRoot,
|
|
687
|
+
existsSync: () => true,
|
|
688
|
+
}), false);
|
|
689
|
+
|
|
690
|
+
// No installed_plugins record → exempt (nothing authoritative to defer to).
|
|
691
|
+
assert.equal(isStaleRelicContext({
|
|
692
|
+
pluginRoot: relicRoot, cacheRoot, activePath: null,
|
|
693
|
+
existsSync: () => true,
|
|
694
|
+
}), false);
|
|
695
|
+
|
|
696
|
+
// Active path recorded but its lifecycle.js is gone (cache wiped) → the
|
|
697
|
+
// relic is the only working copy left; keep self-heal rights.
|
|
698
|
+
assert.equal(isStaleRelicContext({
|
|
699
|
+
pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
|
|
700
|
+
existsSync: () => false,
|
|
701
|
+
}), false);
|
|
702
|
+
});
|
|
@@ -11,10 +11,14 @@ const fs = require('fs');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { findBinary } = require('./find-binary');
|
|
13
13
|
const { cgTmpDir } = require('./tmp-dir');
|
|
14
|
+
const { resolveProjectRoot } = require('./project-root');
|
|
15
|
+
const { recordRecommendation } = require('./recommendation-log');
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
22
|
-
// 7.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// 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
|
|
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.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
129
|
-
|
|
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 —
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
// 2026-06-11: 38/40 head-greps dark). Walk up to the nearest ancestor holding
|
|
150
|
-
// `.code-graph/index.db`; stop at $HOME (checked, not crossed) and fs root.
|
|
151
|
-
function resolveProjectRoot(startDir, opts = {}) {
|
|
152
|
-
const home = opts.home !== undefined ? opts.home : os.homedir();
|
|
153
|
-
const exists = opts.exists || fs.existsSync;
|
|
154
|
-
let dir = path.resolve(startDir || '.');
|
|
155
|
-
for (;;) {
|
|
156
|
-
if (exists(path.join(dir, '.code-graph', 'index.db'))) return dir;
|
|
157
|
-
if (dir === home) return null;
|
|
158
|
-
const parent = path.dirname(dir);
|
|
159
|
-
if (parent === dir) return null;
|
|
160
|
-
dir = parent;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
180
|
+
// v0.48 — subdir-cwd fix; v0.49 — extracted to project-root.js so the read
|
|
181
|
+
// hook shares it. Re-exported below for test/back-compat.
|
|
182
|
+
const { resolveProjectRoot } = require('./project-root');
|
|
163
183
|
|
|
164
184
|
// v0.48 — companion to resolveProjectRoot: when the shell sits in a subdir,
|
|
165
185
|
// bare relative path args (`app --include=*.py` from backend/) are
|
|
@@ -199,13 +219,67 @@ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
|
|
|
199
219
|
}).join('');
|
|
200
220
|
}
|
|
201
221
|
|
|
202
|
-
// v0.48 — bypass detection on the RAW command. The deny
|
|
203
|
-
//
|
|
204
|
-
//
|
|
222
|
+
// v0.48 — bypass detection on the RAW command. (The deny copy stopped teaching
|
|
223
|
+
// the escape in v0.49, but models that already know it — or learned it from a
|
|
224
|
+
// session summary — must stay visible to the funnel.)
|
|
205
225
|
function commandHasBypass(cmd) {
|
|
206
226
|
return typeof cmd === 'string' && /(?:^|\s)CODE_GRAPH_NO_BLOCK_GREP=1(?:\s|$)/.test(cmd);
|
|
207
227
|
}
|
|
208
228
|
|
|
229
|
+
// v0.49 — `sed -n X,Yp file.py` is a Read the Read hook can't see; the
|
|
230
|
+
// 2026-06-12 night used it heavily for structure exploration (four sed-range
|
|
231
|
+
// reads of stock_picker/ in 3 min). Extract targets so they count toward the
|
|
232
|
+
// shared read-fanout state.
|
|
233
|
+
const SED_RANGE = /(?:^|[|;&]\s*)sed\s+-n\s+(?:['"]\d+,\d+p['"]|\d+,\d+p)\s+("[^"]+"|'[^']+'|[^\s;|&]+)/g;
|
|
234
|
+
|
|
235
|
+
function extractSedReadTargets(cmd) {
|
|
236
|
+
if (!cmd || typeof cmd !== 'string' || cmd.length > 2000) return [];
|
|
237
|
+
const out = [];
|
|
238
|
+
for (const m of cmd.matchAll(SED_RANGE)) {
|
|
239
|
+
const tok = m[1].replace(/^["']|["']$/g, '');
|
|
240
|
+
if (tok && !out.includes(tok)) out.push(tok);
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// v0.50 — a compound command (`grep …; sed -n 1,60p f` / `grep … && wc`) is
|
|
246
|
+
// denied WHOLE, but the answer covers only the grep. The 2026-06-13 mem-project
|
|
247
|
+
// deny swallowed a `; sed` read while the copy said "use these results directly
|
|
248
|
+
// instead of re-running" — the tail's intent was silently dropped. Extract the
|
|
249
|
+
// first top-level `;`/`&&` tail (quote-aware) so the deny can flag it for
|
|
250
|
+
// re-issue. `||` tails are skipped: the answer delivered hits, so the on-failure
|
|
251
|
+
// branch would not have run anyway. Pipes/redirects are the same pipeline.
|
|
252
|
+
function extractUnansweredTail(cmd) {
|
|
253
|
+
if (!cmd || typeof cmd !== 'string' || cmd.length > 2000) return null;
|
|
254
|
+
let quote = null;
|
|
255
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
256
|
+
const c = cmd[i];
|
|
257
|
+
if (quote) {
|
|
258
|
+
if (c === quote) quote = null;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
262
|
+
if (c === ';' || (c === '&' && cmd[i + 1] === '&')) {
|
|
263
|
+
const tail = cmd.slice(i + (c === ';' ? 1 : 2)).trim();
|
|
264
|
+
return tail || null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Shared deny-copy footer for the unanswered compound tail. Budget-conscious:
|
|
271
|
+
// two lines, verbatim command so the model can re-issue without thinking.
|
|
272
|
+
// Wording is path-neutral — it must stay true for BOTH the answered deny
|
|
273
|
+
// ("grep half answered, tail wasn't") and the static fallback (nothing was).
|
|
274
|
+
function appendUnansweredTailNote(lines, tail) {
|
|
275
|
+
if (!tail) return;
|
|
276
|
+
const shown = tail.length > 300 ? tail.slice(0, 300) + '…' : tail;
|
|
277
|
+
lines.push(
|
|
278
|
+
'NOTE: the rest of this compound command (after the grep) did NOT run — re-issue it separately:',
|
|
279
|
+
`$ ${shown}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
209
283
|
// v0.47.0 — pull the first source-tree path token out of the denied command so
|
|
210
284
|
// the inline answer can scope its search the same way the raw grep would have.
|
|
211
285
|
function extractSearchPath(cmd) {
|
|
@@ -246,7 +320,7 @@ function buildHint() {
|
|
|
246
320
|
// Terse, no banner spam. Single message budget ~600 bytes.
|
|
247
321
|
return [
|
|
248
322
|
'[code-graph] Raw `grep`/`rg` on indexed source — consider AST-aware equivalents:',
|
|
249
|
-
' • code-graph-mcp grep "<pat>"
|
|
323
|
+
' • code-graph-mcp grep "<pat>" [paths...] # grep + containing fn/module per hit (-F literal, -i, -w, -l, -C N)',
|
|
250
324
|
' • code-graph-mcp ast-search "<pat>" --type fn # filter by type/returns/params',
|
|
251
325
|
' • code-graph-mcp callgraph SYMBOL # callers + callees, repo-wide',
|
|
252
326
|
' • code-graph-mcp show SYMBOL # one symbol: signature + source',
|
|
@@ -254,18 +328,22 @@ function buildHint() {
|
|
|
254
328
|
].join('\n');
|
|
255
329
|
}
|
|
256
330
|
|
|
257
|
-
function buildBlockReason() {
|
|
331
|
+
function buildBlockReason(unansweredTail) {
|
|
258
332
|
// Shown to Claude via PreToolUse `decision: block` reason. Must give a
|
|
259
333
|
// concrete alternate command Claude can re-issue without further thinking.
|
|
260
|
-
|
|
334
|
+
// v0.49 — NO escape-hatch line anywhere in deny copy: the daagu 2026-06-12
|
|
335
|
+
// night proved even the "THIS command only" scoping reads as a teachable
|
|
336
|
+
// permanent prefix (adopted in 8s, reused 11×, incl. on the exact identifier
|
|
337
|
+
// searches this hook targets). The env opt-out stays documented in README.
|
|
338
|
+
const lines = [
|
|
261
339
|
'[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
|
|
262
340
|
'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
|
|
263
|
-
' code-graph-mcp grep "<pattern>"
|
|
341
|
+
' code-graph-mcp grep "<pattern>" [paths...] # AST context per hit; -F literal, -i, -w, -l, -C N, --max-count 0',
|
|
264
342
|
' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
|
|
265
343
|
' code-graph-mcp callgraph SYMBOL # callers + callees',
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
344
|
+
];
|
|
345
|
+
appendUnansweredTailNote(lines, unansweredTail);
|
|
346
|
+
return lines.join('\n');
|
|
269
347
|
}
|
|
270
348
|
|
|
271
349
|
// v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
|
|
@@ -275,7 +353,7 @@ function buildBlockReason() {
|
|
|
275
353
|
// advertising the bypass taught it a permanent prefix within 5 seconds (daagu
|
|
276
354
|
// 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
|
|
277
355
|
// escape because there we give no answer.
|
|
278
|
-
function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
356
|
+
function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail) {
|
|
279
357
|
const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
|
|
280
358
|
const lines = [
|
|
281
359
|
'[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:',
|
|
@@ -288,14 +366,47 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
|
288
366
|
lines.push(
|
|
289
367
|
'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
|
|
290
368
|
);
|
|
369
|
+
appendUnansweredTailNote(lines, unansweredTail);
|
|
370
|
+
return lines.join('\n');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// v0.49 — show-mode deny: the model grepped for symbol DEFINITIONS with
|
|
374
|
+
// context flags (-A/-B/-C = "show me the body"); the answer IS the bodies,
|
|
375
|
+
// fetched via `code-graph-mcp show`. answer.text already carries per-symbol
|
|
376
|
+
// `$ code-graph-mcp show <sym>` headers.
|
|
377
|
+
function buildShowDenyReason(answer, unansweredTail) {
|
|
378
|
+
const lines = [
|
|
379
|
+
'[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:',
|
|
380
|
+
answer.text,
|
|
381
|
+
];
|
|
382
|
+
if (answer.truncated) {
|
|
383
|
+
lines.push('(truncated — re-run the `code-graph-mcp show <symbol>` command above for full source)');
|
|
384
|
+
}
|
|
385
|
+
lines.push('Use these directly instead of re-running the search.');
|
|
386
|
+
appendUnansweredTailNote(lines, unansweredTail);
|
|
291
387
|
return lines.join('\n');
|
|
292
388
|
}
|
|
293
389
|
|
|
390
|
+
// v0.49 — plain `grep` speaks BRE: alternation/grouping arrive escaped
|
|
391
|
+
// (`a\|b`, `\(x\)`) and 0-hit against cg grep's rust-regex dialect, wasting
|
|
392
|
+
// the answer on the ALLOW fallthrough (2026-06-12: both answered:false denies
|
|
393
|
+
// were dialect/path-shape misses). Unescape for plain grep only — rg/ag and
|
|
394
|
+
// grep -E/-P are already extended.
|
|
395
|
+
function translateBreToRg(cmd, pattern) {
|
|
396
|
+
if (typeof pattern !== 'string' || !pattern) return pattern;
|
|
397
|
+
const verb = (cmd.match(GREP_HEAD) || [])[1];
|
|
398
|
+
if (verb !== 'grep') return pattern;
|
|
399
|
+
if (/(?:^|\s)-[a-zA-Z]*[EP][a-zA-Z]*(?:\s|=|\d|$)|--(?:extended-regexp|perl-regexp)\b/.test(cmd)) {
|
|
400
|
+
return pattern;
|
|
401
|
+
}
|
|
402
|
+
return pattern.replace(/\\([|(){}+?])/g, '$1');
|
|
403
|
+
}
|
|
404
|
+
|
|
294
405
|
// v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
|
|
295
406
|
// ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead.
|
|
296
407
|
// Let the raw grep through with an honest one-liner.
|
|
297
408
|
function buildNoHitsFyi(pattern) {
|
|
298
|
-
return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding
|
|
409
|
+
return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding. (Regex-metachar patterns: \`code-graph-mcp grep -F\` searches literally.)`;
|
|
299
410
|
}
|
|
300
411
|
|
|
301
412
|
// --- Main execution (only when run directly) ---
|
|
@@ -338,6 +449,24 @@ function runMain() {
|
|
|
338
449
|
} catch { return; }
|
|
339
450
|
|
|
340
451
|
const rawCmd = (input.tool_input && input.tool_input.command) || '';
|
|
452
|
+
|
|
453
|
+
// v0.49 — sed-range reads count toward the read-fanout state (the Read hook
|
|
454
|
+
// never sees Bash-side file reads). A fired fanout hint already delivered an
|
|
455
|
+
// overview — skip grep hinting for this command to avoid double output.
|
|
456
|
+
const sedTargets = extractSedReadTargets(rawCmd);
|
|
457
|
+
if (sedTargets.length > 0) {
|
|
458
|
+
const readGuide = require('./pre-read-guide');
|
|
459
|
+
let fanoutFired = false;
|
|
460
|
+
for (const t of sedTargets) {
|
|
461
|
+
if (!readGuide.isSourceFile(t)) continue;
|
|
462
|
+
const abs = path.isAbsolute(t) ? t : path.resolve(shellCwd, t);
|
|
463
|
+
if (readGuide.trackReadAndMaybeHint(root, path.relative(root, abs))) {
|
|
464
|
+
fanoutFired = true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (fanoutFired) return;
|
|
468
|
+
}
|
|
469
|
+
|
|
341
470
|
// v0.47.1 — match against the root-stripped form so absolute paths under the
|
|
342
471
|
// project root behave exactly like their relative spelling. v0.48 — then
|
|
343
472
|
// rebase bare subdir-relative tokens onto the root. Cooldown stays keyed on
|
|
@@ -358,18 +487,30 @@ function runMain() {
|
|
|
358
487
|
|
|
359
488
|
markCooldown(rawCmd);
|
|
360
489
|
|
|
361
|
-
|
|
490
|
+
const block = isBlockDisabled() ? null : classifyBlock(cmd);
|
|
491
|
+
if (block) {
|
|
362
492
|
// v0.47.0 — run the AST-aware equivalent inside the hook and embed the
|
|
363
493
|
// results in the deny reason ("answer in the deny"). Degrades to the
|
|
364
494
|
// v0.46 static deny on any failure; downgrades to allow+FYI on 0 hits
|
|
365
495
|
// (regex-dialect differences mean 0 hits ≠ proof of absence).
|
|
496
|
+
// v0.49 — intent-aware: declaration+context greps get `show` bodies,
|
|
497
|
+
// falling back to the grep answer, then the static deny.
|
|
366
498
|
let answer = { status: 'unavailable' };
|
|
367
|
-
const pattern = pickBlockPattern(cmd);
|
|
499
|
+
const pattern = translateBreToRg(cmd, pickBlockPattern(cmd));
|
|
368
500
|
// v0.48 — glob-truncated once, shared by the run and the deny message
|
|
369
501
|
// (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
|
|
370
502
|
const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
|
|
371
|
-
|
|
372
|
-
|
|
503
|
+
let answeredMode = block.mode;
|
|
504
|
+
if (!isAnswerDisabled()) {
|
|
505
|
+
if (block.mode === 'show') {
|
|
506
|
+
answer = runShowAnswer({ cwd: root, symbols: block.symbols });
|
|
507
|
+
if (answer.status !== 'hits' && pattern) {
|
|
508
|
+
answeredMode = 'grep';
|
|
509
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
510
|
+
}
|
|
511
|
+
} else if (pattern) {
|
|
512
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
513
|
+
}
|
|
373
514
|
}
|
|
374
515
|
|
|
375
516
|
if (answer.status === 'no-hits') {
|
|
@@ -383,16 +524,27 @@ function runMain() {
|
|
|
383
524
|
// ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
|
|
384
525
|
// is the documented modern path. Exit 0 — this is a routing decision, not
|
|
385
526
|
// a hook failure (exit 2 would mark the tool call as "hook errored").
|
|
527
|
+
const answered = answer.status === 'hits';
|
|
528
|
+
// v0.50 — flag the unanswered `;`/`&&` tail. Extracted from rawCmd: its
|
|
529
|
+
// paths are valid in the model's shell cwd, where the re-issue will run.
|
|
530
|
+
const unansweredTail = extractUnansweredTail(rawCmd);
|
|
386
531
|
recordRecommendation(root, {
|
|
387
|
-
hook: 'grep', action: 'deny', answered
|
|
532
|
+
hook: 'grep', action: 'deny', answered,
|
|
533
|
+
// mode segments which answer type converts (show=bodies, grep=hits).
|
|
534
|
+
...(answered ? { mode: answeredMode } : {}),
|
|
535
|
+
// tail segments compound-command denies — lets the funnel compare
|
|
536
|
+
// re-issue behavior for denies that carried a tail note.
|
|
537
|
+
...(unansweredTail ? { tail: true } : {}),
|
|
388
538
|
});
|
|
389
539
|
process.stdout.write(JSON.stringify({
|
|
390
540
|
hookSpecificOutput: {
|
|
391
541
|
hookEventName: 'PreToolUse',
|
|
392
542
|
permissionDecision: 'deny',
|
|
393
|
-
permissionDecisionReason:
|
|
394
|
-
?
|
|
395
|
-
:
|
|
543
|
+
permissionDecisionReason: !answered
|
|
544
|
+
? buildBlockReason(unansweredTail)
|
|
545
|
+
: answeredMode === 'show'
|
|
546
|
+
? buildShowDenyReason(answer, unansweredTail)
|
|
547
|
+
: buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail),
|
|
396
548
|
},
|
|
397
549
|
}) + '\n');
|
|
398
550
|
return;
|
|
@@ -409,6 +561,12 @@ if (require.main === module) {
|
|
|
409
561
|
module.exports = {
|
|
410
562
|
shouldHint,
|
|
411
563
|
shouldBlock,
|
|
564
|
+
classifyBlock, // v0.49 — intent-aware block tiers
|
|
565
|
+
extractDeclSymbols, // v0.49 — show-mode symbol extraction
|
|
566
|
+
translateBreToRg, // v0.49 — BRE→rust-regex dialect bridge
|
|
567
|
+
buildShowDenyReason, // v0.49 — show-mode deny copy
|
|
568
|
+
extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
|
|
569
|
+
extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
|
|
412
570
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
413
571
|
extractSearchPath, // v0.47.0 — deny-with-answer
|
|
414
572
|
normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
|