@colbymchenry/codegraph-darwin-x64 1.0.0 → 1.1.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.
Files changed (175) hide show
  1. package/lib/dist/bin/codegraph.js +258 -17
  2. package/lib/dist/bin/codegraph.js.map +1 -1
  3. package/lib/dist/bin/fatal-handler.d.ts +20 -0
  4. package/lib/dist/bin/fatal-handler.d.ts.map +1 -0
  5. package/lib/dist/bin/fatal-handler.js +118 -0
  6. package/lib/dist/bin/fatal-handler.js.map +1 -0
  7. package/lib/dist/db/index.d.ts +22 -1
  8. package/lib/dist/db/index.d.ts.map +1 -1
  9. package/lib/dist/db/index.js +46 -1
  10. package/lib/dist/db/index.js.map +1 -1
  11. package/lib/dist/db/queries.d.ts +14 -0
  12. package/lib/dist/db/queries.d.ts.map +1 -1
  13. package/lib/dist/db/queries.js +25 -0
  14. package/lib/dist/db/queries.js.map +1 -1
  15. package/lib/dist/directory.d.ts +58 -0
  16. package/lib/dist/directory.d.ts.map +1 -1
  17. package/lib/dist/directory.js +165 -0
  18. package/lib/dist/directory.js.map +1 -1
  19. package/lib/dist/extraction/grammars.d.ts +11 -3
  20. package/lib/dist/extraction/grammars.d.ts.map +1 -1
  21. package/lib/dist/extraction/grammars.js +14 -5
  22. package/lib/dist/extraction/grammars.js.map +1 -1
  23. package/lib/dist/extraction/index.d.ts.map +1 -1
  24. package/lib/dist/extraction/index.js +202 -32
  25. package/lib/dist/extraction/index.js.map +1 -1
  26. package/lib/dist/extraction/languages/c-cpp.d.ts.map +1 -1
  27. package/lib/dist/extraction/languages/c-cpp.js +47 -2
  28. package/lib/dist/extraction/languages/c-cpp.js.map +1 -1
  29. package/lib/dist/extraction/languages/csharp.d.ts.map +1 -1
  30. package/lib/dist/extraction/languages/csharp.js +20 -0
  31. package/lib/dist/extraction/languages/csharp.js.map +1 -1
  32. package/lib/dist/extraction/languages/dart.d.ts.map +1 -1
  33. package/lib/dist/extraction/languages/dart.js +22 -0
  34. package/lib/dist/extraction/languages/dart.js.map +1 -1
  35. package/lib/dist/extraction/languages/java.d.ts.map +1 -1
  36. package/lib/dist/extraction/languages/java.js +213 -9
  37. package/lib/dist/extraction/languages/java.js.map +1 -1
  38. package/lib/dist/extraction/languages/kotlin.d.ts.map +1 -1
  39. package/lib/dist/extraction/languages/kotlin.js +51 -0
  40. package/lib/dist/extraction/languages/kotlin.js.map +1 -1
  41. package/lib/dist/extraction/languages/scala.d.ts.map +1 -1
  42. package/lib/dist/extraction/languages/scala.js +19 -9
  43. package/lib/dist/extraction/languages/scala.js.map +1 -1
  44. package/lib/dist/extraction/parse-worker.js +4 -1
  45. package/lib/dist/extraction/parse-worker.js.map +1 -1
  46. package/lib/dist/extraction/tree-sitter-types.d.ts +13 -0
  47. package/lib/dist/extraction/tree-sitter-types.d.ts.map +1 -1
  48. package/lib/dist/extraction/tree-sitter.d.ts +119 -0
  49. package/lib/dist/extraction/tree-sitter.d.ts.map +1 -1
  50. package/lib/dist/extraction/tree-sitter.js +890 -11
  51. package/lib/dist/extraction/tree-sitter.js.map +1 -1
  52. package/lib/dist/index.d.ts +33 -0
  53. package/lib/dist/index.d.ts.map +1 -1
  54. package/lib/dist/index.js +68 -7
  55. package/lib/dist/index.js.map +1 -1
  56. package/lib/dist/installer/index.d.ts.map +1 -1
  57. package/lib/dist/installer/index.js +33 -56
  58. package/lib/dist/installer/index.js.map +1 -1
  59. package/lib/dist/installer/instructions-template.d.ts +3 -3
  60. package/lib/dist/installer/instructions-template.d.ts.map +1 -1
  61. package/lib/dist/installer/instructions-template.js +4 -4
  62. package/lib/dist/installer/targets/claude.d.ts +18 -12
  63. package/lib/dist/installer/targets/claude.d.ts.map +1 -1
  64. package/lib/dist/installer/targets/claude.js +78 -6
  65. package/lib/dist/installer/targets/claude.js.map +1 -1
  66. package/lib/dist/installer/targets/shared.d.ts +12 -2
  67. package/lib/dist/installer/targets/shared.d.ts.map +1 -1
  68. package/lib/dist/installer/targets/shared.js +13 -12
  69. package/lib/dist/installer/targets/shared.js.map +1 -1
  70. package/lib/dist/installer/targets/types.d.ts +7 -0
  71. package/lib/dist/installer/targets/types.d.ts.map +1 -1
  72. package/lib/dist/mcp/daemon-manager.d.ts +42 -0
  73. package/lib/dist/mcp/daemon-manager.d.ts.map +1 -0
  74. package/lib/dist/mcp/daemon-manager.js +129 -0
  75. package/lib/dist/mcp/daemon-manager.js.map +1 -0
  76. package/lib/dist/mcp/daemon-registry.d.ts +47 -0
  77. package/lib/dist/mcp/daemon-registry.d.ts.map +1 -0
  78. package/lib/dist/mcp/daemon-registry.js +229 -0
  79. package/lib/dist/mcp/daemon-registry.js.map +1 -0
  80. package/lib/dist/mcp/daemon.d.ts.map +1 -1
  81. package/lib/dist/mcp/daemon.js +5 -0
  82. package/lib/dist/mcp/daemon.js.map +1 -1
  83. package/lib/dist/mcp/engine.d.ts.map +1 -1
  84. package/lib/dist/mcp/engine.js +8 -0
  85. package/lib/dist/mcp/engine.js.map +1 -1
  86. package/lib/dist/mcp/index.d.ts +1 -0
  87. package/lib/dist/mcp/index.d.ts.map +1 -1
  88. package/lib/dist/mcp/index.js +13 -0
  89. package/lib/dist/mcp/index.js.map +1 -1
  90. package/lib/dist/mcp/liveness-watchdog.d.ts +18 -0
  91. package/lib/dist/mcp/liveness-watchdog.d.ts.map +1 -0
  92. package/lib/dist/mcp/liveness-watchdog.js +207 -0
  93. package/lib/dist/mcp/liveness-watchdog.js.map +1 -0
  94. package/lib/dist/mcp/server-instructions.d.ts +18 -14
  95. package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
  96. package/lib/dist/mcp/server-instructions.js +57 -52
  97. package/lib/dist/mcp/server-instructions.js.map +1 -1
  98. package/lib/dist/mcp/session.d.ts.map +1 -1
  99. package/lib/dist/mcp/session.js +23 -18
  100. package/lib/dist/mcp/session.js.map +1 -1
  101. package/lib/dist/mcp/tools.d.ts +51 -1
  102. package/lib/dist/mcp/tools.d.ts.map +1 -1
  103. package/lib/dist/mcp/tools.js +585 -151
  104. package/lib/dist/mcp/tools.js.map +1 -1
  105. package/lib/dist/project-config.d.ts +19 -0
  106. package/lib/dist/project-config.d.ts.map +1 -0
  107. package/lib/dist/project-config.js +180 -0
  108. package/lib/dist/project-config.js.map +1 -0
  109. package/lib/dist/reasoning/config.d.ts +45 -0
  110. package/lib/dist/reasoning/config.d.ts.map +1 -0
  111. package/lib/dist/reasoning/config.js +171 -0
  112. package/lib/dist/reasoning/config.js.map +1 -0
  113. package/lib/dist/reasoning/credentials.d.ts +5 -0
  114. package/lib/dist/reasoning/credentials.d.ts.map +1 -0
  115. package/lib/dist/reasoning/credentials.js +83 -0
  116. package/lib/dist/reasoning/credentials.js.map +1 -0
  117. package/lib/dist/reasoning/login.d.ts +21 -0
  118. package/lib/dist/reasoning/login.d.ts.map +1 -0
  119. package/lib/dist/reasoning/login.js +85 -0
  120. package/lib/dist/reasoning/login.js.map +1 -0
  121. package/lib/dist/reasoning/reasoner.d.ts +43 -0
  122. package/lib/dist/reasoning/reasoner.d.ts.map +1 -0
  123. package/lib/dist/reasoning/reasoner.js +308 -0
  124. package/lib/dist/reasoning/reasoner.js.map +1 -0
  125. package/lib/dist/resolution/c-fnptr-synthesizer.d.ts +33 -0
  126. package/lib/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -0
  127. package/lib/dist/resolution/c-fnptr-synthesizer.js +352 -0
  128. package/lib/dist/resolution/c-fnptr-synthesizer.js.map +1 -0
  129. package/lib/dist/resolution/callback-synthesizer.d.ts +6 -1
  130. package/lib/dist/resolution/callback-synthesizer.d.ts.map +1 -1
  131. package/lib/dist/resolution/callback-synthesizer.js +1109 -1
  132. package/lib/dist/resolution/callback-synthesizer.js.map +1 -1
  133. package/lib/dist/resolution/frameworks/goframe.d.ts +41 -0
  134. package/lib/dist/resolution/frameworks/goframe.d.ts.map +1 -0
  135. package/lib/dist/resolution/frameworks/goframe.js +112 -0
  136. package/lib/dist/resolution/frameworks/goframe.js.map +1 -0
  137. package/lib/dist/resolution/frameworks/index.d.ts +1 -0
  138. package/lib/dist/resolution/frameworks/index.d.ts.map +1 -1
  139. package/lib/dist/resolution/frameworks/index.js +5 -1
  140. package/lib/dist/resolution/frameworks/index.js.map +1 -1
  141. package/lib/dist/resolution/frameworks/react.d.ts.map +1 -1
  142. package/lib/dist/resolution/frameworks/react.js +17 -60
  143. package/lib/dist/resolution/frameworks/react.js.map +1 -1
  144. package/lib/dist/resolution/goframe-synthesizer.d.ts +28 -0
  145. package/lib/dist/resolution/goframe-synthesizer.d.ts.map +1 -0
  146. package/lib/dist/resolution/goframe-synthesizer.js +158 -0
  147. package/lib/dist/resolution/goframe-synthesizer.js.map +1 -0
  148. package/lib/dist/resolution/import-resolver.d.ts.map +1 -1
  149. package/lib/dist/resolution/import-resolver.js +56 -0
  150. package/lib/dist/resolution/import-resolver.js.map +1 -1
  151. package/lib/dist/resolution/name-matcher.d.ts.map +1 -1
  152. package/lib/dist/resolution/name-matcher.js +48 -8
  153. package/lib/dist/resolution/name-matcher.js.map +1 -1
  154. package/lib/dist/resolution/strip-comments.d.ts +1 -1
  155. package/lib/dist/resolution/strip-comments.d.ts.map +1 -1
  156. package/lib/dist/resolution/strip-comments.js +2 -0
  157. package/lib/dist/resolution/strip-comments.js.map +1 -1
  158. package/lib/dist/sync/watcher.d.ts +68 -1
  159. package/lib/dist/sync/watcher.d.ts.map +1 -1
  160. package/lib/dist/sync/watcher.js +212 -14
  161. package/lib/dist/sync/watcher.js.map +1 -1
  162. package/lib/dist/telemetry/index.d.ts +0 -3
  163. package/lib/dist/telemetry/index.d.ts.map +1 -1
  164. package/lib/dist/telemetry/index.js +4 -7
  165. package/lib/dist/telemetry/index.js.map +1 -1
  166. package/lib/dist/upgrade/index.d.ts.map +1 -1
  167. package/lib/dist/upgrade/index.js +40 -4
  168. package/lib/dist/upgrade/index.js.map +1 -1
  169. package/lib/dist/utils.d.ts +14 -1
  170. package/lib/dist/utils.d.ts.map +1 -1
  171. package/lib/dist/utils.js +20 -2
  172. package/lib/dist/utils.js.map +1 -1
  173. package/lib/node_modules/.package-lock.json +1 -1
  174. package/lib/package.json +2 -2
  175. package/package.json +1 -1
@@ -10,6 +10,7 @@ exports.getExploreBudget = getExploreBudget;
10
10
  exports.getExploreOutputBudget = getExploreOutputBudget;
11
11
  exports.formatStaleBanner = formatStaleBanner;
12
12
  exports.formatStaleFooter = formatStaleFooter;
13
+ exports.formatDegradedBanner = formatDegradedBanner;
13
14
  exports.getStaticTools = getStaticTools;
14
15
  const directory_1 = require("../directory");
15
16
  // Lazy-load the heavy CodeGraph chain off the MCP startup path — see the same
@@ -228,6 +229,28 @@ function exploreLineNumbersEnabled() {
228
229
  function adaptiveExploreEnabled() {
229
230
  return process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== '0' && process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== 'false';
230
231
  }
232
+ /**
233
+ * How long the FIRST tool call waits on the post-open catch-up reconcile before
234
+ * giving up and serving anyway (issue #905). On a normal repo the reconcile
235
+ * finishes in well under this, so the gate is fully honored and nothing changes.
236
+ * On a very large repo (~100k files) the reconcile takes minutes — blocking the
237
+ * first call on all of it presents as a multi-minute hang — so we wait briefly
238
+ * for a clean answer, then serve and let the reconcile finish in the background
239
+ * (it yields to the event loop, so a concurrent read still runs).
240
+ *
241
+ * `CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS` overrides the default; `0` restores the
242
+ * old unbounded-wait behavior (always block until the reconcile completes).
243
+ */
244
+ const DEFAULT_CATCHUP_GATE_TIMEOUT_MS = 3000;
245
+ function resolveCatchUpGateTimeoutMs() {
246
+ const raw = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
247
+ if (raw === undefined || raw === '')
248
+ return DEFAULT_CATCHUP_GATE_TIMEOUT_MS;
249
+ const n = Number(raw);
250
+ if (!Number.isFinite(n) || n < 0)
251
+ return DEFAULT_CATCHUP_GATE_TIMEOUT_MS;
252
+ return Math.floor(n);
253
+ }
231
254
  /**
232
255
  * Prefix each line of a source slice with its 1-based line number, matching
233
256
  * the Read tool's `cat -n` convention (number + tab) so the agent treats it
@@ -244,6 +267,22 @@ function numberSourceLines(slice, firstLineNumber) {
244
267
  }
245
268
  return out.join('\n');
246
269
  }
270
+ /**
271
+ * Unique line-prefix for a per-file source section in codegraph_explore output.
272
+ * Issue #778: tool results dropped ATX headings (`####`, `##`, `###`) for bold
273
+ * labels so Markdown-rendering MCP clients (e.g. the Claude Code VSCode
274
+ * extension) stop blowing every header up to H1–H4. The path is bold + a code
275
+ * span so it still reads as a header, and the leading ``**` `` stays a UNIQUE,
276
+ * greppable marker — no other explore line begins with it — that the explore
277
+ * truncation boundary (`handleExplore`) and the offload chunker
278
+ * (`reasoning/reasoner.ts`) both key off to cut on whole file sections.
279
+ */
280
+ const FILE_SECTION_PREFIX = '**`';
281
+ function fileSectionHeader(filePath, suffix) {
282
+ return suffix
283
+ ? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
284
+ : `${FILE_SECTION_PREFIX}${filePath}\`**`;
285
+ }
247
286
  /**
248
287
  * Per-file staleness banner emitted at the top of a tool response when the
249
288
  * file watcher has pending events for files referenced by the response.
@@ -280,12 +319,26 @@ function formatStaleFooter(stale) {
280
319
  return (`(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
281
320
  `sync but were not referenced above:\n${lines.join('\n')}${more})`);
282
321
  }
322
+ /**
323
+ * Whole-index degradation banner (issue #876). Emitted at the top of a read
324
+ * tool response when live watching has permanently stopped — at which point
325
+ * `getPendingFiles()` is empty, so the per-file banner above can't fire even
326
+ * though the index is now FROZEN and silently drifting stale. Leads with the
327
+ * agent-actionable instruction (Read directly) and carries the reason, which
328
+ * already names the operator remedy (`codegraph sync` / git hooks).
329
+ */
330
+ function formatDegradedBanner(reason) {
331
+ return ('⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the index is ' +
332
+ 'frozen and any file edited since then is stale here. Read files directly to confirm ' +
333
+ 'current content before relying on it.' +
334
+ (reason ? `\n Reason: ${reason}` : ''));
335
+ }
283
336
  /**
284
337
  * Common projectPath property for cross-project queries
285
338
  */
286
339
  const projectPathProperty = {
287
340
  type: 'string',
288
- description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
341
+ description: 'Absolute path to the project to query (or any directory inside it) — codegraph uses the nearest .codegraph/ index at or above that path. Omit to use this session\'s default project. Pass it to query a second codebase, or when the server root has no index of its own (e.g. a monorepo where only sub-projects are indexed, so there is no default project).',
289
342
  };
290
343
  /**
291
344
  * All CodeGraph MCP tools
@@ -514,28 +567,17 @@ function getStaticTools() {
514
567
  return allow.size ? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, ''))) : exports.tools;
515
568
  }
516
569
  /**
517
- * The MCP tools served by DEFAULT (short names). The other defined tools
518
- * (callees, impact, files, status) remain fully functional handlers stay,
519
- * the library API and CLI are untouched, and `CODEGRAPH_MCP_TOOLS` re-enables
520
- * any of them they just aren't LISTED to agents anymore.
570
+ * The MCP tools served by DEFAULT (short names). Pared to ONLY `codegraph_explore`
571
+ * the single tool that reliably earns its place: one capped call returns the
572
+ * verbatim source of the relevant symbols grouped by file. Every other tool is a
573
+ * narrower slice of what explore already does, and presence itself steers
574
+ * mis-picks, so they are no longer LISTED to agents.
521
575
  *
522
- * Evidence for the cut (the "adapt the tool to the agent" principle —
523
- * fewer tools = fewer mis-picks, and presence itself steers):
524
- * - `codegraph_impact` appears in ZERO recorded eval runs ever — its
525
- * blast-radius info already arrives inline on explore (the "Blast radius"
526
- * section) and node (the dependents note), so agents never need the
527
- * standalone tool.
528
- * - `codegraph_callees` is redundant by construction: a symbol's body (which
529
- * node returns) IS its callee list, plus the caller/callee trail.
530
- * - `codegraph_files` / `codegraph_status`: the tiny-repo audit (see
531
- * getTools) found they "reduce to one grep"; staleness banners already
532
- * inline the pending-sync info on every read tool, and the CLI covers
533
- * diagnostics.
534
- * - `codegraph_callers` stays: exhaustive call-site enumeration (every
535
- * caller with file:line, callback registrations labeled, one section per
536
- * same-named definition) is the one job explore/node don't replicate.
576
+ * The other defined tools (`node`, `search`, `callers`, plus callees/impact/files/
577
+ * status) remain fully functional — handlers stay, the library API and CLI are
578
+ * untouched, and `CODEGRAPH_MCP_TOOLS=explore,node,...` re-enables any of them.
537
579
  */
538
- const DEFAULT_MCP_TOOLS = new Set(['explore', 'node', 'search', 'callers']);
580
+ const DEFAULT_MCP_TOOLS = new Set(['explore']);
539
581
  /**
540
582
  * Tool handler that executes tools against a CodeGraph instance
541
583
  *
@@ -560,7 +602,9 @@ class ToolHandler {
560
602
  // this, a tool call that races past `catchUpSync()` serves rows for files
561
603
  // that were deleted (or edited) while no MCP server was running — and the
562
604
  // per-file staleness banner can't help, because `getPendingFiles()` is
563
- // populated by the watcher, not by catch-up. Cleared on first await so
605
+ // populated by the watcher, not by catch-up. The wait is time-boxed
606
+ // (see {@link resolveCatchUpGateTimeoutMs}) so a minutes-long reconcile on a
607
+ // huge repo can't hang the first call (#905); cleared on first await so
564
608
  // subsequent calls don't pay any cost.
565
609
  catchUpGate = null;
566
610
  constructor(cg) {
@@ -582,6 +626,45 @@ class ToolHandler {
582
626
  setCatchUpGate(p) {
583
627
  this.catchUpGate = p;
584
628
  }
629
+ /**
630
+ * Await the catch-up gate, but no longer than the configured timeout (#905).
631
+ * If the reconcile settles first, we got the fully-reconciled answer. If the
632
+ * timeout wins, we serve the call now and let the reconcile finish in the
633
+ * background — it yields to the event loop (see SYNC_RECONCILE_YIELD_INTERVAL),
634
+ * so a concurrent read still runs against the same connection. Never throws:
635
+ * a failed reconcile is logged by the engine, and we serve best-effort over
636
+ * the same potentially-stale data the un-gated path would have.
637
+ */
638
+ async awaitCatchUpGate(gate) {
639
+ const timeoutMs = resolveCatchUpGateTimeoutMs();
640
+ if (timeoutMs <= 0) {
641
+ // 0 = opt back into the original unbounded wait.
642
+ try {
643
+ await gate;
644
+ }
645
+ catch { /* engine already logged */ }
646
+ return;
647
+ }
648
+ let timer;
649
+ const timedOut = new Promise((resolve) => {
650
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
651
+ timer.unref?.();
652
+ });
653
+ try {
654
+ const outcome = await Promise.race([
655
+ gate.then(() => 'done', () => 'done'),
656
+ timedOut,
657
+ ]);
658
+ if (outcome === 'timeout') {
659
+ process.stderr.write(`[CodeGraph MCP] Catch-up reconcile still running after ${timeoutMs}ms; serving this tool call now and finishing the reconcile in the background (#905). ` +
660
+ `Set CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS=0 to always wait for it.\n`);
661
+ }
662
+ }
663
+ finally {
664
+ if (timer)
665
+ clearTimeout(timer);
666
+ }
667
+ }
585
668
  /**
586
669
  * Record the directory the server tried to resolve the default project from.
587
670
  * Used only to make the "no default project" error actionable.
@@ -696,19 +779,18 @@ class ToolHandler {
696
779
  const searched = this.defaultProjectHint ?? process.cwd();
697
780
  throw new NotIndexedError('No CodeGraph project is loaded for this session.\n' +
698
781
  `Searched for a .codegraph/ directory starting from: ${searched}\n` +
699
- 'If this project IS indexed, this is a working-directory detection issue: ' +
700
- "the MCP client launched the server outside your project and didn't report the " +
701
- 'workspace root. Fix it either way:\n' +
702
- ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
782
+ 'Either the server root has no index of its own (e.g. a monorepo where only ' +
783
+ "sub-projects are indexed), or the MCP client launched the server outside your " +
784
+ 'project without reporting the workspace root. Either way, target the project ' +
785
+ 'explicitly:\n' +
786
+ ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project" ' +
787
+ '(any project that has a .codegraph/ — including a sub-project of a monorepo)\n' +
703
788
  ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
704
- 'If the project simply has no index, continue with your built-in tools (Read/Grep/Glob) ' +
705
- "and don't call codegraph again this session — the user can run 'codegraph init' to enable it.");
789
+ 'If a project simply has no index, use your built-in tools (Read/Grep/Glob) for THAT ' +
790
+ "project (the user can run 'codegraph init' there to enable it) — you can still query " +
791
+ 'other indexed projects by projectPath in the same session.');
706
792
  }
707
- return this.cg;
708
- }
709
- // Check cache first (using original path as key)
710
- if (this.projectCache.has(projectPath)) {
711
- return this.projectCache.get(projectPath);
793
+ return this.freshen(this.cg);
712
794
  }
713
795
  // Reject sensitive system directories before opening. Only validate a
714
796
  // path that actually exists — a nested or not-yet-created sub-path of a
@@ -721,7 +803,16 @@ class ToolHandler {
721
803
  throw new PathRefusalError(pathError);
722
804
  }
723
805
  }
724
- // Walk up parent directories to find nearest .codegraph/
806
+ // Always RE-RESOLVE the nearest .codegraph/ from the input path. The walk
807
+ // is cheap (a few existsSync up the tree) and is the only thing that
808
+ // notices a path whose index root CHANGED since it was first seen — most
809
+ // importantly a git worktree that gained its own .codegraph/ after the
810
+ // (long-lived) server first resolved it up to the parent checkout. We used
811
+ // to short-circuit on a `projectCache[projectPath]` entry before resolving,
812
+ // which pinned that first resolution for the server's whole lifetime, so a
813
+ // worktree kept being served the parent checkout's index until restart
814
+ // (#926). The DB connection itself is still cached (by resolved root,
815
+ // below), so re-resolving costs only the stat walk, never a reopen.
725
816
  const resolvedRoot = (0, directory_1.findNearestCodeGraphRoot)(projectPath);
726
817
  if (!resolvedRoot) {
727
818
  throw new NotIndexedError(`The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` +
@@ -732,25 +823,43 @@ class ToolHandler {
732
823
  // If the path resolves to the default project, reuse the already-open
733
824
  // default instance rather than opening a SECOND connection to the same DB.
734
825
  // A duplicate connection serializes reads against the watcher's auto-sync
735
- // writes; on the wasm backend (no WAL) that surfaces as intermittent
736
- // "database is locked" on concurrent tool calls. See issue #238. Deliberately
737
- // not cached under projectPath the server owns and closes the default
738
- // instance, so routing it through projectCache.closeAll() would double-close it.
826
+ // writes; when WAL isn't in effect (e.g. a filesystem without shared-memory
827
+ // support) that surfaces as intermittent
828
+ // "database is locked" on concurrent tool calls. See issue #238. The
829
+ // default instance is owned/closed by the server, so it's never cached.
739
830
  if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
740
- return this.cg;
741
- }
742
- // Check if we already have this resolved root cached (different path, same project)
743
- if (this.projectCache.has(resolvedRoot)) {
744
- const cg = this.projectCache.get(resolvedRoot);
745
- // Cache under original path too for faster future lookups
746
- this.projectCache.set(projectPath, cg);
747
- return cg;
748
- }
749
- // Open and cache under both paths
831
+ return this.freshen(this.cg);
832
+ }
833
+ // Cache the open DB connection by RESOLVED ROOT only never by the input
834
+ // path. One key per instance means closeAll() closes each exactly once, and
835
+ // a changed resolution maps to a different entry instead of a stale hit.
836
+ const cached = this.projectCache.get(resolvedRoot);
837
+ if (cached)
838
+ return this.freshen(cached);
750
839
  const cg = loadCodeGraph().openSync(resolvedRoot);
751
840
  this.projectCache.set(resolvedRoot, cg);
752
- if (projectPath !== resolvedRoot) {
753
- this.projectCache.set(projectPath, cg);
841
+ return cg;
842
+ }
843
+ /**
844
+ * Heal a long-lived connection whose `.codegraph/` was removed and recreated
845
+ * at the same path (a worktree recreated, or `rm -rf .codegraph` + re-init)
846
+ * before handing it to a tool. Otherwise the daemon keeps serving the
847
+ * pre-removal snapshot from its now-unlinked file handle until restart — and
848
+ * because the daemon registry is keyed by path, a same-path recreate routes
849
+ * new clients straight back to this same stale daemon (#925). The check is one
850
+ * stat() and a no-op unless the inode actually changed; it never throws into a
851
+ * tool call.
852
+ */
853
+ freshen(cg) {
854
+ try {
855
+ if (cg.reopenIfReplaced()) {
856
+ process.stderr.write('[CodeGraph MCP] The index was replaced on disk (e.g. a git worktree ' +
857
+ 'recreated at the same path); reopened the live database in place.\n');
858
+ }
859
+ }
860
+ catch {
861
+ // Best-effort self-heal — a failed reopen must never break the tool call;
862
+ // the (still stale) handle keeps serving and the next call retries.
754
863
  }
755
864
  return cg;
756
865
  }
@@ -808,18 +917,29 @@ class ToolHandler {
808
917
  */
809
918
  worktreeMismatchFor(projectPath) {
810
919
  const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
811
- const cached = this.worktreeMismatchCache.get(startPath);
812
- if (cached !== undefined)
813
- return cached;
814
- let mismatch = null;
920
+ // The verdict depends on BOTH the start path AND the index root it resolves
921
+ // to, so the cache must be keyed on the pair. Resolve the index root first
922
+ // (cheap — getCodeGraph re-walks to the nearest .codegraph/, no git), then
923
+ // key on `(startPath, indexRoot)`. The moment that root changes — most
924
+ // importantly when a git worktree gains its own index and the walk-up stops
925
+ // there instead of at the parent checkout — the key changes and the verdict
926
+ // is recomputed, instead of serving the stale "borrowed the parent's index"
927
+ // warning for the server's whole lifetime. Keying on startPath alone pinned
928
+ // that first verdict until restart (#926).
929
+ let indexRoot;
815
930
  try {
816
- mismatch = (0, worktree_1.detectWorktreeIndexMismatch)(startPath, this.getCodeGraph(projectPath).getProjectRoot());
931
+ indexRoot = this.getCodeGraph(projectPath).getProjectRoot();
817
932
  }
818
933
  catch {
819
934
  // No resolvable project (or any other resolution error) → nothing to warn.
820
- mismatch = null;
935
+ return null;
821
936
  }
822
- this.worktreeMismatchCache.set(startPath, mismatch);
937
+ const cacheKey = `${startPath}\u0000${indexRoot}`;
938
+ const cached = this.worktreeMismatchCache.get(cacheKey);
939
+ if (cached !== undefined)
940
+ return cached;
941
+ const mismatch = (0, worktree_1.detectWorktreeIndexMismatch)(startPath, indexRoot);
942
+ this.worktreeMismatchCache.set(cacheKey, mismatch);
823
943
  return mismatch;
824
944
  }
825
945
  /**
@@ -883,6 +1003,34 @@ class ToolHandler {
883
1003
  /* getProjectRoot may throw on a closed instance — leave cg as is */
884
1004
  }
885
1005
  }
1006
+ // Whole-index degradation (#876): once live watching has permanently
1007
+ // stopped, getPendingFiles() is empty so the per-file banner below can't
1008
+ // fire — but the index is now FROZEN and silently drifting stale. Surface
1009
+ // one global notice instead, so the agent Reads for current content rather
1010
+ // than trusting a response off a no-longer-updating index. (Cross-project
1011
+ // calls open a watcher-less CodeGraph, so this is false there — correct: we
1012
+ // only know degraded state for the default session project.)
1013
+ let degraded = false;
1014
+ try {
1015
+ degraded = cg.isWatcherDegraded?.() ?? false;
1016
+ }
1017
+ catch {
1018
+ degraded = false;
1019
+ }
1020
+ if (degraded) {
1021
+ const [head, ...tail] = result.content;
1022
+ if (!head || head.type !== 'text')
1023
+ return result;
1024
+ let reason = null;
1025
+ try {
1026
+ reason = cg.getWatcherDegradedReason?.() ?? null;
1027
+ }
1028
+ catch {
1029
+ reason = null;
1030
+ }
1031
+ const composed = `${formatDegradedBanner(reason)}\n\n${head.text}`;
1032
+ return { ...result, content: [{ type: 'text', text: composed }, ...tail] };
1033
+ }
886
1034
  // Defensive: some test fakes inject a partial CodeGraph stub without the
887
1035
  // newer pending-files API. Treat missing/throwing as "no pending files."
888
1036
  let pending = [];
@@ -929,16 +1077,16 @@ class ToolHandler {
929
1077
  try {
930
1078
  // Block the first tool call on the engine's post-open reconcile so we
931
1079
  // never serve rows for files deleted/edited while no MCP server was
932
- // running. The gate is cleared after first await subsequent calls
933
- // pay nothing. Catch-up failures are logged by the engine; we
934
- // proceed regardless so a transient sync error never breaks tools.
1080
+ // running. The wait is time-boxed (#905): a huge-repo reconcile takes
1081
+ // minutes, and blocking the first call on all of it reads as a hang, so
1082
+ // we wait briefly then serve and let it finish in the background. The
1083
+ // gate is cleared after first await — subsequent calls pay nothing.
1084
+ // Catch-up failures are logged by the engine; we proceed regardless so a
1085
+ // transient sync error never breaks tools.
935
1086
  if (this.catchUpGate) {
936
1087
  const gate = this.catchUpGate;
937
1088
  this.catchUpGate = null;
938
- try {
939
- await gate;
940
- }
941
- catch { /* engine already logged */ }
1089
+ await this.awaitCatchUpGate(gate);
942
1090
  }
943
1091
  // Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
944
1092
  // surface rejects ablated tools defensively even if a client cached them.
@@ -1089,7 +1237,7 @@ class ToolHandler {
1089
1237
  definitionHeading(group) {
1090
1238
  const head = group[0];
1091
1239
  const line = head.startLine ? `:${head.startLine}` : '';
1092
- return `### ${head.qualifiedName} (${head.kind}) — ${head.filePath}${line}`;
1240
+ return `**${head.qualifiedName}** (${head.kind}) — ${head.filePath}${line}`;
1093
1241
  }
1094
1242
  /**
1095
1243
  * Handle codegraph_callers
@@ -1142,7 +1290,7 @@ class ToolHandler {
1142
1290
  // agent never mistakes one app's callers for another's. Narrow with
1143
1291
  // `file` to focus a single definition.
1144
1292
  const lines = [
1145
- `## Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
1293
+ `**Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
1146
1294
  ];
1147
1295
  for (const group of groups) {
1148
1296
  const { callers, labels } = collect(group);
@@ -1207,7 +1355,7 @@ class ToolHandler {
1207
1355
  }
1208
1356
  // Multiple DISTINCT definitions (#764): per-definition sections.
1209
1357
  const lines = [
1210
- `## Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
1358
+ `**Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
1211
1359
  ];
1212
1360
  for (const group of groups) {
1213
1361
  const { callees, labels } = collect(group);
@@ -1270,7 +1418,7 @@ class ToolHandler {
1270
1418
  // merging unrelated same-named classes (one UserService per monorepo app)
1271
1419
  // overstated impact and confused agents. Narrow with `file`.
1272
1420
  const sections = [
1273
- `## Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)`,
1421
+ `**Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)**`,
1274
1422
  ];
1275
1423
  for (const group of groups) {
1276
1424
  const head = group[0];
@@ -1346,6 +1494,29 @@ class ToolHandler {
1346
1494
  registeredAt,
1347
1495
  };
1348
1496
  }
1497
+ if (m?.synthesizedBy === 'fn-pointer-dispatch') {
1498
+ const via = m.via ? `\`${String(m.via)}\`` : 'a function pointer';
1499
+ return {
1500
+ label: `function-pointer dispatch via ${via} (dynamic dispatch)`,
1501
+ compact: `dynamic: fn-pointer ${m.via ? String(m.via) : ''}${at}`,
1502
+ registeredAt,
1503
+ };
1504
+ }
1505
+ if (m?.synthesizedBy === 'goframe-route') {
1506
+ const route = m.route ? `\`${String(m.route)}\`` : 'a route';
1507
+ return {
1508
+ label: `GoFrame route ${route} — reflective Bind → controller method (dynamic dispatch)`,
1509
+ compact: `dynamic: GoFrame route ${m.route ? String(m.route) : ''}${at}`,
1510
+ registeredAt,
1511
+ };
1512
+ }
1513
+ // Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain,
1514
+ // flutter-build, …): a synthesized hop must never read as a bare static `calls`.
1515
+ // It's a dynamic-dispatch bridge — label it as one and keep its wiring site.
1516
+ if (typeof m?.synthesizedBy === 'string') {
1517
+ const kind = m.synthesizedBy.replace(/-/g, ' ');
1518
+ return { label: `${kind} (dynamic dispatch)`, compact: `dynamic: ${kind}${at}`, registeredAt };
1519
+ }
1349
1520
  return null;
1350
1521
  }
1351
1522
  /**
@@ -1363,7 +1534,10 @@ class ToolHandler {
1363
1534
  * dropping unrelated `OmsOrderService::list`.
1364
1535
  */
1365
1536
  buildFlowFromNamedSymbols(cg, query) {
1366
- const EMPTY = { text: '', pathNodeIds: new Set(), namedNodeIds: new Set(), uniqueNamedNodeIds: new Set() };
1537
+ // spineCallSites: for each spine node, the line where it CALLS the next hop
1538
+ // lets the source assembler window an oversize spine method (e.g. n8n's 962-line
1539
+ // processRunExecutionData) to the call site instead of dumping the whole body.
1540
+ const EMPTY = { text: '', pathNodeIds: new Set(), namedNodeIds: new Set(), uniqueNamedNodeIds: new Set(), spineCallSites: new Map() };
1367
1541
  try {
1368
1542
  const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
1369
1543
  // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
@@ -1395,8 +1569,24 @@ class ToolHandler {
1395
1569
  // the dynamic-boundary scan (a token is covered when ANY of its nodes
1396
1570
  // lands on the main chain — overloads off the chain don't count against).
1397
1571
  const tokenNodes = new Map();
1572
+ // token → its full same-name callable family (before the container filter).
1573
+ // A LARGE family that fails to connect on the chain is a polymorphic
1574
+ // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
1575
+ const tokenFamily = new Map();
1576
+ // Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED
1577
+ // edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop
1578
+ // is constant→constant — the CALLABLE-only `named` set can't hold it, and
1579
+ // without this the hop is invisible to the Flow path at every tier (the
1580
+ // Relationships section catches it only on repos ≥500 files). Kept SEPARATE
1581
+ // from `named` (which drives the call-chain + source sizing, callable-only);
1582
+ // fed only to the dynamic-dispatch-links scan below.
1583
+ const dynNamed = new Map();
1584
+ const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
1585
+ const hasHeuristicEdge = (id) => [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
1398
1586
  for (const t of tokens) {
1399
- const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
1587
+ const hits = this.findAllSymbols(cg, t).nodes;
1588
+ const cands = hits.filter((n) => CALLABLE.has(n.kind));
1589
+ tokenFamily.set(t, cands);
1400
1590
  // A qualified or otherwise-specific name (<=3 hits) keeps all; an
1401
1591
  // ambiguous simple name keeps only candidates whose container is named.
1402
1592
  const specific = cands.length <= 3;
@@ -1414,21 +1604,67 @@ class ToolHandler {
1414
1604
  if (specific)
1415
1605
  uniqueNamedNodeIds.add(n.id);
1416
1606
  }
1607
+ // Same token, non-callable synth endpoints (capped, precision-gated on an
1608
+ // actual heuristic edge so plain config constants never qualify).
1609
+ if (dynNamed.size < 12) {
1610
+ for (const n of hits) {
1611
+ if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id))
1612
+ continue;
1613
+ if (hasHeuristicEdge(n.id))
1614
+ dynNamed.set(n.id, n);
1615
+ if (dynNamed.size >= 12)
1616
+ break;
1617
+ }
1618
+ }
1417
1619
  if (named.size > 40)
1418
1620
  break;
1419
1621
  }
1622
+ // Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
1623
+ // the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
1624
+ // already shown in the rendered main chain (a 2-node chain renders nothing, so a
1625
+ // direct named→named synth hop still surfaces — #687).
1626
+ const collectSynthLinks = (skipInChain) => {
1627
+ const synthLines = [];
1628
+ const synthSeen = new Set();
1629
+ for (const n of [...named.values(), ...dynNamed.values()]) {
1630
+ if (synthLines.length >= 6)
1631
+ break;
1632
+ for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
1633
+ if (synthLines.length >= 6)
1634
+ break;
1635
+ if (edge.provenance !== 'heuristic' || other.id === n.id)
1636
+ continue;
1637
+ if (skipInChain && skipInChain(edge))
1638
+ continue;
1639
+ const src = edge.source === n.id ? n : other;
1640
+ const tgt = edge.source === n.id ? other : n;
1641
+ const key = `${src.name}>${tgt.name}`;
1642
+ if (synthSeen.has(key))
1643
+ continue;
1644
+ synthSeen.add(key);
1645
+ const note = this.synthEdgeNote(edge);
1646
+ synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`);
1647
+ }
1648
+ }
1649
+ return synthLines;
1650
+ };
1420
1651
  if (named.size < 2) {
1421
- // The agent named a flow but only one side resolved (the other end is
1422
- // anonymous / runtime-registered / not extracted). The resolved side's
1423
- // body may still hold the dynamic-dispatch site that EXPLAINS the gap —
1424
- // surface that instead of silently returning nothing.
1425
- if (named.size === 0)
1426
- return EMPTY;
1427
- const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named);
1428
- if (!boundaries)
1652
+ // <2 CALLABLES resolved. Two recoveries before giving up: (1) synthesized
1653
+ // edges among named CONSTANT/VARIABLE endpoints RTK thunk→thunk is
1654
+ // constant→constant, so `named` can be empty while `dynNamed` holds the
1655
+ // whole chain; (2) the one resolved callable's body may hold the
1656
+ // dynamic-dispatch site that EXPLAINS a half-connected flow.
1657
+ const synthLines = collectSynthLinks(null);
1658
+ const boundaries = named.size === 0 ? '' : (this.buildDynamicBoundaries(cg, [...named.values()], named) || '');
1659
+ if (synthLines.length === 0 && !boundaries)
1429
1660
  return EMPTY;
1430
- const text = boundaries + '> Full source for these symbols is below.\n';
1431
- return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
1661
+ const out = [];
1662
+ if (synthLines.length)
1663
+ out.push('**Dynamic-dispatch links among your symbols**', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
1664
+ if (boundaries)
1665
+ out.push(boundaries);
1666
+ out.push('> Full source for these symbols is below.\n');
1667
+ return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map() };
1432
1668
  }
1433
1669
  const MAX_HOPS = 7;
1434
1670
  let best = null;
@@ -1477,6 +1713,16 @@ class ToolHandler {
1477
1713
  }
1478
1714
  const hasMain = !!best && best.length >= 3;
1479
1715
  const pathIds = new Set((best ?? []).map((s) => s.node.id));
1716
+ // Where each spine node calls the NEXT hop (best[i+1].edge is the edge from
1717
+ // best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets
1718
+ // the assembler window an oversize spine method to the call instead of dumping it.
1719
+ const spineCallSites = new Map();
1720
+ if (best)
1721
+ for (let i = 0; i < best.length - 1; i++) {
1722
+ const ln = best[i + 1]?.edge?.line;
1723
+ if (ln && ln > 0 && !spineCallSites.has(best[i].node.id))
1724
+ spineCallSites.set(best[i].node.id, ln);
1725
+ }
1480
1726
  // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
1481
1727
  // asked about did not fully connect: some token resolved to nodes but
1482
1728
  // none of them sit on the main chain (or there is no chain at all). A
@@ -1514,46 +1760,43 @@ class ToolHandler {
1514
1760
  boundaryText = this.buildDynamicBoundaries(cg, scanList, named);
1515
1761
  }
1516
1762
  }
1517
- // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED
1518
- // symbol the indirect hops an agent would otherwise grep/Read to
1519
- // reconstruct ("where do the appended `validators` actually run?"). The
1520
- // synth edge IS that answer, so surface it even when the OTHER end wasn't
1521
- // named (e.g. the agent names `validate` but not the `didCompleteTask`
1522
- // that drains the collection). On-topic by construction: only heuristic
1523
- // edges touching a symbol the agent named; skipped when the hop already
1524
- // shows in the main chain.
1525
- const synthLines = [];
1526
- const synthSeen = new Set();
1527
- for (const n of named.values()) {
1528
- if (synthLines.length >= 6)
1529
- break;
1530
- for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
1531
- if (synthLines.length >= 6)
1532
- break;
1533
- if (edge.provenance !== 'heuristic' || other.id === n.id)
1534
- continue;
1535
- // "Already in the main chain" only applies when a chain RENDERS
1536
- // (hasMain). A 2-node chain populates pathIds but renders nothing,
1537
- // so a direct synthesized hop between two named symbols (custom
1538
- // EventBus emit→handler, #687) was invisible — too short for Flow,
1539
- // skipped here as in-chain. Surface it.
1540
- if (hasMain && pathIds.has(edge.source) && pathIds.has(edge.target))
1541
- continue;
1542
- const src = edge.source === n.id ? n : other;
1543
- const tgt = edge.source === n.id ? other : n;
1544
- const key = `${src.name}>${tgt.name}`;
1545
- if (synthSeen.has(key))
1763
+ // Interface/registry-dispatch announcement (extends #687 to GRAPH-visible
1764
+ // polymorphism). A method the agent NAMED that resolves to a large same-name
1765
+ // family AND did not land on the main chain is almost always a runtime
1766
+ // dispatch (plugin/strategy/handler interface): the concrete target is chosen
1767
+ // at runtime from N implementations, so no single static edge is the answer.
1768
+ // The body-scan above can't see this `nodeType.execute()` is textually an
1769
+ // ordinary call; the polymorphism lives in the graph (implements edges), so
1770
+ // detect it there. Fires ONLY for an uncovered named token; a connected flow
1771
+ // stays silent.
1772
+ let polyText = '';
1773
+ {
1774
+ const POLY_MIN_FAMILY = 8; // smaller families are overload sets, not dispatch
1775
+ const polyCands = [];
1776
+ for (const [t, fam] of tokenFamily) {
1777
+ if (fam.length < POLY_MIN_FAMILY)
1546
1778
  continue;
1547
- synthSeen.add(key);
1548
- const note = this.synthEdgeNote(edge);
1549
- synthLines.push(`- ${src.name} ${tgt.name} [${note ? note.compact : edge.kind}]`);
1779
+ const ids = tokenNodes.get(t) || [];
1780
+ if (ids.some((id) => pathIds.has(id)))
1781
+ continue; // covered by the flow silent
1782
+ polyCands.push({ token: t, family: fam });
1550
1783
  }
1784
+ if (polyCands.length)
1785
+ polyText = this.buildPolymorphicBoundaries(cg, polyCands, named);
1551
1786
  }
1552
- if (!hasMain && synthLines.length === 0 && !boundaryText)
1787
+ // Supplementary: dynamic-dispatch (synthesized) edges incident to a named
1788
+ // symbol (incl. the non-callable CONSTANT endpoints in `dynNamed`) — the
1789
+ // indirect hops an agent would otherwise grep/Read to reconstruct ("where do
1790
+ // the appended `validators` actually run?"). Surfaced even when the OTHER end
1791
+ // wasn't named. The skip drops a hop already in the rendered main chain; a
1792
+ // 2-node chain renders nothing (hasMain false) so a direct named→named synth
1793
+ // hop still surfaces — too short for Flow, but #687-visible here.
1794
+ const synthLines = collectSynthLinks(hasMain ? (e) => pathIds.has(e.source) && pathIds.has(e.target) : null);
1795
+ if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText)
1553
1796
  return EMPTY;
1554
1797
  const out = [];
1555
1798
  if (hasMain) {
1556
- out.push('## Flow (call path among the symbols you queried)', '');
1799
+ out.push('**Flow (call path among the symbols you queried)**', '');
1557
1800
  for (let i = 0; i < best.length; i++) {
1558
1801
  const step = best[i];
1559
1802
  if (step.edge) {
@@ -1565,17 +1808,19 @@ class ToolHandler {
1565
1808
  out.push('');
1566
1809
  }
1567
1810
  if (synthLines.length) {
1568
- out.push('## Dynamic-dispatch links among your symbols', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
1811
+ out.push('**Dynamic-dispatch links among your symbols**', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
1569
1812
  }
1570
1813
  if (boundaryText)
1571
1814
  out.push(boundaryText);
1815
+ if (polyText)
1816
+ out.push(polyText);
1572
1817
  out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
1573
1818
  // namedNodeIds = every callable the agent explicitly named (a superset of
1574
1819
  // the spine). A file holding one is something the agent asked to SEE, so it
1575
1820
  // must keep full source even if it's an off-spine polymorphic sibling — the
1576
1821
  // agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
1577
1822
  // as the mechanism, not as an interchangeable leaf. See the skeleton gate.
1578
- return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
1823
+ return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites };
1579
1824
  }
1580
1825
  catch {
1581
1826
  return EMPTY;
@@ -1645,7 +1890,7 @@ class ToolHandler {
1645
1890
  if (notes.length === 0)
1646
1891
  return '';
1647
1892
  return [
1648
- '## Dynamic boundaries (the static path ends at runtime dispatch)',
1893
+ '**Dynamic boundaries (the static path ends at runtime dispatch)**',
1649
1894
  '',
1650
1895
  ...notes,
1651
1896
  '',
@@ -1653,6 +1898,113 @@ class ToolHandler {
1653
1898
  '',
1654
1899
  ].join('\n');
1655
1900
  }
1901
+ /**
1902
+ * Interface/registry-dispatch announcement — #687 extended to GRAPH-visible
1903
+ * polymorphism (the body-scan can't see it: `nodeType.execute()` is textually
1904
+ * an ordinary call; the polymorphism lives in the `implements`/`extends` edges).
1905
+ *
1906
+ * A method the agent named that resolves to a large same-name family whose
1907
+ * definers overwhelmingly implement/extend ONE supertype is a runtime dispatch:
1908
+ * the concrete target is chosen at runtime from N implementations, so no single
1909
+ * static edge is "the answer" — the implementations ARE the continuations. We
1910
+ * announce the supertype, its TRUE implementer count, and a few concrete targets,
1911
+ * then steer to codegraph_explore. Graph-only, query-time, zero mutation; the
1912
+ * caller fires it ONLY for an UNCOVERED named token, so a connected flow is silent.
1913
+ *
1914
+ * Robust to FTS sampling bias: the same-name family is a capped FTS sample that
1915
+ * over-represents whatever FTS ranks first (n8n: DB `TableOperation.execute`
1916
+ * outnumbered `INodeType.execute` in the sample 7:6 even though INodeType has
1917
+ * 611 implementers vs a handful). So candidate supertypes are ranked by their
1918
+ * TRUE graph-wide implementer count, NOT their frequency in the sample.
1919
+ */
1920
+ buildPolymorphicBoundaries(cg, candidates, named) {
1921
+ const CLASSY = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'abstract']);
1922
+ const MIN_IMPL = 8; // a supertype needs >= this many implementers to count as "polymorphic"
1923
+ const MIN_SUPPORT = 2; // >= this many sampled definers must share the supertype (ties it to the token)
1924
+ const SAMPLE = 40; // family members inspected per token
1925
+ const MAX_NOTES = 3;
1926
+ const rel = (p) => p.replace(/\\/g, '/');
1927
+ const containerOf = (m) => {
1928
+ try {
1929
+ const ce = cg.getIncomingEdges(m.id).find((e) => e.kind === 'contains');
1930
+ return ce ? cg.getNode(ce.source) : null;
1931
+ }
1932
+ catch {
1933
+ return null;
1934
+ }
1935
+ };
1936
+ const notes = [];
1937
+ const seenSuper = new Set();
1938
+ for (const { token, family } of candidates) {
1939
+ if (notes.length >= MAX_NOTES)
1940
+ break;
1941
+ // supertype id → how many sampled definers share it + a few example definers
1942
+ const supers = new Map();
1943
+ for (const m of family.slice(0, SAMPLE)) {
1944
+ const container = containerOf(m);
1945
+ if (!container || !CLASSY.has(container.kind))
1946
+ continue;
1947
+ let sups = [];
1948
+ try {
1949
+ sups = cg.getOutgoingEdges(container.id)
1950
+ .filter((e) => e.kind === 'implements' || e.kind === 'extends')
1951
+ .map((e) => { try {
1952
+ return cg.getNode(e.target);
1953
+ }
1954
+ catch {
1955
+ return null;
1956
+ } })
1957
+ .filter((n) => !!n && CLASSY.has(n.kind) && (n.name?.length || 0) >= 3);
1958
+ }
1959
+ catch { /* no supertypes — free function or unresolved */ }
1960
+ for (const s of sups) {
1961
+ const e = supers.get(s.id) || { node: s, count: 0, targets: [] };
1962
+ e.count++;
1963
+ if (e.targets.length < 6)
1964
+ e.targets.push(m);
1965
+ supers.set(s.id, e);
1966
+ }
1967
+ }
1968
+ // Pick the supertype with the most TRUE implementers (graph-wide), among
1969
+ // those genuinely shared by the token's definers.
1970
+ let best = null;
1971
+ for (const { node, count, targets } of supers.values()) {
1972
+ if (count < MIN_SUPPORT)
1973
+ continue;
1974
+ let impl = 0;
1975
+ try {
1976
+ impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length;
1977
+ }
1978
+ catch { /* leave 0 — gated out below */ }
1979
+ if (impl < MIN_IMPL)
1980
+ continue;
1981
+ if (!best || impl > best.impl)
1982
+ best = { node, impl, targets };
1983
+ }
1984
+ if (!best || seenSuper.has(best.node.id))
1985
+ continue;
1986
+ seenSuper.add(best.node.id);
1987
+ const namedNames = new Set([...named.values()].map((n) => n.name));
1988
+ const eg = best.targets.slice(0, 4).map((m) => {
1989
+ const cont = containerOf(m);
1990
+ const disp = cont ? `${cont.name}.${m.name}` : (m.qualifiedName || m.name);
1991
+ const mark = cont && namedNames.has(cont.name) ? ' ← you named this' : '';
1992
+ return `\`${disp}\` (${rel(m.filePath)}:${m.startLine})${mark}`;
1993
+ });
1994
+ const more = best.impl > eg.length ? ` +${best.impl - eg.length} more` : '';
1995
+ notes.push(`- \`${token}\` → runtime dispatch to **${best.impl}** types implementing \`${best.node.name}\` — the static path ends here, the target is chosen at runtime. e.g. ${eg.join(', ')}${more}`);
1996
+ }
1997
+ if (notes.length === 0)
1998
+ return '';
1999
+ return [
2000
+ '**Interface dispatch (a named method has many implementations)**',
2001
+ '',
2002
+ ...notes,
2003
+ '',
2004
+ '> The method above is dispatched at runtime to one of the listed implementations (a registry / plugin / strategy interface) — there is no single static caller→callee edge; the implementations ARE the continuations. To follow one, run codegraph_explore on a listed target.',
2005
+ '',
2006
+ ].join('\n');
2007
+ }
1656
2008
  /**
1657
2009
  * Shortlist candidate runtime targets for a dispatch key surfaced by
1658
2010
  * {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
@@ -1791,7 +2143,7 @@ class ToolHandler {
1791
2143
  if (entries.length === 0)
1792
2144
  return '';
1793
2145
  return [
1794
- '### Blast radius — what depends on these (update/verify before editing)',
2146
+ '**Blast radius — what depends on these (update/verify before editing)**',
1795
2147
  '',
1796
2148
  ...entries,
1797
2149
  '',
@@ -2191,6 +2543,25 @@ class ToolHandler {
2191
2543
  if (n)
2192
2544
  namedSeedFiles.add(n.filePath);
2193
2545
  }
2546
+ // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
2547
+ // (a search root, named seed, or graph-central hub — i.e. structurally part of
2548
+ // the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
2549
+ // graph-centrality mass that accrued to a denser-but-off-topic cluster. In a
2550
+ // cross-layer monorepo (an API server alongside a much larger, internally dense
2551
+ // frontend that mirrors the same domain words) the Random-Walk-with-Restart mass
2552
+ // — seeded from text matches that skew to the bigger layer — floats hits=0
2553
+ // frontend files above the hits=2/3 backend service that IS the answer (its many
2554
+ // callers don't help: it's call-isolated from the frontend seed cluster). The
2555
+ // entry/central GUARD keeps this safe: an INCIDENTAL multi-term file that is
2556
+ // neither entry nor central (a type/util file that matches "element"+x but isn't
2557
+ // the flow) is NOT promoted, so it can't displace the graph-central answer file
2558
+ // (hits=1) the way a blunt hits-only tier would. Single-layer repos with one
2559
+ // cluster are unaffected (no competing mass). Set CODEGRAPH_RANK_NO_MULTITERM=1
2560
+ // to disable.
2561
+ const MULTITERM_OFF = process.env.CODEGRAPH_RANK_NO_MULTITERM === '1';
2562
+ const isCorroborated = (fp) => !MULTITERM_OFF &&
2563
+ (fileTermHits.get(fp) ?? 0) >= 2 &&
2564
+ (entryFiles.has(fp) || centralFiles.has(fp));
2194
2565
  const sortedFiles = relevantFiles.sort((a, b) => {
2195
2566
  const aPath = a[0].toLowerCase();
2196
2567
  const bPath = b[0].toLowerCase();
@@ -2199,6 +2570,11 @@ class ToolHandler {
2199
2570
  const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
2200
2571
  if (aNamed !== bNamed)
2201
2572
  return bNamed - aNamed;
2573
+ // Corroborated (entry/central + ≥2 terms) tier, above the graph signal.
2574
+ const aCorr = isCorroborated(a[0]) ? 1 : 0;
2575
+ const bCorr = isCorroborated(b[0]) ? 1 : 0;
2576
+ if (aCorr !== bCorr)
2577
+ return bCorr - aCorr;
2202
2578
  // Graph connectivity is the next key (small epsilon so near-ties fall
2203
2579
  // through to the text signal rather than coin-flipping on float noise).
2204
2580
  const aG = fileGraphScore.get(a[0]) ?? 0;
@@ -2229,7 +2605,7 @@ class ToolHandler {
2229
2605
  });
2230
2606
  // Step 3: Build relationship map
2231
2607
  const lines = [
2232
- `## Exploration: ${query}`,
2608
+ `**Exploration: ${query}**`,
2233
2609
  '',
2234
2610
  `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
2235
2611
  '',
@@ -2244,7 +2620,7 @@ class ToolHandler {
2244
2620
  const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
2245
2621
  );
2246
2622
  if (budget.includeRelationships && significantEdges.length > 0) {
2247
- lines.push('### Relationships');
2623
+ lines.push('**Relationships**');
2248
2624
  lines.push('');
2249
2625
  // Group edges by kind for readability
2250
2626
  const byKind = new Map();
@@ -2329,7 +2705,7 @@ class ToolHandler {
2329
2705
  }
2330
2706
  return false;
2331
2707
  };
2332
- lines.push('### Source Code');
2708
+ lines.push('**Source Code**');
2333
2709
  lines.push('');
2334
2710
  lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
2335
2711
  lines.push('');
@@ -2484,7 +2860,7 @@ class ToolHandler {
2484
2860
  const tag = bodyIds.size > 0
2485
2861
  ? 'focused (the methods you named in full, the rest as signatures — codegraph_explore a signature by name for its body; do NOT Read)'
2486
2862
  : 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
2487
- lines.push(`#### ${filePath} — ${names} · ${tag}`, '', '```' + lang, skel.join('\n'), '```', '');
2863
+ lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
2488
2864
  totalChars += skel.join('\n').length + 120;
2489
2865
  filesIncluded++;
2490
2866
  continue;
@@ -2522,7 +2898,7 @@ class ToolHandler {
2522
2898
  .map(n => `${n.name}(${n.kind})`))];
2523
2899
  const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
2524
2900
  const omitted = uniqSymbols.length - headerNames.length;
2525
- const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
2901
+ const wholeHeader = fileSectionHeader(filePath, omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', '));
2526
2902
  if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
2527
2903
  // Don't slice a whole file mid-method: an incidental file that doesn't
2528
2904
  // fit is skipped; a necessary one (below) renders in full. Half a file
@@ -2586,7 +2962,12 @@ class ToolHandler {
2586
2962
  importance = 6; // bridging caller/callee of an entry
2587
2963
  else if (connectedToEntry.has(n.id))
2588
2964
  importance = 3;
2589
- return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
2965
+ // On the rendered call-path spine? That IS the flow answer — its cluster
2966
+ // must never be dropped by the per-file budget (n8n's huge workflow-execute.ts:
2967
+ // processRunExecutionData, the named flow ENTRY at L1562, is a large
2968
+ // low-density method that lost the budget to denser blocks and got cut, so
2969
+ // the agent Read it back — the very thing explore exists to prevent).
2970
+ return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id) };
2590
2971
  });
2591
2972
  // Add edge source locations in this file — captures template references
2592
2973
  // (component usages, event handlers) that aren't nodes themselves.
@@ -2605,7 +2986,7 @@ class ToolHandler {
2605
2986
  // Look up target name from subgraph first, fall back to edge kind
2606
2987
  const targetNode = subgraph.nodes.get(edge.target);
2607
2988
  const targetName = targetNode?.name ?? edge.kind;
2608
- ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
2989
+ ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2, spine: false });
2609
2990
  }
2610
2991
  }
2611
2992
  ranges.sort((a, b) => a.start - b.start);
@@ -2619,6 +3000,8 @@ class ToolHandler {
2619
3000
  symbols: [`${ranges[0].name}(${ranges[0].kind})`],
2620
3001
  score: ranges[0].importance,
2621
3002
  maxImportance: ranges[0].importance,
3003
+ hasSpine: ranges[0].spine,
3004
+ spineCallLine: ranges[0].spineCallLine,
2622
3005
  };
2623
3006
  for (let i = 1; i < ranges.length; i++) {
2624
3007
  const r = ranges[i];
@@ -2627,6 +3010,8 @@ class ToolHandler {
2627
3010
  current.symbols.push(`${r.name}(${r.kind})`);
2628
3011
  current.score += r.importance;
2629
3012
  current.maxImportance = Math.max(current.maxImportance, r.importance);
3013
+ current.hasSpine = current.hasSpine || r.spine;
3014
+ current.spineCallLine = current.spineCallLine ?? r.spineCallLine;
2630
3015
  }
2631
3016
  else {
2632
3017
  clusters.push(current);
@@ -2636,6 +3021,8 @@ class ToolHandler {
2636
3021
  symbols: [`${r.name}(${r.kind})`],
2637
3022
  score: r.importance,
2638
3023
  maxImportance: r.importance,
3024
+ hasSpine: r.spine,
3025
+ spineCallLine: r.spineCallLine,
2639
3026
  };
2640
3027
  }
2641
3028
  }
@@ -2649,16 +3036,40 @@ class ToolHandler {
2649
3036
  // get tail-trimmed with a marker.
2650
3037
  const contextPadding = 3;
2651
3038
  const withLineNumbers = exploreLineNumbersEnabled();
3039
+ // Language-neutral separator (no `//` — not a comment in Python, Ruby,
3040
+ // etc.). With line numbers on, the line-number jump also signals the gap.
3041
+ const GAP_MARKER = '\n\n... (gap) ...\n\n';
3042
+ // An oversize spine method (the call path runs THROUGH a god-method — n8n's
3043
+ // processRunExecutionData is 962 lines) is windowed to its next-hop CALL site
3044
+ // plus the signature head, NOT dumped whole. Without this the cluster is too big
3045
+ // for any per-file cap and gets dropped, so the agent Reads the method back —
3046
+ // the exact gap this closes. Bounded, so a god-method can't blow the budget yet
3047
+ // the spine's call still appears in context.
3048
+ const OVERSIZE_SPINE_LINES = 200;
3049
+ const SPINE_WINDOW = 28; // lines each side of the next-hop call site
2652
3050
  const buildSection = (c) => {
3051
+ if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) {
3052
+ const call = c.spineCallLine;
3053
+ const winStart = Math.max(c.start, call - SPINE_WINDOW);
3054
+ const winEnd = Math.min(c.end, call + SPINE_WINDOW);
3055
+ const parts = [];
3056
+ // Signature head, only when it sits clearly above the window (else the
3057
+ // window already covers the method opening).
3058
+ const headEnd = Math.min(c.start + 4, winStart - 2);
3059
+ if (headEnd >= c.start) {
3060
+ const head = fileLines.slice(c.start - 1, headEnd).join('\n');
3061
+ parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head);
3062
+ }
3063
+ const win = fileLines.slice(winStart - 1, winEnd).join('\n');
3064
+ parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win);
3065
+ return parts.join(GAP_MARKER);
3066
+ }
2653
3067
  const startIdx = Math.max(0, c.start - 1 - contextPadding);
2654
3068
  const endIdx = Math.min(fileLines.length, c.end + contextPadding);
2655
3069
  const slice = fileLines.slice(startIdx, endIdx).join('\n');
2656
3070
  // startIdx is 0-based, so the slice's first line is line startIdx + 1.
2657
3071
  return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
2658
3072
  };
2659
- // Language-neutral separator (no `//` — not a comment in Python, Ruby,
2660
- // etc.). With line numbers on, the line-number jump also signals the gap.
2661
- const GAP_MARKER = '\n\n... (gap) ...\n\n';
2662
3073
  // Rank clusters for inclusion under the per-file cap. Entry-point
2663
3074
  // clusters come first: a cluster containing a query entry point
2664
3075
  // (importance 10) must outrank a dense block of mere declarations,
@@ -2672,6 +3083,12 @@ class ToolHandler {
2672
3083
  const rankedClusters = clusters
2673
3084
  .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
2674
3085
  .sort((a, b) => {
3086
+ // Spine clusters first — the rendered call path IS the flow answer, so it
3087
+ // outranks any denser block of peripheral declarations (a low-density entry
3088
+ // method must not lose the budget to them). Within spine / within non-spine,
3089
+ // the existing importance → density → score → span order holds.
3090
+ if (a.c.hasSpine !== b.c.hasSpine)
3091
+ return (b.c.hasSpine ? 1 : 0) - (a.c.hasSpine ? 1 : 0);
2675
3092
  if (b.c.maxImportance !== a.c.maxImportance)
2676
3093
  return b.c.maxImportance - a.c.maxImportance;
2677
3094
  const densityA = a.c.score / a.span;
@@ -2689,6 +3106,11 @@ class ToolHandler {
2689
3106
  // That source-order slice is what cut Django's `_fetch_all` (L2237, importance
2690
3107
  // 9 — agent-named) when query.py was the last of four big files to be emitted.
2691
3108
  const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200));
3109
+ // Spine ceiling: a flow-path cluster may exceed the per-file cap (the call
3110
+ // path is the answer), but bounded — at most ~2.5× the per-file cap and never
3111
+ // past what's left of the total output cap — so a pathological long in-file
3112
+ // spine can't run away or starve co-flow files entirely.
3113
+ const SPINE_CEILING = Math.min(budget.maxCharsPerFile * 2.5, Math.max(0, budget.maxOutputChars - totalChars - 200));
2692
3114
  const chosenIndices = new Set();
2693
3115
  let projectedChars = 0;
2694
3116
  for (const rc of rankedClusters) {
@@ -2701,7 +3123,12 @@ class ToolHandler {
2701
3123
  projectedChars += sectionLen;
2702
3124
  continue;
2703
3125
  }
2704
- if (projectedChars + sectionLen > fileBudget)
3126
+ // A spine cluster (the rendered call path) is the flow answer — include it
3127
+ // past the per-file budget up to the spine ceiling; non-spine clusters obey
3128
+ // the normal per-file budget.
3129
+ const fits = projectedChars + sectionLen <= fileBudget;
3130
+ const spineFits = rc.c.hasSpine && projectedChars + sectionLen <= SPINE_CEILING;
3131
+ if (!fits && !spineFits)
2705
3132
  continue;
2706
3133
  chosenIndices.add(rc.idx);
2707
3134
  projectedChars += sectionLen;
@@ -2744,7 +3171,7 @@ class ToolHandler {
2744
3171
  const headerSuffix = omittedCount > 0
2745
3172
  ? `${headerSymbols.join(', ')}, +${omittedCount} more`
2746
3173
  : headerSymbols.join(', ');
2747
- const fileHeader = `#### ${filePath} — ${headerSuffix}`;
3174
+ const fileHeader = fileSectionHeader(filePath, headerSuffix);
2748
3175
  // The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
2749
3176
  // the agent named (or that's on the flow spine) renders even when the
2750
3177
  // nominal total is used up — it's the answer, and the set is bounded by
@@ -2781,7 +3208,7 @@ class ToolHandler {
2781
3208
  .sort((a, b) => b[1].score - a[1].score);
2782
3209
  const remainingFiles = [...remainingRelevant, ...peripheralFiles];
2783
3210
  if (remainingFiles.length > 0) {
2784
- lines.push('### Not shown above — explore these names for their source');
3211
+ lines.push('**Not shown above — explore these names for their source**');
2785
3212
  lines.push('');
2786
3213
  for (const [filePath, group] of remainingFiles.slice(0, 10)) {
2787
3214
  const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
@@ -2828,13 +3255,13 @@ class ToolHandler {
2828
3255
  const output = flow.text + lines.join('\n');
2829
3256
  const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
2830
3257
  if (output.length > hardCeiling) {
2831
- // Cut at a FILE-SECTION boundary (the last `#### ` header before the
3258
+ // Cut at a FILE-SECTION boundary (the last ``**` `` file header before the
2832
3259
  // ceiling) so we drop whole trailing file-sections rather than slicing
2833
3260
  // through a method body — a half-rendered method just forces the Read this
2834
3261
  // tool exists to prevent. Fall back to a line boundary only if no section
2835
3262
  // header sits in the back half (degenerate single-giant-section case).
2836
3263
  const cut = output.slice(0, hardCeiling);
2837
- const lastSection = cut.lastIndexOf('\n#### ');
3264
+ const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
2838
3265
  const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
2839
3266
  const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
2840
3267
  return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
@@ -2943,7 +3370,7 @@ class ToolHandler {
2943
3370
  if (listed.length) {
2944
3371
  const LIST_CAP = 20;
2945
3372
  const shownList = listed.slice(0, LIST_CAP);
2946
- out.push('', '### Other definitions', ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`));
3373
+ out.push('', '**Other definitions**', ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`));
2947
3374
  if (listed.length > LIST_CAP)
2948
3375
  out.push(`- … +${listed.length - LIST_CAP} more`);
2949
3376
  out.push('', `> Need one of these in full? Call codegraph_node again with \`file\` (e.g. \`"${listed[0].filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`);
@@ -3012,7 +3439,7 @@ class ToolHandler {
3012
3439
  if (opts.symbolsOnly) {
3013
3440
  const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
3014
3441
  if (nodes.length)
3015
- out.push(...symbolMap('### Symbols'));
3442
+ out.push(...symbolMap('**Symbols**'));
3016
3443
  else
3017
3444
  out.push('_No indexed symbols in this file._');
3018
3445
  out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
@@ -3023,7 +3450,7 @@ class ToolHandler {
3023
3450
  if (utils_1.CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
3024
3451
  const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
3025
3452
  if (nodes.length)
3026
- out.push(...symbolMap('### Keys (values withheld for safety)'));
3453
+ out.push(...symbolMap('**Keys (values withheld for safety)**'));
3027
3454
  out.push('', '> Values may be secrets, so codegraph indexes keys only. Read the file directly if you need a value.');
3028
3455
  return this.textResult(this.truncateOutput(out.join('\n')));
3029
3456
  }
@@ -3042,7 +3469,7 @@ class ToolHandler {
3042
3469
  if (content === null) {
3043
3470
  const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
3044
3471
  if (nodes.length)
3045
- out.push(...symbolMap('### Symbols'));
3472
+ out.push(...symbolMap('**Symbols**'));
3046
3473
  out.push('', `> Read \`${filePath}\` directly for its current content.`);
3047
3474
  return this.textResult(this.truncateOutput(out.join('\n')));
3048
3475
  }
@@ -3133,7 +3560,7 @@ class ToolHandler {
3133
3560
  const callers = collect(cg.getCallers(node.id));
3134
3561
  if (callees.length === 0 && callers.length === 0)
3135
3562
  return '';
3136
- const lines = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
3563
+ const lines = ['', '**Trail — codegraph_node any of these to follow it (no Read needed)**'];
3137
3564
  if (callees.length > 0) {
3138
3565
  lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
3139
3566
  }
@@ -3167,7 +3594,7 @@ class ToolHandler {
3167
3594
  // one-liner via withWorktreeNotice. Both share the cached detection.
3168
3595
  const mismatch = this.worktreeMismatchFor(args.projectPath);
3169
3596
  const lines = [
3170
- '## CodeGraph Status',
3597
+ '**CodeGraph Status**',
3171
3598
  '',
3172
3599
  ];
3173
3600
  if (mismatch) {
@@ -3189,25 +3616,32 @@ class ToolHandler {
3189
3616
  lines.push(`**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
3190
3617
  `can block on a concurrent write (WAL appears unsupported on this filesystem)`);
3191
3618
  }
3192
- lines.push('', '### Nodes by Kind:');
3619
+ lines.push('', '**Nodes by Kind:**');
3193
3620
  for (const [kind, count] of Object.entries(stats.nodesByKind)) {
3194
3621
  if (count > 0) {
3195
3622
  lines.push(`- ${kind}: ${count}`);
3196
3623
  }
3197
3624
  }
3198
- lines.push('', '### Languages:');
3625
+ lines.push('', '**Languages:**');
3199
3626
  for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
3200
3627
  if (count > 0) {
3201
3628
  lines.push(`- ${lang}: ${count}`);
3202
3629
  }
3203
3630
  }
3631
+ // Whole-index degradation (#876): when live watching has permanently
3632
+ // stopped, getPendingFiles() is empty (so no "Pending sync" section below)
3633
+ // but the index is frozen — call that out explicitly here, the one place an
3634
+ // agent asks "is the index caught up?".
3635
+ if (cg.isWatcherDegraded()) {
3636
+ lines.push('', '**Auto-sync disabled:**', `- ${cg.getWatcherDegradedReason() ?? 'live file watching stopped'}`, '- The index is frozen; Read files directly for current content.');
3637
+ }
3204
3638
  // Per-file freshness — the inverse of the auto-prepended staleness banner
3205
3639
  // (issue #403). Surfacing it inside `status` gives the agent a single
3206
3640
  // place to ask "is the index caught up?" rather than inferring from
3207
3641
  // banners on other tool calls.
3208
3642
  const pending = cg.getPendingFiles();
3209
3643
  if (pending.length > 0) {
3210
- lines.push('', '### Pending sync:');
3644
+ lines.push('', '**Pending sync:**');
3211
3645
  const now = Date.now();
3212
3646
  for (const p of pending) {
3213
3647
  const ageMs = Math.max(0, now - p.lastSeenMs);
@@ -3287,7 +3721,7 @@ class ToolHandler {
3287
3721
  * Format files as a flat list
3288
3722
  */
3289
3723
  formatFilesFlat(files, includeMetadata) {
3290
- const lines = [`## Files (${files.length})`, ''];
3724
+ const lines = [`**Files (${files.length})**`, ''];
3291
3725
  for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
3292
3726
  if (includeMetadata) {
3293
3727
  lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
@@ -3308,11 +3742,11 @@ class ToolHandler {
3308
3742
  existing.push(file);
3309
3743
  byLang.set(file.language, existing);
3310
3744
  }
3311
- const lines = [`## Files by Language (${files.length} total)`, ''];
3745
+ const lines = [`**Files by Language (${files.length} total)**`, ''];
3312
3746
  // Sort languages by file count (descending)
3313
3747
  const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
3314
3748
  for (const [lang, langFiles] of sortedLangs) {
3315
- lines.push(`### ${lang} (${langFiles.length})`);
3749
+ lines.push(`**${lang} (${langFiles.length})**`);
3316
3750
  for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
3317
3751
  if (includeMetadata) {
3318
3752
  lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
@@ -3348,7 +3782,7 @@ class ToolHandler {
3348
3782
  }
3349
3783
  }
3350
3784
  // Render tree
3351
- const lines = [`## Project Structure (${files.length} files)`, ''];
3785
+ const lines = [`**Project Structure (${files.length} files)**`, ''];
3352
3786
  const renderNode = (node, prefix, isLast, depth) => {
3353
3787
  if (maxDepth !== undefined && depth > maxDepth)
3354
3788
  return;
@@ -3539,12 +3973,12 @@ class ToolHandler {
3539
3973
  // Formatting helpers (compact by default to reduce context usage)
3540
3974
  // =========================================================================
3541
3975
  formatSearchResults(results) {
3542
- const lines = [`## Search Results (${results.length} found)`, ''];
3976
+ const lines = [`**Search Results (${results.length} found)**`, ''];
3543
3977
  for (const result of results) {
3544
3978
  const { node } = result;
3545
3979
  const location = node.startLine ? `:${node.startLine}` : '';
3546
3980
  // Compact format: one line per result with key info
3547
- lines.push(`### ${node.name} (${node.kind})`);
3981
+ lines.push(`**${node.name}** (${node.kind})`);
3548
3982
  lines.push(`${node.filePath}${location}`);
3549
3983
  if (node.signature)
3550
3984
  lines.push(`\`${node.signature}\``);
@@ -3553,7 +3987,7 @@ class ToolHandler {
3553
3987
  return lines.join('\n');
3554
3988
  }
3555
3989
  formatNodeList(nodes, title, labels) {
3556
- const lines = [`## ${title} (${nodes.length} found)`, ''];
3990
+ const lines = [`**${title} (${nodes.length} found)**`, ''];
3557
3991
  for (const node of nodes) {
3558
3992
  const location = node.startLine ? `:${node.startLine}` : '';
3559
3993
  // Compact: just name, kind, location — plus the relationship when it
@@ -3586,7 +4020,7 @@ class ToolHandler {
3586
4020
  const nodeCount = impact.nodes.size;
3587
4021
  // Compact format: just list affected symbols grouped by file
3588
4022
  const lines = [
3589
- `## Impact: "${symbol}" affects ${nodeCount} symbols`,
4023
+ `**Impact: "${symbol}" affects ${nodeCount} symbols**`,
3590
4024
  '',
3591
4025
  ];
3592
4026
  // Group by file
@@ -3629,7 +4063,7 @@ class ToolHandler {
3629
4063
  formatNodeDetails(node, code, outline) {
3630
4064
  const location = node.startLine ? `:${node.startLine}` : '';
3631
4065
  const lines = [
3632
- `## ${node.name} (${node.kind})`,
4066
+ `**${node.name}** (${node.kind})`,
3633
4067
  '',
3634
4068
  `**Location:** ${node.filePath}${location}`,
3635
4069
  ];