@sdsrs/code-graph 0.162.0 → 0.164.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/README.md +5 -6
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/agents/code-explorer.md +15 -12
- package/claude-plugin/hooks/hooks.json +91 -1
- package/claude-plugin/scripts/adopt.js +49 -37
- package/claude-plugin/scripts/cg-answer.js +7 -0
- package/claude-plugin/scripts/covering-tests.js +1 -2
- package/claude-plugin/scripts/doctor.js +40 -1
- package/claude-plugin/scripts/incremental-index.js +4 -1
- package/claude-plugin/scripts/lifecycle.js +49 -13
- package/claude-plugin/scripts/post-grep-inject.js +58 -3
- package/claude-plugin/scripts/pre-edit-guide.js +122 -83
- package/claude-plugin/scripts/pre-grep-guide.js +243 -11
- package/claude-plugin/scripts/pre-read-guide.js +42 -28
- package/claude-plugin/scripts/project-root.js +23 -1
- package/claude-plugin/scripts/session-edits.js +67 -5
- package/claude-plugin/scripts/session-init.js +101 -348
- package/claude-plugin/scripts/stop-impact.js +244 -18
- package/claude-plugin/scripts/subagent-start.js +8 -0
- package/claude-plugin/scripts/user-prompt-context.js +48 -16
- package/claude-plugin/skills/explore/SKILL.md +10 -7
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/claude-plugin/templates/plugin_code_graph_mcp.md +58 -169
- package/package.json +6 -6
|
@@ -88,13 +88,48 @@ const VERB_STRIP = new RegExp(`^\\s*(?:env\\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\
|
|
|
88
88
|
// invisible to the hook so it could never scope the answer to the real target.
|
|
89
89
|
const SRC_PREFIXES =
|
|
90
90
|
'src|tests|lib|libs|scripts|skills|claude-plugin|tools|pkg|cmd|internal|app|apps|components?|server|client|crates|packages|backend|frontend|services|models|domain|controllers|views|handlers|middleware|routes|repositories|entities|migrations|tasks|jobs|workers|features|modules|api|web';
|
|
91
|
-
|
|
91
|
+
// The three source-path tests below are built from SRC_PREFIXES plus the
|
|
92
|
+
// project's own source roots (useSourceRoots), so they are `let`.
|
|
93
|
+
let SRC_PATH;
|
|
92
94
|
// Anchored variant for whole-token matching in extractSearchPath.
|
|
93
|
-
|
|
95
|
+
let SRC_PATH_TOKEN;
|
|
94
96
|
// A path operand the rewrite grammar proved: a bare prefix word (`src`), or a
|
|
95
97
|
// `./`-rooted one with any subpath (`./src`, `./src/x/`), which SRC_PATH's
|
|
96
98
|
// lookbehind never matched.
|
|
97
|
-
|
|
99
|
+
let SRC_BARE_TOKEN;
|
|
100
|
+
|
|
101
|
+
// F1 (tasks/specs/grep-hook-source-roots.md) — the prefix list names
|
|
102
|
+
// conventional source dirs, but a Python package lives in a dir named after it
|
|
103
|
+
// (`networkx/`, `django/`): in the 2026-09-28 coding eval not one grep of 15
|
|
104
|
+
// runs reached this hook. The indexer writes the top-level dirs that hold
|
|
105
|
+
// indexed code to `.code-graph/source-roots.json`, and those names join the
|
|
106
|
+
// list. A name must be one plain shell word (no quote, space, slash, leading
|
|
107
|
+
// dot); past MAX_SOURCE_ROOTS the list alone applies, as it does when the file
|
|
108
|
+
// is missing or unreadable.
|
|
109
|
+
const SOURCE_ROOTS_FILE = 'source-roots.json';
|
|
110
|
+
const MAX_SOURCE_ROOTS = 64;
|
|
111
|
+
const SOURCE_ROOT_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.+@-]{0,63}$/;
|
|
112
|
+
const PREFIX_WORDS = new Set(SRC_PREFIXES.replace('components?', 'components|component').split('|'));
|
|
113
|
+
|
|
114
|
+
function readSourceRoots(root) {
|
|
115
|
+
if (!root) return [];
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(fs.readFileSync(path.join(root, '.code-graph', SOURCE_ROOTS_FILE), 'utf8'));
|
|
119
|
+
} catch { return []; }
|
|
120
|
+
const roots = parsed && Array.isArray(parsed.roots) ? parsed.roots : null;
|
|
121
|
+
if (!roots || roots.length > MAX_SOURCE_ROOTS) return [];
|
|
122
|
+
return roots.filter((r) => typeof r === 'string' && SOURCE_ROOT_NAME.test(r) && !PREFIX_WORDS.has(r));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function useSourceRoots(roots) {
|
|
126
|
+
const escaped = (roots || []).map((r) => r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
127
|
+
const alt = [SRC_PREFIXES, ...escaped].join('|');
|
|
128
|
+
SRC_PATH = new RegExp(`(?:^|\\s|["'])(${alt})/`);
|
|
129
|
+
SRC_PATH_TOKEN = new RegExp(`^(?:\\./)?(${alt})/`);
|
|
130
|
+
SRC_BARE_TOKEN = new RegExp(`^(?:(?:${alt})|\\./(?:${alt})(?:/.*)?)$`);
|
|
131
|
+
}
|
|
132
|
+
useSourceRoots([]);
|
|
98
133
|
|
|
99
134
|
// D#73 — a source dir written as a bare word (`grep -rn X src`, `rg X tests`,
|
|
100
135
|
// `./src`): the shape models write when a prompt says "under src/", and it
|
|
@@ -826,6 +861,135 @@ function extractSedReadTargets(cmd) {
|
|
|
826
861
|
return out;
|
|
827
862
|
}
|
|
828
863
|
|
|
864
|
+
// Q1 — `sed -i` and `perl -pi` edit files the edit hooks never see. Their
|
|
865
|
+
// targets are logged for the Stop hook's signature check (never answered).
|
|
866
|
+
// Shapes: tasks/specs/edit-log-coverage.md. A word only counts as a target
|
|
867
|
+
// when it names an existing regular file, so a misread costs a few baseline
|
|
868
|
+
// records the Stop check then finds unchanged; a command the tokenizer cannot
|
|
869
|
+
// read exactly (`$x`, globs) is skipped, which is the state before Q1.
|
|
870
|
+
const MAX_IN_PLACE_TARGETS = 8;
|
|
871
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Absolute paths of the files an in-place `sed`/`perl` in `cmd` edits, at
|
|
875
|
+
* most MAX_IN_PLACE_TARGETS, in command order.
|
|
876
|
+
* @param {string} cmd
|
|
877
|
+
* @param {string} shellCwd where the command starts
|
|
878
|
+
* @param {{isFile?: (abs:string)=>boolean, isDir?: (abs:string)=>boolean}} [opts]
|
|
879
|
+
* @returns {string[]}
|
|
880
|
+
*/
|
|
881
|
+
function extractInPlaceEditTargets(cmd, shellCwd, { isFile = isRegularFile, isDir = isDirectory } = {}) {
|
|
882
|
+
if (!cmd || typeof cmd !== 'string' || cmd.length > 4000 || !/\b(?:sed|perl)\b/.test(cmd)) return [];
|
|
883
|
+
const segments = splitTopLevelSegments(cmd);
|
|
884
|
+
const seps = segmentSeparators(cmd);
|
|
885
|
+
const out = [];
|
|
886
|
+
for (let i = 0; i < segments.length && out.length < MAX_IN_PLACE_TARGETS; i++) {
|
|
887
|
+
const words = shellWords(segments[i]);
|
|
888
|
+
if (!words) continue;
|
|
889
|
+
let k = 0;
|
|
890
|
+
while (k < words.length && !words[k].op && !words[k].anyQuoted && ENV_ASSIGNMENT.test(words[k].text)) k++;
|
|
891
|
+
const head = words[k];
|
|
892
|
+
if (!head || head.op || head.anyQuoted || (head.text !== 'sed' && head.text !== 'perl')) continue;
|
|
893
|
+
const args = [];
|
|
894
|
+
for (let j = k + 1; j < words.length && !words[j].op; j++) args.push(words[j]);
|
|
895
|
+
const operands = head.text === 'sed' ? sedInPlaceFiles(args) : perlInPlaceFiles(args);
|
|
896
|
+
if (operands.length === 0) continue;
|
|
897
|
+
const cwd = segmentCwd(segments, i, shellCwd, { isDir, seps });
|
|
898
|
+
if (cwd === null) continue;
|
|
899
|
+
for (const w of operands) {
|
|
900
|
+
const abs = path.resolve(cwd, w);
|
|
901
|
+
if (!out.includes(abs) && out.length < MAX_IN_PLACE_TARGETS && isFile(abs)) out.push(abs);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return out;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Quoting does not decide what is an option: the shell removes the quotes
|
|
908
|
+
// before sed or perl sees `--expression='s/a/b/'` or `'-i'`.
|
|
909
|
+
//
|
|
910
|
+
// Plain operand words, a redirect and its target dropped. A word with a bare
|
|
911
|
+
// glob or redirect character is not a file name the shell passes unchanged.
|
|
912
|
+
function operandWords(words) {
|
|
913
|
+
const out = [];
|
|
914
|
+
for (let j = 0; j < words.length; j++) {
|
|
915
|
+
const w = words[j];
|
|
916
|
+
if (w.bareOp) {
|
|
917
|
+
if (/[<>]$/.test(w.text)) j++; // `> out`: the next word is the redirect's target
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (!w.bareSpecial) out.push(w.text);
|
|
921
|
+
}
|
|
922
|
+
return out;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// GNU sed: `-i[SUFFIX]` / `--in-place[=SUFFIX]` (a suffix is attached, so in a
|
|
926
|
+
// cluster the rest of the word is it: `-ie` = suffix `e`); `-e`/`-f`/`-l` and
|
|
927
|
+
// their long forms take a value. The first operand is the script unless `-e`
|
|
928
|
+
// or `-f` gave one. BSD `-i ''` reads as GNU `-i` with script `''`, and its
|
|
929
|
+
// real script then fails the existing-file test.
|
|
930
|
+
function sedInPlaceFiles(words) {
|
|
931
|
+
let inPlace = false;
|
|
932
|
+
let haveScript = false;
|
|
933
|
+
let endOfOpts = false;
|
|
934
|
+
const operands = [];
|
|
935
|
+
for (let j = 0; j < words.length; j++) {
|
|
936
|
+
const w = words[j];
|
|
937
|
+
const t = w.text;
|
|
938
|
+
if (endOfOpts || !t.startsWith('-') || t === '-') { operands.push(w); continue; }
|
|
939
|
+
if (t === '--') { endOfOpts = true; continue; }
|
|
940
|
+
if (t.startsWith('--')) {
|
|
941
|
+
const [name, value] = [t.slice(2).split('=')[0], t.includes('=')];
|
|
942
|
+
if (name === 'in-place') inPlace = true;
|
|
943
|
+
else if (name === 'expression' || name === 'file') { haveScript = true; if (!value) j++; }
|
|
944
|
+
else if (name === 'line-length' && !value) j++;
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
for (let c = 1; c < t.length; c++) {
|
|
948
|
+
const ch = t[c];
|
|
949
|
+
if (ch === 'i') { inPlace = true; break; }
|
|
950
|
+
if (ch === 'e' || ch === 'f' || ch === 'l') {
|
|
951
|
+
if (ch !== 'l') haveScript = true;
|
|
952
|
+
if (c === t.length - 1) j++;
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (!inPlace) return [];
|
|
958
|
+
return operandWords(haveScript ? operands : operands.slice(1));
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// perl: `-i[EXT]` (the rest of the word is the extension: `-pie` = `-p -i`
|
|
962
|
+
// with extension `e`); `-e`/`-E` take the code (rest of the word, else the
|
|
963
|
+
// next word), `-M`/`-I`/`-F` a value the same way. With no `-e`, the first
|
|
964
|
+
// operand is the script file.
|
|
965
|
+
function perlInPlaceFiles(words) {
|
|
966
|
+
let inPlace = false;
|
|
967
|
+
let haveCode = false;
|
|
968
|
+
let endOfOpts = false;
|
|
969
|
+
const operands = [];
|
|
970
|
+
for (let j = 0; j < words.length; j++) {
|
|
971
|
+
const w = words[j];
|
|
972
|
+
const t = w.text;
|
|
973
|
+
if (endOfOpts || !t.startsWith('-') || t === '-') { operands.push(w); continue; }
|
|
974
|
+
if (t === '--') { endOfOpts = true; continue; }
|
|
975
|
+
for (let c = 1; c < t.length; c++) {
|
|
976
|
+
const ch = t[c];
|
|
977
|
+
if (ch === 'i') { inPlace = true; break; }
|
|
978
|
+
if ('eEMIF'.includes(ch)) {
|
|
979
|
+
if (ch === 'e' || ch === 'E') haveCode = true;
|
|
980
|
+
if (c === t.length - 1) j++;
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (!inPlace) return [];
|
|
986
|
+
return operandWords(haveCode ? operands : operands.slice(1));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function isRegularFile(p) {
|
|
990
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
991
|
+
}
|
|
992
|
+
|
|
829
993
|
// v0.50 — a compound command (`grep …; sed -n 1,60p f` / `grep … && wc`) is
|
|
830
994
|
// denied WHOLE, but the answer covers only the grep. The 2026-06-13 mem-project
|
|
831
995
|
// deny swallowed a `; sed` read while the copy said "use these results directly
|
|
@@ -1120,7 +1284,7 @@ function buildHint() {
|
|
|
1120
1284
|
' • code-graph-mcp ast-search "<pat>" --type fn # filter by type/returns/params',
|
|
1121
1285
|
' • code-graph-mcp callgraph SYMBOL # callers + callees, repo-wide',
|
|
1122
1286
|
' • code-graph-mcp show SYMBOL # one symbol: signature + source',
|
|
1123
|
-
'Repo-wide index
|
|
1287
|
+
'Repo-wide index. Skip this hint if you specifically need raw-text regex.',
|
|
1124
1288
|
].join('\n');
|
|
1125
1289
|
}
|
|
1126
1290
|
|
|
@@ -1459,7 +1623,48 @@ function rgIgnoreAbove(dir) {
|
|
|
1459
1623
|
function isSymlink(p, { missing = true } = {}) {
|
|
1460
1624
|
try { return fs.lstatSync(p).isSymbolicLink(); } catch { return missing; }
|
|
1461
1625
|
}
|
|
1462
|
-
|
|
1626
|
+
// F1b — `grep -r` reads git-ignored files and the rewrite does not, so an
|
|
1627
|
+
// ignored entry under the path declines. The one nearly every Python tree has
|
|
1628
|
+
// once its tests ran is a `__pycache__/` of `.pyc` files (the coding-eval
|
|
1629
|
+
// fixtures carry 286), and a binary file yields no matched line: grep notes
|
|
1630
|
+
// "binary file matches" instead (stderr in GNU grep 3.5+, stdout in BSD grep
|
|
1631
|
+
// and ugrep), a notice, not a hit. So a bytecode cache may differ between the
|
|
1632
|
+
// two — for a grep that does not list files (see grepListsBinaryMatches).
|
|
1633
|
+
const BYTECODE_FILE = /\.py[co]$/;
|
|
1634
|
+
function isBytecodeCache(root, rel) {
|
|
1635
|
+
const r = rel.replace(/\/+$/, '');
|
|
1636
|
+
if (BYTECODE_FILE.test(r)) return true;
|
|
1637
|
+
if (path.posix.basename(r) !== '__pycache__') return false;
|
|
1638
|
+
let entries;
|
|
1639
|
+
try { entries = fs.readdirSync(path.join(root, r), { withFileTypes: true }); } catch { return false; }
|
|
1640
|
+
return entries.length <= WALK_LIMIT && entries.every((e) => e.isFile() && BYTECODE_FILE.test(e.name));
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/// True when the grep would PRINT a matching binary file: it lists files
|
|
1644
|
+
/// (`-l`, `--files-with-matches`) and does not skip binaries (`-I`). The
|
|
1645
|
+
/// rewrite grammar admits no other output mode that prints one for grep
|
|
1646
|
+
/// (`-c`, `-L`, `-a`, `-o` are refused). Only the grep's own clause counts.
|
|
1647
|
+
function grepListsBinaryMatches(cmd) {
|
|
1648
|
+
const words = clauseWords(firstShellClause(cmd || '').replace(VERB_STRIP, ''));
|
|
1649
|
+
if (!words) return true;
|
|
1650
|
+
let lists = false;
|
|
1651
|
+
let skipsBinary = false;
|
|
1652
|
+
// Quoted words count too: the shell strips the quotes and grep still reads
|
|
1653
|
+
// `"-l"` as an option. A quoted PATTERN that merely contains ` -l ` has a
|
|
1654
|
+
// space in it and never matches the one-cluster shape below.
|
|
1655
|
+
for (const w of words) {
|
|
1656
|
+
const t = w.text;
|
|
1657
|
+
if (t === '--files-with-matches') lists = true;
|
|
1658
|
+
else if (t === '--binary-files=without-match') skipsBinary = true;
|
|
1659
|
+
else if (/^-[A-Za-z0-9]+$/.test(t)) {
|
|
1660
|
+
if (t.includes('l')) lists = true;
|
|
1661
|
+
if (t.includes('I')) skipsBinary = true;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return lists && !skipsBinary;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function searchesSameFiles({ root, target, verb, show = false, binaryCacheOk = false } = {}) {
|
|
1463
1668
|
if (!root || !['grep', 'git', 'rg', 'ag'].includes(verb)) return false;
|
|
1464
1669
|
const rel = target === undefined ? '' : String(target).replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
1465
1670
|
if (rel.split('/').includes('..') || path.isAbsolute(rel)) return false;
|
|
@@ -1503,7 +1708,8 @@ function searchesSameFiles({ root, target, verb, show = false } = {}) {
|
|
|
1503
1708
|
const anyHidden = (l) => l.some((f) => HIDDEN_SEGMENT.test(f));
|
|
1504
1709
|
let reads;
|
|
1505
1710
|
if (verb === 'grep') {
|
|
1506
|
-
|
|
1711
|
+
const ignored = binaryCacheOk ? ignoredOthers.filter((f) => !isBytecodeCache(root, f)) : ignoredOthers;
|
|
1712
|
+
if (anyHidden(others) || ignored.length || ignoredTracked.length) return false;
|
|
1507
1713
|
reads = [...tracked, ...others];
|
|
1508
1714
|
} else if (verb === 'git') {
|
|
1509
1715
|
if (others.length) return false;
|
|
@@ -1998,15 +2204,17 @@ function isAnswerDisabled(env = process.env) {
|
|
|
1998
2204
|
// Smallest per-dir share of the context cap worth spending on an overview answer.
|
|
1999
2205
|
const MIN_FANOUT_ANSWER_BYTES = 400;
|
|
2000
2206
|
|
|
2001
|
-
/// Byte budget for each overview answer
|
|
2002
|
-
///
|
|
2207
|
+
/// Byte budget for each overview answer of the dirs that fire in one command.
|
|
2208
|
+
/// A single dir is budgeted too: left at the answer's own 4,000-byte default,
|
|
2209
|
+
/// its header pushed the hint past the envelope cap and the overview was
|
|
2210
|
+
/// replaced by a bare "5+ Reads also into …" line (hook audit P2-5). Every hint first
|
|
2003
2211
|
/// pays for its own lines — an answer's header, footer and separator are about
|
|
2004
2212
|
/// 175 bytes plus the dir name twice, the one-line advice about 165 plus the
|
|
2005
2213
|
/// name three times — reserved as 200 plus the name three times, and the
|
|
2006
2214
|
/// answers share what is left of the cap. A share under MIN_FANOUT_ANSWER_BYTES
|
|
2007
2215
|
/// returns 0: every dir then gets the one-line advice instead.
|
|
2008
2216
|
function fanoutAnswerBudget(dirs) {
|
|
2009
|
-
if (dirs.length <
|
|
2217
|
+
if (dirs.length < 1) return undefined;
|
|
2010
2218
|
const reserved = dirs.reduce((sum, d) => sum + 200 + 3 * Buffer.byteLength(d, 'utf8'), 0);
|
|
2011
2219
|
const share = Math.floor((MAX_INJECTED_BYTES - reserved) / dirs.length);
|
|
2012
2220
|
return share >= MIN_FANOUT_ANSWER_BYTES ? share : 0;
|
|
@@ -2039,6 +2247,7 @@ function runMain() {
|
|
|
2039
2247
|
const shellCwd = process.cwd();
|
|
2040
2248
|
const root = resolveProjectRoot(shellCwd);
|
|
2041
2249
|
if (root === null) return; // no index anywhere up to $HOME — no hint
|
|
2250
|
+
useSourceRoots(readSourceRoots(root));
|
|
2042
2251
|
|
|
2043
2252
|
let input;
|
|
2044
2253
|
try {
|
|
@@ -2050,6 +2259,18 @@ function runMain() {
|
|
|
2050
2259
|
|
|
2051
2260
|
const rawCmd = (input.tool_input && input.tool_input.command) || '';
|
|
2052
2261
|
|
|
2262
|
+
// Q1 — an in-place `sed`/`perl` is logged for the Stop check, then the
|
|
2263
|
+
// command goes on as it would have (nothing here answers or rewrites it).
|
|
2264
|
+
const inPlace = extractInPlaceEditTargets(rawCmd, shellCwd);
|
|
2265
|
+
if (inPlace.length > 0) {
|
|
2266
|
+
const sessionEdits = require('./session-edits');
|
|
2267
|
+
for (const abs of inPlace) {
|
|
2268
|
+
const rel = path.relative(root, abs);
|
|
2269
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
2270
|
+
sessionEdits.recordFileEdit(root, input.session_id, rel.split(path.sep).join('/'));
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2053
2274
|
// v0.49 — sed-range reads count toward the read-fanout state (the Read hook
|
|
2054
2275
|
// never sees Bash-side file reads). A fired fanout hint already delivered an
|
|
2055
2276
|
// overview — skip grep hinting for this command to avoid double output.
|
|
@@ -2077,8 +2298,15 @@ function runMain() {
|
|
|
2077
2298
|
}
|
|
2078
2299
|
if (firedDirs.length > 0) {
|
|
2079
2300
|
const maxBytes = fanoutAnswerBudget(firedDirs);
|
|
2080
|
-
|
|
2081
|
-
|
|
2301
|
+
// A dir with no overview contributes nothing (buildFanoutHint → null);
|
|
2302
|
+
// it stays marked, so it does not fire again.
|
|
2303
|
+
const answered = firedDirs
|
|
2304
|
+
.map((dir) => ({ dir, hint: readGuide.buildFanoutHint(root, dir, { maxBytes }) }))
|
|
2305
|
+
.filter((h) => h.hint !== null);
|
|
2306
|
+
if (answered.length > 0) {
|
|
2307
|
+
process.stdout.write(emitPreToolContext(
|
|
2308
|
+
joinFanoutHints(answered.map((h) => h.dir), answered.map((h) => h.hint))) + '\n');
|
|
2309
|
+
}
|
|
2082
2310
|
return;
|
|
2083
2311
|
}
|
|
2084
2312
|
}
|
|
@@ -2232,6 +2460,7 @@ function runMain() {
|
|
|
2232
2460
|
// D#133, H-2).
|
|
2233
2461
|
if (plan && !searchesSameFiles({
|
|
2234
2462
|
root, target: extractSearchPath(cmd), verb: plan.verb, show: answeredMode === 'show',
|
|
2463
|
+
binaryCacheOk: !grepListsBinaryMatches(cmd),
|
|
2235
2464
|
})) return;
|
|
2236
2465
|
|
|
2237
2466
|
const answered = answer.status === 'hits';
|
|
@@ -2311,6 +2540,8 @@ if (require.main === module) {
|
|
|
2311
2540
|
}
|
|
2312
2541
|
|
|
2313
2542
|
module.exports = {
|
|
2543
|
+
grepListsBinaryMatches,
|
|
2544
|
+
useSourceRoots, readSourceRoots, MAX_SOURCE_ROOTS,
|
|
2314
2545
|
shouldHint,
|
|
2315
2546
|
shouldBlock,
|
|
2316
2547
|
classifyBlock, // v0.49 — intent-aware block tiers
|
|
@@ -2330,6 +2561,7 @@ module.exports = {
|
|
|
2330
2561
|
segmentCwd, // D#76 — the directory a compound command's segment runs in
|
|
2331
2562
|
segmentSeparators, // D#76 — the separator before each top-level segment
|
|
2332
2563
|
extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
|
|
2564
|
+
extractInPlaceEditTargets, // Q1 — `sed -i` / `perl -pi` targets feed the Stop check's edit log
|
|
2333
2565
|
extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
|
|
2334
2566
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
2335
2567
|
countNamedPaths, // v0.70 — multi-path deny→hint downgrade
|
|
@@ -19,8 +19,9 @@ if (require.main === module) require('./hook-fail-open').installHookFailOpen('Pr
|
|
|
19
19
|
// 2. file_path is under CWD (no escape to absolute paths outside the project)
|
|
20
20
|
// 3. file_path is not at CWD root (top-level files = config / one-off scripts)
|
|
21
21
|
// 4. .code-graph/index.db exists in CWD (project is indexed)
|
|
22
|
-
// 5. ≥5
|
|
23
|
-
// 6.
|
|
22
|
+
// 5. ≥5 DISTINCT files read in the SAME parent dir, tracked in /tmp state
|
|
23
|
+
// 6. The dir has not fired before while its state entry lives
|
|
24
|
+
// 7. The CLI delivers an overview for the dir (no advice-only line)
|
|
24
25
|
//
|
|
25
26
|
// State scoping: per-cwd (NOT per-session). Cost: two concurrent sessions in
|
|
26
27
|
// the same project might share counters and over-trigger by ~1 hint each.
|
|
@@ -35,22 +36,17 @@ const path = require('path');
|
|
|
35
36
|
const crypto = require('crypto');
|
|
36
37
|
const { cgTmpDir } = require('./tmp-dir');
|
|
37
38
|
const { recordRecommendation } = require('./recommendation-log');
|
|
38
|
-
const { resolveProjectRoot } = require('./project-root');
|
|
39
|
+
const { resolveProjectRoot, indexBuildInProgress } = require('./project-root');
|
|
39
40
|
const { runOverviewAnswer } = require('./cg-answer');
|
|
40
41
|
const { emitPreToolAllowContext } = require('./hook-emit');
|
|
41
42
|
|
|
42
43
|
// --- Configuration ---
|
|
43
44
|
|
|
44
|
-
// Hint fires on the (FANOUT_THRESHOLD + 1)-th
|
|
45
|
+
// Hint fires on the (FANOUT_THRESHOLD + 1)-th DISTINCT file read in the same dir.
|
|
45
46
|
// Set so that 4 reads stay quiet (legitimate "read a couple files to
|
|
46
|
-
// understand X" pattern); 5+
|
|
47
|
+
// understand X" pattern); 5+ files is the fanout we want to catch.
|
|
47
48
|
const FANOUT_THRESHOLD = 4;
|
|
48
49
|
|
|
49
|
-
// Per-dir cooldown after firing a hint. Prevents spam if Claude keeps
|
|
50
|
-
// reading the same dir after seeing the hint (e.g., still has 3 more
|
|
51
|
-
// files queued from a prior plan).
|
|
52
|
-
const COOLDOWN_MS = 5 * 60 * 1000;
|
|
53
|
-
|
|
54
50
|
// Entries older than this are pruned on load. Long enough to survive
|
|
55
51
|
// normal multi-step tasks (15-20 min typical), short enough that stale
|
|
56
52
|
// per-cwd state doesn't accumulate across days.
|
|
@@ -104,11 +100,23 @@ function saveState(cwd, state) {
|
|
|
104
100
|
} catch { /* ok */ }
|
|
105
101
|
}
|
|
106
102
|
|
|
107
|
-
|
|
103
|
+
// Distinct files remembered per dir. Past this the count is already far over
|
|
104
|
+
// the threshold, so the list stops growing and so does the state file.
|
|
105
|
+
const MAX_FILES_PER_DIR = 32;
|
|
106
|
+
|
|
107
|
+
/// Count a read of `file` (when given) in `dir`. Re-reading one file does not
|
|
108
|
+
/// count again: five chunked reads of one big file are not a fanout, and they
|
|
109
|
+
/// fired an overview of its whole parent dir (hook audit 2026-09-28).
|
|
110
|
+
function recordRead(state, dir, now = Date.now(), file) {
|
|
108
111
|
if (!state.by_dir[dir]) state.by_dir[dir] = { reads: 0, last_read_at: 0, last_hint_at: 0 };
|
|
109
112
|
const e = state.by_dir[dir];
|
|
110
|
-
e.reads += 1;
|
|
111
113
|
e.last_read_at = now;
|
|
114
|
+
if (file) {
|
|
115
|
+
if (!Array.isArray(e.files)) e.files = [];
|
|
116
|
+
if (e.files.includes(file)) return;
|
|
117
|
+
if (e.files.length < MAX_FILES_PER_DIR) e.files.push(file);
|
|
118
|
+
}
|
|
119
|
+
e.reads += 1;
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
function shouldHint(state, dir, now = Date.now()) {
|
|
@@ -116,7 +124,10 @@ function shouldHint(state, dir, now = Date.now()) {
|
|
|
116
124
|
const e = state.by_dir[dir];
|
|
117
125
|
if (!e) return false;
|
|
118
126
|
if (e.reads < FANOUT_THRESHOLD + 1) return false; // need >=5
|
|
119
|
-
|
|
127
|
+
// Once per dir while its entry lives (pruned STATE_TTL_MS after the last
|
|
128
|
+
// read). The old 5-minute re-fire re-sent the same overview: 31.7% of fanout
|
|
129
|
+
// hints in 2026-09 sessions repeated a text the session already had.
|
|
130
|
+
if (e.last_hint_at) return false;
|
|
120
131
|
return true;
|
|
121
132
|
}
|
|
122
133
|
|
|
@@ -125,18 +136,13 @@ function markHint(state, dir, now = Date.now()) {
|
|
|
125
136
|
state.by_dir[dir].last_hint_at = now;
|
|
126
137
|
}
|
|
127
138
|
|
|
128
|
-
function buildHint(dir) {
|
|
129
|
-
// Single-line, ~190-byte budget. Skip-clause matches pre-grep-guide voice.
|
|
130
|
-
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.`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
139
|
// v0.49 — the hint DELIVERS the overview instead of advising a tool call
|
|
134
140
|
// (advice measured 0/40 transfer on 2026-06-12; delivered answers satisfied
|
|
135
141
|
// 5/5 in place). Falls back to the advice-only line when the CLI is
|
|
136
142
|
// unavailable or the dir has no overview.
|
|
137
143
|
function buildHintWithAnswer(dir, answer) {
|
|
138
144
|
const lines = [
|
|
139
|
-
`[code-graph] 5+ Reads into ${dir}/ — module overview from the AST index
|
|
145
|
+
`[code-graph] 5+ Reads into ${dir}/ — module overview from the AST index:`,
|
|
140
146
|
answer.text,
|
|
141
147
|
];
|
|
142
148
|
if (answer.truncated) {
|
|
@@ -167,9 +173,11 @@ function trackRead(root, rel, now = Date.now()) {
|
|
|
167
173
|
if (!dir || dir === '.' || dir === '') return null; // top-level file: not fanout
|
|
168
174
|
|
|
169
175
|
const state = loadState(root, now);
|
|
170
|
-
recordRead(state, dir, now);
|
|
176
|
+
recordRead(state, dir, now, rel);
|
|
171
177
|
let fired = false;
|
|
172
|
-
|
|
178
|
+
// During the startup build the overview would list part of the dir as the
|
|
179
|
+
// whole module (D5); leave the hint unspent for the first read after it.
|
|
180
|
+
if (!indexBuildInProgress(root, now) && shouldHint(state, dir, now)) {
|
|
173
181
|
markHint(state, dir, now);
|
|
174
182
|
fired = true;
|
|
175
183
|
}
|
|
@@ -184,9 +192,15 @@ function trackRead(root, rel, now = Date.now()) {
|
|
|
184
192
|
return dir;
|
|
185
193
|
}
|
|
186
194
|
|
|
187
|
-
/// The fanout hint text for `dir
|
|
188
|
-
/// CLI delivers
|
|
189
|
-
/// splits one envelope's budget between several dirs.
|
|
195
|
+
/// The fanout hint text for `dir` with the overview answer embedded, or null
|
|
196
|
+
/// when the CLI delivers none. `maxBytes` bounds that answer — the pre-grep sed
|
|
197
|
+
/// path splits one envelope's budget between several dirs.
|
|
198
|
+
///
|
|
199
|
+
/// No overview, no hint. The advice-only line measured no effect twice: 0/40
|
|
200
|
+
/// transfer on 2026-06-12, and 18/672 follow-through against a 2.76% baseline in
|
|
201
|
+
/// 2026-09 sessions, where 572 of the 672 advised `overview tests/` — which
|
|
202
|
+
/// answers "No symbols found", since overview leaves test symbols out. The
|
|
203
|
+
/// unanswered hint is still recorded, so the funnel keeps its reasons.
|
|
190
204
|
function buildFanoutHint(root, dir, { maxBytes } = {}) {
|
|
191
205
|
let answer = { status: 'unavailable' };
|
|
192
206
|
if (maxBytes === 0) {
|
|
@@ -212,11 +226,11 @@ function buildFanoutHint(root, dir, { maxBytes } = {}) {
|
|
|
212
226
|
...(answered ? {} : { reason: answer.status }),
|
|
213
227
|
...(answered || !answer.reason ? {} : { fallthrough_reason: answer.reason }),
|
|
214
228
|
});
|
|
215
|
-
return answered ? buildHintWithAnswer(dir, answer) :
|
|
229
|
+
return answered ? buildHintWithAnswer(dir, answer) : null;
|
|
216
230
|
}
|
|
217
231
|
|
|
218
232
|
/// trackRead + buildFanoutHint for one read. Returns the hint text when the
|
|
219
|
-
/// hint fired, else null. The caller emits it.
|
|
233
|
+
/// hint fired with an overview, else null. The caller emits it.
|
|
220
234
|
function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
221
235
|
const dir = trackRead(root, rel, now);
|
|
222
236
|
return dir === null ? null : buildFanoutHint(root, dir);
|
|
@@ -263,8 +277,8 @@ if (require.main === module) {
|
|
|
263
277
|
module.exports = {
|
|
264
278
|
isSourceFile, dirOf, cwdHash, statePath,
|
|
265
279
|
loadState, saveState, recordRead, shouldHint, markHint,
|
|
266
|
-
|
|
280
|
+
buildHintWithAnswer, isSilenced, isAnswerDisabled,
|
|
267
281
|
trackReadAndMaybeHint, // v0.49 — shared with pre-grep-guide's sed-range path
|
|
268
282
|
trackRead, buildFanoutHint, // the sed path's two halves: one envelope for every dir that fired
|
|
269
|
-
FANOUT_THRESHOLD,
|
|
283
|
+
FANOUT_THRESHOLD, STATE_TTL_MS, SRC_EXT,
|
|
270
284
|
};
|
|
@@ -124,4 +124,26 @@ function resolveProjectRoot(startDir, opts = {}) {
|
|
|
124
124
|
return null;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
// True while the MCP server's startup index is still being written for `root`.
|
|
128
|
+
// The server writes `.code-graph/indexing-status.json` ({s, d, t}) and heartbeats
|
|
129
|
+
// it per batch and per finalize phase, then removes it. Until then the index is
|
|
130
|
+
// partial, and a structural answer from it is wrong, not just short: 0.3 s into
|
|
131
|
+
// a cold networkx build `impact edge_betweenness_centrality` said "0 callers,
|
|
132
|
+
// Risk UNKNOWN"; the finished index has 4 callers, MEDIUM (2026-09-29, D5).
|
|
133
|
+
// Same test as statusline.js, whose INDEXING_STALE_MS mirrors the Rust
|
|
134
|
+
// INDEXING_STATUS_STALE_SECS: an older file was left by a killed server.
|
|
135
|
+
const INDEXING_STALE_MS = 120000;
|
|
136
|
+
|
|
137
|
+
function indexBuildInProgress(root, now = Date.now()) {
|
|
138
|
+
if (!root) return false;
|
|
139
|
+
const file = path.join(root, '.code-graph', 'indexing-status.json');
|
|
140
|
+
try {
|
|
141
|
+
if (now - fs.statSync(file).mtimeMs >= INDEXING_STALE_MS) return false;
|
|
142
|
+
const p = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
143
|
+
return (p.s === 'indexing' || p.s === 'finalizing') && p.t > 0;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { resolveProjectRoot, indexBuildInProgress, INDEXING_STALE_MS };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
// Per-session edit log shared by the PreToolUse
|
|
4
|
-
//
|
|
3
|
+
// Per-session edit log shared by the PreToolUse hooks (writers: pre-edit-guide
|
|
4
|
+
// for Edit/Write, pre-grep-guide for `sed -i`/`perl -pi`) and the
|
|
5
|
+
// Stop hook (reader) — P1 #3, Q1.
|
|
5
6
|
//
|
|
6
7
|
// Two files per (project, session), both in the shared cgTmpDir() so the
|
|
7
8
|
// SessionStart prune (24 h, mtime) and `uninstall`'s wholesale removal of that
|
|
@@ -9,7 +10,9 @@
|
|
|
9
10
|
//
|
|
10
11
|
// .cg-edits-<cwdHash>-<sid>.jsonl one line per Edit call: {ts, file, symbol[, sigs]}
|
|
11
12
|
// .cg-stop-<cwdHash>-<sid>.json written by the Stop hook only:
|
|
12
|
-
// {lastStopAt, reported: ["file#symbol", …]
|
|
13
|
+
// {lastStopAt, reported: ["file#symbol", …],
|
|
14
|
+
// pending?: {at, files: […]}} — the last
|
|
15
|
+
// report's caller files, until its follow-up
|
|
13
16
|
//
|
|
14
17
|
// The edit log is APPEND-only: parallel Edit calls each run their own hook
|
|
15
18
|
// process, and a read-modify-write JSON file would drop one of two concurrent
|
|
@@ -82,6 +85,59 @@ function recordEdit(root, sessionId, { file, symbol = null }, now = Date.now())
|
|
|
82
85
|
} catch { return false; }
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
// Q1: an edit that names no definition (Write over an existing file, `sed -i`,
|
|
89
|
+
// `perl -pi`) baselines the file's definitions at once. Bounded so the hook
|
|
90
|
+
// stays inside its budget: a larger file is logged as a file only, and at
|
|
91
|
+
// most this many definitions — the first in the file — get a record.
|
|
92
|
+
const MAX_FILE_SCAN_BYTES = 256 * 1024;
|
|
93
|
+
const MAX_FILE_DEFINITIONS = 64;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Log an edit that names no definition: one file record, then one baselined
|
|
97
|
+
* record per definition of the file as it is now (before the edit). With
|
|
98
|
+
* `newText` (a Write's content) only definitions whose header the new text
|
|
99
|
+
* changes are recorded — the Stop hook would find the rest unchanged. Without
|
|
100
|
+
* it (`sed -i`: the result is unknown until the command runs) every
|
|
101
|
+
* definition is, and the Stop hook sorts them out. One append, best-effort.
|
|
102
|
+
* @param {string} root project root
|
|
103
|
+
* @param {string} sessionId
|
|
104
|
+
* @param {string} file root-relative
|
|
105
|
+
* @param {{newText?: string|null}} [opts]
|
|
106
|
+
* @returns {number} records written
|
|
107
|
+
*/
|
|
108
|
+
function recordFileEdit(root, sessionId, file, { newText = null } = {}, now = Date.now()) {
|
|
109
|
+
const p = editsPath(root, sessionId);
|
|
110
|
+
if (!p || !file) return 0;
|
|
111
|
+
const recs = [{ ts: now, file, symbol: null }];
|
|
112
|
+
for (const [symbol, sigs] of fileBaselines(root, file, newText)) recs.push({ ts: now, file, symbol, sigs });
|
|
113
|
+
try {
|
|
114
|
+
fs.appendFileSync(p, recs.map((r) => JSON.stringify(r) + '\n').join(''));
|
|
115
|
+
return recs.length;
|
|
116
|
+
} catch { return 0; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fileBaselines(root, file, newText) {
|
|
120
|
+
try {
|
|
121
|
+
const abs = path.join(root, file);
|
|
122
|
+
if (fs.statSync(abs).size > MAX_FILE_SCAN_BYTES) return [];
|
|
123
|
+
const { allSignatures, extractSignatures, signatureChanged } = require('./stop-impact');
|
|
124
|
+
const ext = path.extname(file).toLowerCase();
|
|
125
|
+
const all = allSignatures(fs.readFileSync(abs, 'utf8'), ext);
|
|
126
|
+
if (!all) return [];
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const [symbol, sigs] of all) {
|
|
129
|
+
if (out.length === MAX_FILE_DEFINITIONS) break;
|
|
130
|
+
if (sigs.length > MAX_BASELINE_SIGS) continue;
|
|
131
|
+
if (typeof newText === 'string') {
|
|
132
|
+
const after = extractSignatures(newText, symbol, ext);
|
|
133
|
+
if (!after || after.length === 0 || !signatureChanged(sigs, after, symbol)) continue;
|
|
134
|
+
}
|
|
135
|
+
out.push([symbol, sigs]);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
} catch { return []; }
|
|
139
|
+
}
|
|
140
|
+
|
|
85
141
|
/** Every well-formed record in the session log; a torn or foreign line is skipped. */
|
|
86
142
|
function readEdits(root, sessionId) {
|
|
87
143
|
const p = editsPath(root, sessionId);
|
|
@@ -108,10 +164,15 @@ function readStopState(root, sessionId) {
|
|
|
108
164
|
if (!p) return empty;
|
|
109
165
|
try {
|
|
110
166
|
const s = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
111
|
-
|
|
167
|
+
const out = {
|
|
112
168
|
lastStopAt: typeof s.lastStopAt === 'number' ? s.lastStopAt : null,
|
|
113
169
|
reported: Array.isArray(s.reported) ? s.reported.filter((x) => typeof x === 'string') : [],
|
|
114
170
|
};
|
|
171
|
+
const p2 = s.pending;
|
|
172
|
+
if (p2 && typeof p2.at === 'number' && Array.isArray(p2.files)) {
|
|
173
|
+
out.pending = { at: p2.at, files: p2.files.filter((x) => typeof x === 'string') };
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
115
176
|
} catch { return empty; }
|
|
116
177
|
}
|
|
117
178
|
|
|
@@ -130,5 +191,6 @@ function writeStopState(root, sessionId, state) {
|
|
|
130
191
|
}
|
|
131
192
|
|
|
132
193
|
module.exports = {
|
|
133
|
-
sessionKey, editsPath, stopStatePath, recordEdit, readEdits, readStopState, writeStopState,
|
|
194
|
+
sessionKey, editsPath, stopStatePath, recordEdit, recordFileEdit, readEdits, readStopState, writeStopState,
|
|
195
|
+
MAX_FILE_DEFINITIONS,
|
|
134
196
|
};
|