@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
|
@@ -25,7 +25,9 @@ if (require.main === module) require('./hook-fail-open').installHookFailOpen('St
|
|
|
25
25
|
//
|
|
26
26
|
// Silent when: `stop_hook_active` (a continuation this or another Stop hook
|
|
27
27
|
// caused — never loop), no session edit log, no index, not a git work tree,
|
|
28
|
-
// no signature changed
|
|
28
|
+
// no signature changed (optional parameters appended at the end of a list do
|
|
29
|
+
// not count: every existing call stays valid), the language has no exact
|
|
30
|
+
// header reading (LANGS),
|
|
29
31
|
// every caller's file was touched this turn, or the symbol was already
|
|
30
32
|
// reported this session. CODE_GRAPH_QUIET_HOOKS=1 silences it like the other
|
|
31
33
|
// injecting hooks.
|
|
@@ -141,8 +143,54 @@ function extractSignatures(text, symbol, ext = '') {
|
|
|
141
143
|
const lang = LANGS.get(String(ext).toLowerCase());
|
|
142
144
|
if (!lang) return null;
|
|
143
145
|
if (typeof text !== 'string' || !symbol) return [];
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
+
const starts = definitionStarts(text, lang, escapeRe(symbol), symbol);
|
|
147
|
+
if (starts === null) return null;
|
|
148
|
+
return headersAt(text, starts.get(symbol) || [], lang);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The name slot of definitionPatterns when every definition is wanted.
|
|
152
|
+
const ANY_NAME = '([A-Za-z_$][\\w$]*)';
|
|
153
|
+
// Words the name slot must not take: the control words that open a
|
|
154
|
+
// `word (…) {` line, which the JS one-line-method pattern reads as a method.
|
|
155
|
+
// Not NOT_A_TYPE: `from`, `use`, `match` are ordinary method names (`fn from`).
|
|
156
|
+
const NOT_A_NAME = new Set([
|
|
157
|
+
'if', 'for', 'while', 'switch', 'catch', 'with', 'function', 'foreach', 'elseif',
|
|
158
|
+
'synchronized', 'using', 'lock', 'fixed', 'until', 'unless', 'return', 'await',
|
|
159
|
+
'yield', 'typeof', 'sizeof', 'new', 'super', 'this',
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Every definition in `text` with its signatures (Q1): Map name → sorted keys,
|
|
164
|
+
* in order of first appearance in the file — for an edit that names no
|
|
165
|
+
* definition (Write, `sed -i`). One pass per pattern; each name reads exactly
|
|
166
|
+
* as extractSignatures(text, name) reads it, and a name whose header has no
|
|
167
|
+
* reading is left out. null: the language is not in LANGS, or a definition
|
|
168
|
+
* sits on a line over MAX_DEFINITION_LINE_CHARS (for every name at once: the
|
|
169
|
+
* scan stops there, as extractSignatures does).
|
|
170
|
+
* @returns {Map<string,string[]>|null}
|
|
171
|
+
*/
|
|
172
|
+
function allSignatures(text, ext = '') {
|
|
173
|
+
const lang = LANGS.get(String(ext).toLowerCase());
|
|
174
|
+
if (!lang || typeof text !== 'string') return null;
|
|
175
|
+
const starts = definitionStarts(text, lang, ANY_NAME, null);
|
|
176
|
+
if (starts === null) return null;
|
|
177
|
+
const byFirst = [...starts].sort((a, b) => Math.min(...a[1]) - Math.min(...b[1]));
|
|
178
|
+
const out = new Map();
|
|
179
|
+
for (const [name, lineStarts] of byFirst) {
|
|
180
|
+
const sigs = headersAt(text, lineStarts, lang);
|
|
181
|
+
if (sigs && sigs.length > 0) out.set(name, sigs);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Where definitions start: Map name → Set of line-start offsets. With `symbol`
|
|
188
|
+
* the patterns carry it escaped in `S`; without, `S` is ANY_NAME and the name
|
|
189
|
+
* is the pattern's last group. null when a match sits on an over-long line.
|
|
190
|
+
*/
|
|
191
|
+
function definitionStarts(text, lang, S, symbol) {
|
|
192
|
+
const out = new Map();
|
|
193
|
+
for (const { re, typed } of definitionPatterns(S, lang)) {
|
|
146
194
|
for (const m of text.matchAll(re)) {
|
|
147
195
|
const at = text.lastIndexOf('\n', m.index) + 1;
|
|
148
196
|
const lineEnd = text.indexOf('\n', at);
|
|
@@ -158,9 +206,16 @@ function extractSignatures(text, symbol, ext = '') {
|
|
|
158
206
|
// `:` is a label or access specifier, never part of a return type.
|
|
159
207
|
if (m[1].split(/[ \t]+/).some((tok) => /(?:^|[^:]):$/.test(tok))) continue;
|
|
160
208
|
}
|
|
161
|
-
|
|
209
|
+
const name = symbol || m[m.length - 1];
|
|
210
|
+
if (!symbol && NOT_A_NAME.has(name)) continue;
|
|
211
|
+
if (!out.has(name)) out.set(name, new Set());
|
|
212
|
+
out.get(name).add(at);
|
|
162
213
|
}
|
|
163
214
|
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function headersAt(text, lineStarts, lang) {
|
|
164
219
|
const out = [];
|
|
165
220
|
for (const at of lineStarts) {
|
|
166
221
|
const header = readHeader(text, at, lang);
|
|
@@ -212,15 +267,120 @@ function signatureKey(header) {
|
|
|
212
267
|
* True when a definition that existed before is gone: its header changed, or
|
|
213
268
|
* it was removed while others of the same name remain. A definition ADDED
|
|
214
269
|
* beside unchanged ones (a new overload, a new `impl From<B>`) changes no
|
|
215
|
-
* existing caller's target, so it is not a change (review M2).
|
|
270
|
+
* existing caller's target, so it is not a change (review M2). Nor is a header
|
|
271
|
+
* whose only difference is optional parameters appended to its parameter list
|
|
272
|
+
* (Q2, see appendsOptionalParams) — every existing call stays valid. That
|
|
273
|
+
* reading needs `symbol` to find the parameter list; without it every
|
|
274
|
+
* difference counts.
|
|
216
275
|
*/
|
|
217
|
-
function signatureChanged(oldSigs, newSigs) {
|
|
276
|
+
function signatureChanged(oldSigs, newSigs, symbol = null) {
|
|
218
277
|
const remaining = new Map();
|
|
219
278
|
for (const s of newSigs) remaining.set(s, (remaining.get(s) || 0) + 1);
|
|
279
|
+
const unmatched = [];
|
|
220
280
|
for (const s of oldSigs) {
|
|
221
281
|
const n = remaining.get(s) || 0;
|
|
222
|
-
if (n === 0)
|
|
223
|
-
remaining.set(s, n - 1);
|
|
282
|
+
if (n === 0) unmatched.push(s);
|
|
283
|
+
else remaining.set(s, n - 1);
|
|
284
|
+
}
|
|
285
|
+
if (unmatched.length === 0) return false;
|
|
286
|
+
if (!symbol) return true;
|
|
287
|
+
// Each old header may pair with one new header that only extends it.
|
|
288
|
+
const pool = [];
|
|
289
|
+
for (const [s, n] of remaining) for (let k = 0; k < n; k++) pool.push(s);
|
|
290
|
+
for (const s of unmatched) {
|
|
291
|
+
const at = pool.findIndex((t) => appendsOptionalParams(s, t, symbol));
|
|
292
|
+
if (at === -1) return true;
|
|
293
|
+
pool.splice(at, 1);
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* True when signature key `newKey` is `oldKey` with parameters inserted just
|
|
300
|
+
* before the `)` that closes `symbol`'s parameter list, and every inserted
|
|
301
|
+
* parameter is optional at a call site (isOptionalParam). Anything else that
|
|
302
|
+
* moved — a return type, a parameter before the end, a parameter list inside
|
|
303
|
+
* a type — makes it false. Keys are whitespace-free (signatureKey).
|
|
304
|
+
*/
|
|
305
|
+
function appendsOptionalParams(oldKey, newKey, symbol) {
|
|
306
|
+
if (newKey.length <= oldKey.length) return false;
|
|
307
|
+
let i = 0;
|
|
308
|
+
while (i < oldKey.length && oldKey[i] === newKey[i]) i++;
|
|
309
|
+
if (oldKey[i] !== ')') return false;
|
|
310
|
+
const tail = oldKey.slice(i);
|
|
311
|
+
if (!newKey.endsWith(tail)) return false;
|
|
312
|
+
// The `(` this `)` closes.
|
|
313
|
+
let open = -1;
|
|
314
|
+
for (let j = i - 1, depth = 0; j >= 0; j--) {
|
|
315
|
+
if (oldKey[j] === ')') depth++;
|
|
316
|
+
else if (oldKey[j] === '(') {
|
|
317
|
+
if (depth === 0) { open = j; break; }
|
|
318
|
+
depth--;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (open === -1) return false;
|
|
322
|
+
// A parameter list opens right after the name, its generics, or — for a JS
|
|
323
|
+
// binding — `name = [async] [function]`. A `(` after `->`, `:` or `)` opens
|
|
324
|
+
// a return type or a Go receiver/result list, never the parameters.
|
|
325
|
+
const S = escapeRe(symbol);
|
|
326
|
+
const opener = new RegExp(`${S}(?:<.*>|\\[.*\\])?(?:=(?:async)?(?:function\\*?)?)?$`);
|
|
327
|
+
if (!opener.test(oldKey.slice(0, open))) return false;
|
|
328
|
+
let inserted = newKey.slice(i, newKey.length - tail.length);
|
|
329
|
+
if (open < i - 1) {
|
|
330
|
+
if (inserted[0] !== ',') return false;
|
|
331
|
+
inserted = inserted.slice(1);
|
|
332
|
+
}
|
|
333
|
+
const params = splitTopLevel(inserted);
|
|
334
|
+
return params.length > 0 && params.every(isOptionalParam);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// +1 for an opening bracket at s[i], -1 for a closing one, else 0. The `>` of
|
|
338
|
+
// `=>` / `->` closes nothing.
|
|
339
|
+
function bracketStep(s, i) {
|
|
340
|
+
const c = s[i];
|
|
341
|
+
if ('([{<'.includes(c)) return 1;
|
|
342
|
+
if (')]}'.includes(c) || (c === '>' && s[i - 1] !== '=' && s[i - 1] !== '-')) return -1;
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Splits at commas outside brackets.
|
|
347
|
+
function splitTopLevel(s) {
|
|
348
|
+
const out = [];
|
|
349
|
+
let depth = 0;
|
|
350
|
+
let start = 0;
|
|
351
|
+
for (let i = 0; i < s.length; i++) {
|
|
352
|
+
const step = bracketStep(s, i);
|
|
353
|
+
if (step !== 0) depth = Math.max(0, depth + step);
|
|
354
|
+
else if (s[i] === ',' && depth === 0) {
|
|
355
|
+
out.push(s.slice(start, i));
|
|
356
|
+
start = i + 1;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
out.push(s.slice(start));
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* A parameter a caller may leave out: a default value (`x = 1`, `int y = 0`,
|
|
365
|
+
* `strict: bool = False`), TS `x?: T`, a variadic (`*args`, `**kwargs`, a bare
|
|
366
|
+
* `*` marker, `...rest`, Go `opts ...T`, Java `T... xs`). Whitespace is
|
|
367
|
+
* already gone from the key.
|
|
368
|
+
*/
|
|
369
|
+
function isOptionalParam(p) {
|
|
370
|
+
if (!p) return false;
|
|
371
|
+
if (p[0] === '*') return true;
|
|
372
|
+
let depth = 0;
|
|
373
|
+
for (let i = 0; i < p.length; i++) {
|
|
374
|
+
const c = p[i];
|
|
375
|
+
const step = bracketStep(p, i);
|
|
376
|
+
if (step !== 0) { depth = Math.max(0, depth + step); continue; }
|
|
377
|
+
if (depth !== 0) continue;
|
|
378
|
+
if (c === '.' && p.startsWith('...', i)) return true;
|
|
379
|
+
if (c === '?' && (p[i + 1] === ':' || i === p.length - 1)) return true;
|
|
380
|
+
// The first depth-0 `=` is the default, unless it starts `=>` (an arrow
|
|
381
|
+
// type). No `==`/`>=` can come earlier; `number>=new Map()` is a generic's
|
|
382
|
+
// `>` then a default.
|
|
383
|
+
if (c === '=' && p[i + 1] !== '>') return true;
|
|
224
384
|
}
|
|
225
385
|
return false;
|
|
226
386
|
}
|
|
@@ -286,11 +446,13 @@ function computeStopReport({ edits, state, now, workText, callers, mtimeMs }) {
|
|
|
286
446
|
if (!Array.isArray(e.sigs)) continue;
|
|
287
447
|
const after = extractSignatures(workText(e.file), e.symbol, path.extname(e.file).toLowerCase());
|
|
288
448
|
if (!after || after.length === 0) continue; // removed, or no reading now
|
|
289
|
-
|
|
449
|
+
// Body-only this turn, or only optional parameters appended (Q2).
|
|
450
|
+
if (!signatureChanged(e.sigs, after, e.symbol)) continue;
|
|
290
451
|
changed.push({ key, file: e.file, symbol: e.symbol });
|
|
291
452
|
}
|
|
292
453
|
|
|
293
454
|
const lines = [];
|
|
455
|
+
const listed = new Set();
|
|
294
456
|
for (const c of changed.slice(0, MAX_SYMBOLS_CHECKED)) {
|
|
295
457
|
const refs = callers(c.symbol, c.file);
|
|
296
458
|
if (!refs || refs.length === 0) continue;
|
|
@@ -318,6 +480,7 @@ function computeStopReport({ edits, state, now, workText, callers, mtimeMs }) {
|
|
|
318
480
|
}
|
|
319
481
|
lines.push(line);
|
|
320
482
|
next.reported.push(c.key);
|
|
483
|
+
for (const r of untouched) listed.add(r.file);
|
|
321
484
|
}
|
|
322
485
|
// Changed beyond the query cap: named, not dropped in silence (review M1).
|
|
323
486
|
// Not marked reported, and a later turn does not see them as changed again
|
|
@@ -327,7 +490,54 @@ function computeStopReport({ edits, state, now, workText, callers, mtimeMs }) {
|
|
|
327
490
|
lines.push(' ' + unchecked.length + ' more changed, callers not checked: ' +
|
|
328
491
|
unchecked.map((c) => shellQuoteArg(c.symbol) + '() in ' + shellQuoteArg(c.file)).join(', '));
|
|
329
492
|
}
|
|
330
|
-
|
|
493
|
+
// `symbols` / `files`: what the report names, for its telemetry and its
|
|
494
|
+
// follow-up (D#163). Not part of `state`.
|
|
495
|
+
return { lines, state: next, symbols: next.reported.length - state.reported.length, files: [...listed].sort() };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The follow-up remembers at most this many of a report's caller files.
|
|
499
|
+
const MAX_FOLLOWUP_FILES = 64;
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* What a report leaves behind (D#163): the `stop_check` record — for every
|
|
503
|
+
* report the model is shown, the one made only of the "more changed, callers
|
|
504
|
+
* not checked" line included — and the follow-up to judge at the next Stop,
|
|
505
|
+
* only when the report named caller files. `callers` counts every caller file
|
|
506
|
+
* the report found, the ones past the text's per-symbol cap included.
|
|
507
|
+
* @param {{lines:string[], state:{lastStopAt:number}, symbols?:number, files?:string[]}} report
|
|
508
|
+
* @returns {{check: object|null, pending: {at:number, files:string[]}|null}}
|
|
509
|
+
*/
|
|
510
|
+
function reportRecords(report) {
|
|
511
|
+
if (!report.lines.length) return { check: null, pending: null };
|
|
512
|
+
const files = report.files || [];
|
|
513
|
+
return {
|
|
514
|
+
check: { hook: 'stop', action: 'stop_check', symbols: report.symbols || 0, callers: files.length },
|
|
515
|
+
pending: files.length ? { at: report.state.lastStopAt, files: files.slice(0, MAX_FOLLOWUP_FILES) } : null,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Whether a report was acted on: a caller file it listed was edited after it —
|
|
521
|
+
* an Edit logged since `pending.at`, or an mtime at or after it (Write, `sed
|
|
522
|
+
* -i`, formatters; the same reading as "touched this turn"). Judged at the
|
|
523
|
+
* next Stop, which is the end of the continuation the feedback starts.
|
|
524
|
+
* @param {{at:number, files:string[]}} pending left in the Stop state by the report
|
|
525
|
+
* @param {{ts:number, file:string}[]} edits the session's edit log
|
|
526
|
+
* @param {(file:string)=>number|null} mtimeMs
|
|
527
|
+
* @returns {{adopted:boolean, listed:number, edited:number}}
|
|
528
|
+
*/
|
|
529
|
+
function followUpOf(pending, edits, mtimeMs) {
|
|
530
|
+
const listed = new Set(pending.files);
|
|
531
|
+
const edited = new Set();
|
|
532
|
+
for (const e of edits) {
|
|
533
|
+
if (e.ts >= pending.at && listed.has(e.file)) edited.add(e.file);
|
|
534
|
+
}
|
|
535
|
+
for (const f of listed) {
|
|
536
|
+
if (edited.has(f)) continue;
|
|
537
|
+
const m = mtimeMs(f);
|
|
538
|
+
if (m !== null && m >= pending.at) edited.add(f);
|
|
539
|
+
}
|
|
540
|
+
return { adopted: edited.size > 0, listed: listed.size, edited: edited.size };
|
|
331
541
|
}
|
|
332
542
|
|
|
333
543
|
function formatStopContext(lines) {
|
|
@@ -350,17 +560,32 @@ function readStdinJson() {
|
|
|
350
560
|
function runMain() {
|
|
351
561
|
if (process.env.CODE_GRAPH_QUIET_HOOKS === '1') return;
|
|
352
562
|
const input = readStdinJson();
|
|
353
|
-
if (!input || input.
|
|
354
|
-
if (!input.session_id) return;
|
|
563
|
+
if (!input || !input.session_id) return;
|
|
355
564
|
|
|
356
565
|
const { resolveProjectRoot } = require('./project-root');
|
|
357
566
|
const root = resolveProjectRoot(typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd());
|
|
358
567
|
if (root === null) return;
|
|
359
568
|
|
|
360
569
|
const sessionEdits = require('./session-edits');
|
|
570
|
+
const { recordRecommendation } = require('./recommendation-log');
|
|
571
|
+
const mtimeMs = (file) => {
|
|
572
|
+
try { return fs.statSync(path.join(root, file)).mtimeMs; } catch { return null; }
|
|
573
|
+
};
|
|
574
|
+
const state = sessionEdits.readStopState(root, input.session_id);
|
|
575
|
+
// The previous report's follow-up (D#163), at every Stop — the one ending
|
|
576
|
+
// the continuation that report started included. It only records: nothing
|
|
577
|
+
// is emitted, so a continuation cannot loop through it.
|
|
578
|
+
if (state.pending) {
|
|
579
|
+
const f = followUpOf(state.pending, sessionEdits.readEdits(root, input.session_id), mtimeMs);
|
|
580
|
+
recordRecommendation(root, { hook: 'stop', action: 'stop_followup', ...f });
|
|
581
|
+
delete state.pending;
|
|
582
|
+
sessionEdits.writeStopState(root, input.session_id, state);
|
|
583
|
+
}
|
|
584
|
+
// A continuation caused by a Stop hook is never re-checked.
|
|
585
|
+
if (input.stop_hook_active === true) return;
|
|
586
|
+
|
|
361
587
|
const edits = sessionEdits.readEdits(root, input.session_id);
|
|
362
588
|
if (edits.length === 0) return; // no log → nothing to check, and nothing written
|
|
363
|
-
const state = sessionEdits.readStopState(root, input.session_id);
|
|
364
589
|
|
|
365
590
|
const { remainingMs } = require('./hook-fail-open');
|
|
366
591
|
const run = (cmd, args, defaultMs) => {
|
|
@@ -410,11 +635,12 @@ function runMain() {
|
|
|
410
635
|
return { file: r.file_path, name: r.name, line: findCallSiteLine(text, symbol, r.start_line) };
|
|
411
636
|
});
|
|
412
637
|
},
|
|
413
|
-
mtimeMs
|
|
414
|
-
try { return fs.statSync(path.join(root, file)).mtimeMs; } catch { return null; }
|
|
415
|
-
},
|
|
638
|
+
mtimeMs,
|
|
416
639
|
});
|
|
417
640
|
}
|
|
641
|
+
const rec = reportRecords(report);
|
|
642
|
+
if (rec.check) recordRecommendation(root, rec.check);
|
|
643
|
+
if (rec.pending) report.state.pending = rec.pending;
|
|
418
644
|
sessionEdits.writeStopState(root, input.session_id, report.state);
|
|
419
645
|
|
|
420
646
|
const text = formatStopContext(report.lines);
|
|
@@ -426,6 +652,6 @@ function runMain() {
|
|
|
426
652
|
if (require.main === module) runMain();
|
|
427
653
|
|
|
428
654
|
module.exports = {
|
|
429
|
-
extractSignatures, signatureChanged, findCallSiteLine, computeStopReport, formatStopContext,
|
|
430
|
-
MAX_SYMBOLS_CHECKED, MAX_CALLERS_LISTED,
|
|
655
|
+
extractSignatures, allSignatures, signatureChanged, findCallSiteLine, computeStopReport, formatStopContext,
|
|
656
|
+
followUpOf, reportRecords, MAX_SYMBOLS_CHECKED, MAX_CALLERS_LISTED, MAX_FOLLOWUP_FILES,
|
|
431
657
|
};
|
|
@@ -88,6 +88,14 @@ function runMain() {
|
|
|
88
88
|
if (!text) return;
|
|
89
89
|
const { emitEventContext } = require('./hook-emit');
|
|
90
90
|
process.stdout.write(emitEventContext('SubagentStart', text) + '\n');
|
|
91
|
+
// D#163: how many subagents were handed the facts. Whether they then used
|
|
92
|
+
// the index is in their transcripts, not here (scripts/subagent_share.py).
|
|
93
|
+
// The agent type is harness input; only a plain name is kept.
|
|
94
|
+
const agent = typeof input.agent_type === 'string' && /^[\w:.-]{1,64}$/.test(input.agent_type)
|
|
95
|
+
? input.agent_type : null;
|
|
96
|
+
require('./recommendation-log').recordRecommendation(root, {
|
|
97
|
+
hook: 'subagent', action: 'subagent_context', ...(agent ? { agent } : {}),
|
|
98
|
+
});
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
if (require.main === module) runMain();
|
|
@@ -42,6 +42,7 @@ const { hidden } = require('./proc-opts');
|
|
|
42
42
|
// must not load (startup budget); it used to be a third hand-spelled copy of the
|
|
43
43
|
// same three path segments (JS-05).
|
|
44
44
|
const { MANIFEST_FILE: MANIFEST_PATH } = require('./cache-paths');
|
|
45
|
+
const { capContext } = require('./hook-emit');
|
|
45
46
|
|
|
46
47
|
// --- Per-type rate limiting (replaces single global cooldown) ---
|
|
47
48
|
const COOLDOWNS = {
|
|
@@ -56,20 +57,26 @@ const COOLDOWNS = {
|
|
|
56
57
|
// here, so an un-scoped flag was effectively a machine-wide mute: one `impact`
|
|
57
58
|
// push in any repo silenced the next 30s of impact pushes in every other repo,
|
|
58
59
|
// and `overview` did it for five minutes.
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
//
|
|
61
|
+
// Keyed on (type, subject) when there is a subject — the symbol or path the
|
|
62
|
+
// query is about. A type-only key let an impact push for one symbol swallow
|
|
63
|
+
// the impact query for a DIFFERENT symbol asked a second later (hook audit
|
|
64
|
+
// 2026-09-28). The subject is hashed: symbols and paths are not file-name safe.
|
|
65
|
+
function ctxFlagPath(type, cwd, key) {
|
|
66
|
+
const suffix = key ? `-${cwdHash(String(key)).slice(0, 8)}` : '';
|
|
67
|
+
return path.join(cgTmpDir(), `.code-graph-ctx-${cwdHash(cwd)}-${type}${suffix}`);
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
function isCoolingDown(type, cwd = process.cwd()) {
|
|
70
|
+
function isCoolingDown(type, cwd = process.cwd(), key) {
|
|
64
71
|
try {
|
|
65
|
-
const stat = fs.statSync(ctxFlagPath(type, cwd));
|
|
72
|
+
const stat = fs.statSync(ctxFlagPath(type, cwd, key));
|
|
66
73
|
return Date.now() - stat.mtimeMs < (COOLDOWNS[type] || 60000);
|
|
67
74
|
} catch { return false; }
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
function markCooldown(type, cwd = process.cwd()) {
|
|
77
|
+
function markCooldown(type, cwd = process.cwd(), key) {
|
|
71
78
|
try {
|
|
72
|
-
fs.writeFileSync(ctxFlagPath(type, cwd), '');
|
|
79
|
+
fs.writeFileSync(ctxFlagPath(type, cwd, key), '');
|
|
73
80
|
} catch { /* ok */ }
|
|
74
81
|
}
|
|
75
82
|
|
|
@@ -111,8 +118,14 @@ const STOP_WORDS = new Set([
|
|
|
111
118
|
|
|
112
119
|
const PLAIN_WORD_EXCLUDE = /^(possible|together|actually|something|different|important|following|available|necessary|currently|implement|operation|otherwise|beginning|knowledge|attention|according|certainly|sometimes|direction|recommend|structure|describe|question|complete|generate|anything|continue|consider|response|approach|happened|recently|probably|expected|previous|original|specific|directly|received|required|supposed|separate|designed|finished|provided|included|prepared|combined|properly|remember|whatever|although|document|handling|existing|everyone|standard|research|personal|relative|absolute|practice|language|thousand|national|evidence|refactor|understand|validate|analysis|debugging|configure|improving|resolving|creating|building|checking|updating|removing|changing|searching|cleaning|optimize|migration|overview|introduce|reviewing|thinking|managing|starting|yourself|features|problems|breaking|requires|argument|settings|includes|examples|comments|patterns|tutorial|concepts|supports|priority|organize|scenario|tracking|internal|external|abstract|concrete|strategy|evaluate|diagnose|platform|variable|optional|multiple)$/;
|
|
113
120
|
|
|
121
|
+
// Text the harness submits on the user's behalf: background-task
|
|
122
|
+
// notifications, teammate / agent / cross-session messages, reminders. 42 of
|
|
123
|
+
// 175 real injections (24%) fired on these, answering a question nobody asked.
|
|
124
|
+
const HARNESS_PROMPT = /^(?:<(?:task-notification|teammate-message|agent-message|cross-session-message|system-reminder|local-command-(?:stdout|stderr|caveat)|command-(?:name|message|args)|bash-(?:input|stdout|stderr))\b|Another Claude session sent a message:)/;
|
|
125
|
+
|
|
114
126
|
function shouldSkip(msg) {
|
|
115
127
|
const trimmed = msg.trim();
|
|
128
|
+
if (HARNESS_PROMPT.test(trimmed)) return 'harness';
|
|
116
129
|
if (/^(yes|no|ok|commit|push|y|n|done|thanks|thank you|继续|确认|好的|好|是的|不|可以|行|对|提交|推送|没问题|谢谢|发布|更新|编译|安装|卸载|重启|重连|清理)\s*[.!?。!?]?\s*$/i.test(trimmed)) return 'simple';
|
|
117
130
|
if (/^(修复|实施|执行|开始|按|实测|进入|用|重新)/.test(trimmed) && !/[a-zA-Z_]{3,}/.test(trimmed)) return 'action-only';
|
|
118
131
|
return false;
|
|
@@ -397,11 +410,23 @@ function determineQueryType(intents, symbols, filePaths, isCoolingDownFn, messag
|
|
|
397
410
|
|
|
398
411
|
const cd = isCoolingDownFn || (() => false);
|
|
399
412
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
413
|
+
const sym = symbols.symbols[0];
|
|
414
|
+
// The file the prompt names disambiguates a same-named symbol (`impact
|
|
415
|
+
// url_for` is ambiguous in flask; `--file src/flask/helpers.py` answers).
|
|
416
|
+
const file = filePaths[0];
|
|
417
|
+
|
|
418
|
+
if ((intents.impact || intents.modify) && hasStrict && !cd('impact', sym)) return { type: 'impact', symbol: sym, ...(file ? { file } : {}) };
|
|
419
|
+
if (intents.callgraph && hasStrict && !cd('callgraph', sym)) return { type: 'callgraph', symbol: sym };
|
|
420
|
+
// The named file itself, not its parent dir: `src/context.ts` used to yield
|
|
421
|
+
// an overview of all of src/ — 18,497 chars, none of it about that file.
|
|
422
|
+
if (file && !cd('overview', file)) return { type: 'overview', path: file };
|
|
423
|
+
// Search only for an identifier-shaped name, like impact and callgraph. A
|
|
424
|
+
// plain English word (`lowConfidence`) searched as a concept injected code
|
|
425
|
+
// unrelated to the prompt (`undefined` → JSX attribute types, `networkx` →
|
|
426
|
+
// doc headings; 2026-09-28 usage evaluation B8), and UPS search injections
|
|
427
|
+
// were followed up 2 times in 51.
|
|
428
|
+
if ((intents.search || intents.implement || hasQualified) && hasStrict && !cd('search', sym)) return { type: 'search', symbol: sym };
|
|
429
|
+
if ((intents.understand || !hasAny) && hasStrict && !cd('search', sym)) return { type: 'search', symbol: sym };
|
|
405
430
|
|
|
406
431
|
// Phase E fallback: nothing actionable above, but message has symptom phrasing.
|
|
407
432
|
// Emit ONE-LINE prose hint (no CLI execution). Default-empty `message` keeps
|
|
@@ -501,7 +526,7 @@ function runMain() {
|
|
|
501
526
|
const intents = detectIntents(message);
|
|
502
527
|
// Key the cooldowns on the resolved project root, not the raw shell cwd, so a
|
|
503
528
|
// `cd` into a subdir does not read as a different project and re-fire.
|
|
504
|
-
const query = determineQueryType(intents, symbols, filePaths, (type) => isCoolingDown(type, cwd), message);
|
|
529
|
+
const query = determineQueryType(intents, symbols, filePaths, (type, key) => isCoolingDown(type, cwd, key), message);
|
|
505
530
|
|
|
506
531
|
if (!query) return;
|
|
507
532
|
|
|
@@ -510,12 +535,16 @@ function runMain() {
|
|
|
510
535
|
if (query.type === 'symptom-hint') {
|
|
511
536
|
markCooldown('symptom', cwd);
|
|
512
537
|
process.stdout.write(
|
|
513
|
-
'[code-graph:hint] indexed repo — for vague-symptom prompts, try `
|
|
538
|
+
'[code-graph:hint] indexed repo — for vague-symptom prompts, try `code-graph-mcp search "<symptom words>"` ' +
|
|
514
539
|
'or `module_overview <suspected-dir>` to surface candidate code structurally. Skip if not searching code.\n'
|
|
515
540
|
);
|
|
516
541
|
return;
|
|
517
542
|
}
|
|
518
543
|
|
|
544
|
+
// A partial index answers wrongly, not just briefly (D5). Return before the
|
|
545
|
+
// cooldown so the same question is answered once the build is done.
|
|
546
|
+
if (require('./project-root').indexBuildInProgress(cwd)) return;
|
|
547
|
+
|
|
519
548
|
const PREFIXES = {
|
|
520
549
|
impact: '[code-graph:impact] Blast radius — review before editing:',
|
|
521
550
|
overview: '[code-graph:structure] Module structure:',
|
|
@@ -556,17 +585,20 @@ function runMain() {
|
|
|
556
585
|
// the same shape re-ran it — a 3s execFileSync timeout on EVERY turn, which is
|
|
557
586
|
// the worst case wearing the cheapest disguise. The cooldown's job is rate
|
|
558
587
|
// limiting; whether the run had something to say is a separate question.
|
|
559
|
-
markCooldown(query.type, cwd);
|
|
588
|
+
markCooldown(query.type, cwd, query.symbol || query.path);
|
|
560
589
|
|
|
561
590
|
try {
|
|
562
591
|
let result = '';
|
|
563
|
-
if (query.type === 'impact') result = run(['impact', query.symbol]);
|
|
592
|
+
if (query.type === 'impact') result = run(['impact', query.symbol, ...(query.file ? ['--file', query.file] : [])]);
|
|
564
593
|
else if (query.type === 'callgraph') result = run(['callgraph', query.symbol, '--depth', '2']);
|
|
565
594
|
else if (query.type === 'overview') result = run(['overview', query.path]);
|
|
566
595
|
else if (query.type === 'search') result = run(['search', query.symbol, '--limit', '8']);
|
|
567
596
|
|
|
568
597
|
if (result && result.trim()) {
|
|
569
|
-
|
|
598
|
+
// The shared 4,000-byte context budget. Written raw, a dir overview came
|
|
599
|
+
// back at 18,497 chars; past 10,000 Claude Code swaps the whole output
|
|
600
|
+
// for a file path and a preview.
|
|
601
|
+
process.stdout.write(capContext(`${PREFIXES[query.type]}\n${result.trim()}\n`));
|
|
570
602
|
}
|
|
571
603
|
} catch {
|
|
572
604
|
/* return silently */
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: explore
|
|
3
3
|
description: |
|
|
4
|
-
Understand code structure
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
Understand code structure with the AST index before reading files one by
|
|
5
|
+
one — when starting work in unfamiliar code, exploring a module before a
|
|
6
|
+
change, or finding which file to edit. An overview lists a directory's
|
|
7
|
+
symbols and their callers in one call.
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Explore Code (indexed project)
|
|
@@ -16,7 +16,10 @@ Use these BEFORE reading individual files:
|
|
|
16
16
|
| Module structure | `code-graph-mcp overview <dir>` | 5+ Read calls |
|
|
17
17
|
| Project architecture | `code-graph-mcp map --compact` | ls + README |
|
|
18
18
|
| Who calls / what calls | `code-graph-mcp callgraph <symbol>` | Grep + manual trace |
|
|
19
|
-
| Find by concept | `code-graph-mcp search "concept"` | 3+ Grep attempts |
|
|
20
|
-
| Impact before
|
|
19
|
+
| Find by concept | `code-graph-mcp search "concept"` (full-text) | 3+ Grep attempts |
|
|
20
|
+
| Impact before a signature change | `code-graph-mcp impact <symbol>` | Grep for callers |
|
|
21
|
+
| Every use (rename / remove) | `code-graph-mcp refs <symbol>`, then `grep -w` | Grep alone |
|
|
21
22
|
|
|
22
|
-
**Workflow**: overview first → Read only the file you will edit.
|
|
23
|
+
**Workflow**: overview first → Read only the file you will edit. Calls the graph could
|
|
24
|
+
not resolve (dynamic dispatch, reflection, unresolved imports) are not in it, so confirm
|
|
25
|
+
an empty answer with grep.
|
|
@@ -35,7 +35,7 @@ jobs:
|
|
|
35
35
|
node-version: '20'
|
|
36
36
|
- name: Build snapshot
|
|
37
37
|
run: |
|
|
38
|
-
npx -y -p @sdsrs/code-graph@0.
|
|
38
|
+
npx -y -p @sdsrs/code-graph@0.164.0 code-graph-mcp snapshot create --out snapshot.db
|
|
39
39
|
zstd -9 snapshot.db -o snapshot.db.zst
|
|
40
40
|
mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
|
|
41
41
|
- name: Upload to release
|