@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.
@@ -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, the language has no exact header reading (LANGS),
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 lineStarts = new Set();
145
- for (const { re, typed } of definitionPatterns(escapeRe(symbol), lang)) {
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
- lineStarts.add(at);
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) return true;
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
- if (!signatureChanged(e.sigs, after)) continue; // body-only this turn
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
- return { lines, state: next };
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.stop_hook_active === true) return;
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: (file) => {
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
- function ctxFlagPath(type, cwd) {
60
- return path.join(cgTmpDir(), `.code-graph-ctx-${cwdHash(cwd)}-${type}`);
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
- if ((intents.impact || intents.modify) && hasStrict && !cd('impact')) return { type: 'impact', symbol: symbols.symbols[0] };
401
- if (intents.callgraph && hasStrict && !cd('callgraph')) return { type: 'callgraph', symbol: symbols.symbols[0] };
402
- if (filePaths.length > 0 && !cd('overview')) return { type: 'overview', path: filePaths[0].replace(/\/[^/]+$/, '/') };
403
- if ((intents.search || intents.implement || hasQualified) && symbols.symbols.length > 0 && !cd('search')) return { type: 'search', symbol: symbols.symbols[0] };
404
- if ((intents.understand || !hasAny) && symbols.symbols.length > 0 && !cd('search')) return { type: 'search', symbol: symbols.symbols[0] };
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 `semantic_code_search "<symptom>"` ' +
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
- process.stdout.write(`${PREFIXES[query.type]}\n${result.trim()}\n`);
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 efficiently using the AST index. Use BEFORE reading
5
- files one by one — when starting work in unfamiliar code, exploring a module
6
- before changes, or finding the right file to edit. One overview call replaces
7
- 5+ Read calls and saves significant context.
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 edit | `code-graph-mcp impact <symbol>` | Grep for callers |
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.162.0 code-graph-mcp snapshot create --out snapshot.db
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