Repository: colbymchenry/codegraph (2026-06-22T17:06:24Z)

Issue:
MCP tool results use ATX Markdown headings (##/###) — render as oversized fonts in Markdown-rendering clients
### Summary

The MCP tools return result text using ATX Markdown headings (`##`, `###`).
In MCP clients that render tool output as rich Markdown (e.g. the Claude Code
VSCode extension), every heading is shown at H1/H2/H3 font size. Because some
tools emit **one heading per result**, a normal query fills the transcript with
large-font lines and is persistently distracting.

This is purely about the MCP output rendering — the CLI/TTY output is fine.

### Where it comes from

`dist/mcp/tools.js` formats results with `##`/`###`. Examples as seen by the client:

`codegraph_status`:

    ## CodeGraph Status
    **Files indexed:** 1953
    ...
    ### Nodes by Kind:
    ...

`codegraph_search` — one `###` heading *per hit*, so 10 results = 10 oversized headings:

    ## Search Results (10 found)

    ### WidgetSeeder (method)
    src/.../WidgetSeeder.cs:31

    ### WidgetSeeder (class)
    src/.../WidgetSeeder.cs:18
    ...

The per-result `###` headings are the worst offender, since the noise scales
with result count.

### Why the client can't fix it

There's no client-side control for this in at least the Claude Code VSCode
extension — tool-result text is rendered as Markdown uniformly, with no
plain-text or heading-size option. So the only effective lever is the server's
output format.

### Concrete ask (cheapest first)

1. **Drop ATX headings in favor of bold labels** — `**Search Results (10 found)**`
   and `**WidgetSeeder** (method)` instead of `##`/`###`. Bold renders
   at normal size, so structure is preserved without font-scaling, and it needs
   no flag or config. This alone would resolve it for everyone.

2. *Or*, if the heading style is wanted by default, an opt-in plain/compact mode
   via an env knob — e.g. `CODEGRAPH_PLAIN=1`, consistent with the existing
   `CODEGRAPH_*` knobs (`CODEGRAPH_ASCII`, `CODEGRAPH_UNICODE`, …) — that emits
   the same content without ATX headings.

Either is fine; (1) is the lowest-risk and benefits all Markdown-rendering
clients without configuration.

### Environment

- codegraph `0.9.9` (npm, linux-x64 engine bundle)
- Transport: MCP stdio (`codegraph serve --mcp`)
- Client: Claude Code VSCode extension


Discussion:
Confirmed locally on `main`: the ATX headings come from `src/mcp/tools.ts` — e.g. `## Search Results` and the per-hit `### <name> (<kind>)` (around L3325/L3331), `## CodeGraph Status` (L2886) — plus a number of `###`/`####` headers elsewhere in that file and in `src/context/formatter.ts`. Happy to pick this up.

Before I open a PR, which direction do you prefer?

1. **Change the default** — swap the ATX headings for bold labels (`**Search Results (10 found)**`, `**WidgetSeeder** (method)`) across the board. Zero-config win for every Markdown-rendering client, but it changes the default output agents currently parse and touches the heading assertions in ~7 test files.

2. **Opt-in env knob** — add `CODEGRAPH_PLAIN=1` (consistent with the existing `CODEGRAPH_ASCII`/`CODEGRAPH_UNICODE` in `src/ui/glyphs.ts`) that emits the same content without ATX headings, leaving the default untouched.

Both are straightforward — I'll go whichever way you prefer.
Personally I would be happy with the opt-in `CODEGRAPH_PLAIN=1` if that works for you.
Fixed in #951 (merged to `main`, ships in the next release) — thanks for the thorough write-up and for ranking the cheapest fix first; that's exactly what shipped.

All MCP tool results now use **bold labels** instead of ATX headings (`##`/`###`/`####`), so they render at normal text size in Markdown-rendering clients like the Claude Code VSCode extension. That covers the worst offenders you flagged — the per-hit `###` in `codegraph_search` and the per-file `####` in `codegraph_explore` — plus `status`, `node`, `callers`/`callees`/`impact`, and `files`. No flag or config needed.

CLI/TTY output is unchanged, as you noted it was already fine.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64d20a793..140f02376 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- MCP tool results no longer show up as oversized headings in Markdown-rendering clients (such as the Claude Code VSCode extension). Results used Markdown headings (`##`/`###`) for things like the status summary, each search hit, and every file section in an exploration, so a normal query filled the transcript with large-font lines — worst with `codegraph_search` and `codegraph_explore`, where the noise grew with the number of results. Section headers are now bold labels, which render at normal text size while keeping the same structure. Terminal/CLI output is unchanged. (#778)
 - An MCP server pointed at a very large repository (tens of thousands of files) no longer hangs on the first tool call after a fresh start. On startup CodeGraph reconciles its index against the current files on disk, and on a huge repo that reconcile could run for minutes while blocking the very first request — long enough that the background server was sometimes force-restarted mid-scan, so the first query never came back at all. The reconcile now yields as it runs (keeping the server responsive instead of pinning it), and the first tool call waits only briefly for it before answering and letting the rest finish in the background — so you get a fast first response and the index still catches up. Set `CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS` to tune how long that first call waits (default 3000ms), or `=0` to always wait for the full reconcile. (#905)
 - `codegraph install` now wires up your agents and stops there — it no longer indexes the current directory. Building a project's graph is always the explicit `codegraph init` (or `codegraph index`), so you decide what gets indexed and when, and the steps are the same whether you installed globally or just for one project. This clears up the confusion where a project-local install silently indexed but a global one didn't, and where the docs and the tool disagreed about whether you still had to run `init`. (#826)
 - React components declared with `forwardRef`, `memo`, or styled-components / emotion (`const Button = forwardRef(...)`, `const Card = memo(...)`, `const Box = styled.button\`…\``) are now recognized as components, so finding where they're used works. Before, they were indexed as plain constants, so `codegraph callers` and impact analysis reported "no callers found" even when the component was rendered across dozens of files — a dangerous false "safe to change" right before refactoring a shared component. Now every `<Button/>` usage links back to the component, so callers and blast radius are complete. This is the standard shadcn/ui declaration style, so for typical React design systems the whole UI layer is no longer invisible to impact analysis. Thanks @Arlandaren for the report and @maxmilian for the root-cause. (#841)
diff --git a/scripts/agent-eval/offload-eval-metrics.mjs b/scripts/agent-eval/offload-eval-metrics.mjs
index 97cb35f97..9e27b3c68 100644
--- a/scripts/agent-eval/offload-eval-metrics.mjs
+++ b/scripts/agent-eval/offload-eval-metrics.mjs
@@ -48,7 +48,7 @@ for (const line of lines) {
       // "### Referenced source — verbatim" appendix). A refs call that cited nothing
       // valid falls back to RAW source, which is correctly counted as a raw explore below.
       if (/Synthesized by CodeGraph|### Referenced source — verbatim/.test(text)) { offloadAnswers.push(text); exploreResults++; }
-      else if (/Found \d+ symbols? across|## Exploration:/.test(text)) exploreResults++;
+      else if (/Found \d+ symbols? across|\*\*Exploration:/.test(text)) exploreResults++;
     }
   }
   if (ev.type === 'result') result = ev;
diff --git a/scripts/agent-eval/probe-explore.mjs b/scripts/agent-eval/probe-explore.mjs
index 05ae073d4..9452d1f13 100644
--- a/scripts/agent-eval/probe-explore.mjs
+++ b/scripts/agent-eval/probe-explore.mjs
@@ -36,5 +36,5 @@ console.log(text);
 console.error('\n--- PROBE STATS ---');
 console.error('output chars:', text.length);
 console.error('triggerRender body present (-> setState({})):', /triggerRender[\s\S]{0,400}setState\(\{\}\)/.test(text));
-console.error('App.tsx in source section:', /#### .*App\.tsx —/.test(text));
+console.error('App.tsx in source section:', /\*\*`.*App\.tsx`\*\* —/.test(text));
 try { cg.close?.(); } catch {}
diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts
index 9d241ed6d..791fe4752 100644
--- a/src/mcp/tools.ts
+++ b/src/mcp/tools.ts
@@ -134,7 +134,7 @@ export interface ExploreOutputBudget {
   maxCharsPerFile: number;
   /** Cluster gap threshold in lines — tighter clustering on small projects. */
   gapThreshold: number;
-  /** Max symbols listed in the per-file header (`#### path — sym(kind), ...`). */
+  /** Max symbols listed in the per-file header (``**`path`** — sym(kind), ...``). */
   maxSymbolsInFileHeader: number;
   /** Max edges shown per relationship kind in the Relationships section. */
   maxEdgesPerRelationshipKind: number;
@@ -326,6 +326,23 @@ function numberSourceLines(slice: string, firstLineNumber: number): string {
   return out.join('\n');
 }
 
+/**
+ * Unique line-prefix for a per-file source section in codegraph_explore output.
+ * Issue #778: tool results dropped ATX headings (`####`, `##`, `###`) for bold
+ * labels so Markdown-rendering MCP clients (e.g. the Claude Code VSCode
+ * extension) stop blowing every header up to H1–H4. The path is bold + a code
+ * span so it still reads as a header, and the leading ``**` `` stays a UNIQUE,
+ * greppable marker — no other explore line begins with it — that the explore
+ * truncation boundary (`handleExplore`) and the offload chunker
+ * (`reasoning/reasoner.ts`) both key off to cut on whole file sections.
+ */
+const FILE_SECTION_PREFIX = '**`';
+function fileSectionHeader(filePath: string, suffix: string): string {
+  return suffix
+    ? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
+    : `${FILE_SECTION_PREFIX}${filePath}\`**`;
+}
+
 /**
  * Per-file staleness banner emitted at the top of a tool response when the
  * file watcher has pending events for files referenced by the response.
@@ -1350,7 +1367,7 @@ export class ToolHandler {
   private definitionHeading(group: Node[]): string {
     const head = group[0]!;
     const line = head.startLine ? `:${head.startLine}` : '';
-    return `### ${head.qualifiedName} (${head.kind}) — ${head.filePath}${line}`;
+    return `**${head.qualifiedName}** (${head.kind}) — ${head.filePath}${line}`;
   }
 
   /**
@@ -1408,7 +1425,7 @@ export class ToolHandler {
     // agent never mistakes one app's callers for another's. Narrow with
     // `file` to focus a single definition.
     const lines: string[] = [
-      `## Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
+      `**Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
     ];
     for (const group of groups) {
       const { callers, labels } = collect(group);
@@ -1478,7 +1495,7 @@ export class ToolHandler {
 
     // Multiple DISTINCT definitions (#764): per-definition sections.
     const lines: string[] = [
-      `## Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
+      `**Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
     ];
     for (const group of groups) {
       const { callees, labels } = collect(group);
@@ -1547,7 +1564,7 @@ export class ToolHandler {
     // merging unrelated same-named classes (one UserService per monorepo app)
     // overstated impact and confused agents. Narrow with `file`.
     const sections: string[] = [
-      `## Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)`,
+      `**Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)**`,
     ];
     for (const group of groups) {
       const head = group[0]!;
@@ -1765,7 +1782,7 @@ export class ToolHandler {
         if (synthLines.length === 0 && !boundaries) return EMPTY;
         const out: string[] = [];
         if (synthLines.length) out.push(
-          '## Dynamic-dispatch links among your symbols',
+          '**Dynamic-dispatch links among your symbols**',
           '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)',
           '', ...synthLines, '');
         if (boundaries) out.push(boundaries);
@@ -1880,7 +1897,7 @@ export class ToolHandler {
       if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY;
       const out: string[] = [];
       if (hasMain) {
-        out.push('## Flow (call path among the symbols you queried)', '');
+        out.push('**Flow (call path among the symbols you queried)**', '');
         for (let i = 0; i < best!.length; i++) {
           const step = best![i]!;
           if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(`   ↓ ${sy ? sy.compact : step.edge.kind}`); }
@@ -1890,7 +1907,7 @@ export class ToolHandler {
       }
       if (synthLines.length) {
         out.push(
-          '## Dynamic-dispatch links among your symbols',
+          '**Dynamic-dispatch links among your symbols**',
           '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)',
           '',
           ...synthLines,
@@ -1958,7 +1975,7 @@ export class ToolHandler {
     }
     if (notes.length === 0) return '';
     return [
-      '## Dynamic boundaries (the static path ends at runtime dispatch)',
+      '**Dynamic boundaries (the static path ends at runtime dispatch)**',
       '',
       ...notes,
       '',
@@ -2045,7 +2062,7 @@ export class ToolHandler {
     }
     if (notes.length === 0) return '';
     return [
-      '## Interface dispatch (a named method has many implementations)',
+      '**Interface dispatch (a named method has many implementations)**',
       '',
       ...notes,
       '',
@@ -2174,7 +2191,7 @@ export class ToolHandler {
     if (entries.length === 0) return '';
 
     return [
-      '### Blast radius — what depends on these (update/verify before editing)',
+      '**Blast radius — what depends on these (update/verify before editing)**',
       '',
       ...entries,
       '',
@@ -2643,7 +2660,7 @@ export class ToolHandler {
 
     // Step 3: Build relationship map
     const lines: string[] = [
-      `## Exploration: ${query}`,
+      `**Exploration: ${query}**`,
       '',
       `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
       '',
@@ -2661,7 +2678,7 @@ export class ToolHandler {
     );
 
     if (budget.includeRelationships && significantEdges.length > 0) {
-      lines.push('### Relationships');
+      lines.push('**Relationships**');
       lines.push('');
 
       // Group edges by kind for readability
@@ -2748,7 +2765,7 @@ export class ToolHandler {
       return false;
     };
 
-    lines.push('### Source Code');
+    lines.push('**Source Code**');
     lines.push('');
     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.');
     lines.push('');
@@ -2892,7 +2909,7 @@ export class ToolHandler {
           const tag = bodyIds.size > 0
             ? 'focused (the methods you named in full, the rest as signatures — codegraph_explore a signature by name for its body; do NOT Read)'
             : 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
-          lines.push(`#### ${filePath} — ${names} · ${tag}`, '', '```' + lang, skel.join('\n'), '```', '');
+          lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
           totalChars += skel.join('\n').length + 120;
           filesIncluded++;
           continue;
@@ -2933,7 +2950,7 @@ export class ToolHandler {
         )];
         const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
         const omitted = uniqSymbols.length - headerNames.length;
-        const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
+        const wholeHeader = fileSectionHeader(filePath, omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', '));
 
         if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
           // Don't slice a whole file mid-method: an incidental file that doesn't
@@ -3200,7 +3217,7 @@ export class ToolHandler {
       const headerSuffix = omittedCount > 0
         ? `${headerSymbols.join(', ')}, +${omittedCount} more`
         : headerSymbols.join(', ');
-      const fileHeader = `#### ${filePath} — ${headerSuffix}`;
+      const fileHeader = fileSectionHeader(filePath, headerSuffix);
 
       // The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
       // the agent named (or that's on the flow spine) renders even when the
@@ -3241,7 +3258,7 @@ export class ToolHandler {
         .sort((a, b) => b[1].score - a[1].score);
       const remainingFiles = [...remainingRelevant, ...peripheralFiles];
       if (remainingFiles.length > 0) {
-        lines.push('### Not shown above — explore these names for their source');
+        lines.push('**Not shown above — explore these names for their source**');
         lines.push('');
         for (const [filePath, group] of remainingFiles.slice(0, 10)) {
           const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
@@ -3290,13 +3307,13 @@ export class ToolHandler {
 
     const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
     if (output.length > hardCeiling) {
-      // Cut at a FILE-SECTION boundary (the last `#### ` header before the
+      // Cut at a FILE-SECTION boundary (the last ``**` `` file header before the
       // ceiling) so we drop whole trailing file-sections rather than slicing
       // through a method body — a half-rendered method just forces the Read this
       // tool exists to prevent. Fall back to a line boundary only if no section
       // header sits in the back half (degenerate single-giant-section case).
       const cut = output.slice(0, hardCeiling);
-      const lastSection = cut.lastIndexOf('\n#### ');
+      const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
       const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
       const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
       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.)');
@@ -3411,7 +3428,7 @@ export class ToolHandler {
       const shownList = listed.slice(0, LIST_CAP);
       out.push(
         '',
-        '### Other definitions',
+        '**Other definitions**',
         ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`),
       );
       if (listed.length > LIST_CAP) out.push(`- … +${listed.length - LIST_CAP} more`);
@@ -3493,7 +3510,7 @@ export class ToolHandler {
     // symbolsOnly → the cheap structural overview, no source.
     if (opts.symbolsOnly) {
       const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
-      if (nodes.length) out.push(...symbolMap('### Symbols'));
+      if (nodes.length) out.push(...symbolMap('**Symbols**'));
       else out.push('_No indexed symbols in this file._');
       out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
       return this.textResult(this.truncateOutput(out.join('\n')));
@@ -3503,7 +3520,7 @@ export class ToolHandler {
     // line is `key: <secret>`. Summarize by key and point to a real Read.
     if (CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
       const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
-      if (nodes.length) out.push(...symbolMap('### Keys (values withheld for safety)'));
+      if (nodes.length) out.push(...symbolMap('**Keys (values withheld for safety)**'));
       out.push('', '> Values may be secrets, so codegraph indexes keys only. Read the file directly if you need a value.');
       return this.textResult(this.truncateOutput(out.join('\n')));
     }
@@ -3517,7 +3534,7 @@ export class ToolHandler {
     }
     if (content === null) {
       const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
-      if (nodes.length) out.push(...symbolMap('### Symbols'));
+      if (nodes.length) out.push(...symbolMap('**Symbols**'));
       out.push('', `> Read \`${filePath}\` directly for its current content.`);
       return this.textResult(this.truncateOutput(out.join('\n')));
     }
@@ -3614,7 +3631,7 @@ export class ToolHandler {
     const callees = collect(cg.getCallees(node.id));
     const callers = collect(cg.getCallers(node.id));
     if (callees.length === 0 && callers.length === 0) return '';
-    const lines: string[] = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
+    const lines: string[] = ['', '**Trail — codegraph_node any of these to follow it (no Read needed)**'];
     if (callees.length > 0) {
       lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
     }
@@ -3650,7 +3667,7 @@ export class ToolHandler {
     const mismatch = this.worktreeMismatchFor(args.projectPath as string | undefined);
 
     const lines: string[] = [
-      '## CodeGraph Status',
+      '**CodeGraph Status**',
       '',
     ];
     if (mismatch) {
@@ -3681,7 +3698,7 @@ export class ToolHandler {
       );
     }
 
-    lines.push('', '### Nodes by Kind:');
+    lines.push('', '**Nodes by Kind:**');
 
     for (const [kind, count] of Object.entries(stats.nodesByKind)) {
       if ((count as number) > 0) {
@@ -3689,7 +3706,7 @@ export class ToolHandler {
       }
     }
 
-    lines.push('', '### Languages:');
+    lines.push('', '**Languages:**');
     for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
       if ((count as number) > 0) {
         lines.push(`- ${lang}: ${count}`);
@@ -3703,7 +3720,7 @@ export class ToolHandler {
     if (cg.isWatcherDegraded()) {
       lines.push(
         '',
-        '### Auto-sync disabled:',
+        '**Auto-sync disabled:**',
         `- ${cg.getWatcherDegradedReason() ?? 'live file watching stopped'}`,
         '- The index is frozen; Read files directly for current content.'
       );
@@ -3715,7 +3732,7 @@ export class ToolHandler {
     // banners on other tool calls.
     const pending = cg.getPendingFiles();
     if (pending.length > 0) {
-      lines.push('', '### Pending sync:');
+      lines.push('', '**Pending sync:**');
       const now = Date.now();
       for (const p of pending) {
         const ageMs = Math.max(0, now - p.lastSeenMs);
@@ -3806,7 +3823,7 @@ export class ToolHandler {
    * Format files as a flat list
    */
   private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
-    const lines: string[] = [`## Files (${files.length})`, ''];
+    const lines: string[] = [`**Files (${files.length})**`, ''];
 
     for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
       if (includeMetadata) {
@@ -3831,13 +3848,13 @@ export class ToolHandler {
       byLang.set(file.language, existing);
     }
 
-    const lines: string[] = [`## Files by Language (${files.length} total)`, ''];
+    const lines: string[] = [`**Files by Language (${files.length} total)**`, ''];
 
     // Sort languages by file count (descending)
     const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
 
     for (const [lang, langFiles] of sortedLangs) {
-      lines.push(`### ${lang} (${langFiles.length})`);
+      lines.push(`**${lang} (${langFiles.length})**`);
       for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
         if (includeMetadata) {
           lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
@@ -3889,7 +3906,7 @@ export class ToolHandler {
     }
 
     // Render tree
-    const lines: string[] = [`## Project Structure (${files.length} files)`, ''];
+    const lines: string[] = [`**Project Structure (${files.length} files)**`, ''];
 
     const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
       if (maxDepth !== undefined && depth > maxDepth) return;
@@ -4102,13 +4119,13 @@ export class ToolHandler {
   // =========================================================================
 
   private formatSearchResults(results: SearchResult[]): string {
-    const lines: string[] = [`## Search Results (${results.length} found)`, ''];
+    const lines: string[] = [`**Search Results (${results.length} found)**`, ''];
 
     for (const result of results) {
       const { node } = result;
       const location = node.startLine ? `:${node.startLine}` : '';
       // Compact format: one line per result with key info
-      lines.push(`### ${node.name} (${node.kind})`);
+      lines.push(`**${node.name}** (${node.kind})`);
       lines.push(`${node.filePath}${location}`);
       if (node.signature) lines.push(`\`${node.signature}\``);
       lines.push('');
@@ -4118,7 +4135,7 @@ export class ToolHandler {
   }
 
   private formatNodeList(nodes: Node[], title: string, labels?: Map<string, string>): string {
-    const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
+    const lines: string[] = [`**${title} (${nodes.length} found)**`, ''];
 
     for (const node of nodes) {
       const location = node.startLine ? `:${node.startLine}` : '';
@@ -4153,7 +4170,7 @@ export class ToolHandler {
 
     // Compact format: just list affected symbols grouped by file
     const lines: string[] = [
-      `## Impact: "${symbol}" affects ${nodeCount} symbols`,
+      `**Impact: "${symbol}" affects ${nodeCount} symbols**`,
       '',
     ];
 
@@ -4201,7 +4218,7 @@ export class ToolHandler {
   private formatNodeDetails(node: Node, code: string | null, outline?: string | null): string {
     const location = node.startLine ? `:${node.startLine}` : '';
     const lines: string[] = [
-      `## ${node.name} (${node.kind})`,
+      `**${node.name}** (${node.kind})`,
       '',
       `**Location:** ${node.filePath}${location}`,
     ];
diff --git a/src/reasoning/reasoner.ts b/src/reasoning/reasoner.ts
index b410c5b64..e3bb831e4 100644
--- a/src/reasoning/reasoner.ts
+++ b/src/reasoning/reasoner.ts
@@ -124,8 +124,8 @@ CORRECTNESS OVERRIDES EVERYTHING. Being incomplete is fine; being WRONG is not 
 const SYSTEM_PROMPT_REPORT = `${ROLE}
 
 Produce a single self-contained exploration report, formatted exactly like the summary a thorough senior engineer hands back after investigating. Clean Markdown, in this shape:
-- Open with the one-line coverage verdict (above). Then, ONLY if covered, a title: "## <Topic> — <Flow / Trace / Overview>". If coverage is not-found, the verdict + the names to explore next is the entire reply. NO preamble ("Here is", "Now I understand").
-- Body is numbered sections with bold headers: "### 1. **<step or aspect>**", "### 2. **<...>**", …
+- Open with the one-line coverage verdict (above). Then, ONLY if covered, a bold title: "**<Topic> — <Flow / Trace / Overview>**". If coverage is not-found, the verdict + the names to explore next is the entire reply. NO preamble ("Here is", "Now I understand"). Use bold labels for headers, never Markdown ATX headings (\`#\`/\`##\`) — they render oversized in some clients.
+- Body is numbered sections with bold headers: "**1. <step or aspect>**", "**2. <...>**", …
 - Cite every location inline and in bold as **\`path/to/file.ts:line\`** (or a line range), exactly as given in the source. Bold key classes, methods, and symbols.
 - For a flow/path question, include a call-chain diagram in a fenced code block using down-arrows:
   \`\`\`
@@ -135,7 +135,7 @@ Produce a single self-contained exploration report, formatted exactly like the s
   \`\`\`
 - Quote only the code lines that carry the logic, in fenced code blocks, keeping their line numbers. Keep snippets tight.
 - Separate major sections with a "---" rule.
-- End with "### Summary" — the end-to-end chain in one compact block.
+- End with "**Summary**" — the end-to-end chain in one compact block.
 
 Be precise and dense — an engineer should be able to act from this report without opening a file.`;
 
@@ -172,11 +172,13 @@ export function stripAgentDirectives(context: string): string {
   let i = 0;
   while (i < lines.length) {
     const ln = lines[i] ?? '';
-    if (/^##\s+Exploration:/.test(ln) || /^Found \d+ symbols? across \d+ files?/.test(ln)) { i++; continue; }
-    // "Not shown above" pointer section: drop header + its bullets/blanks until the next rule/heading/blockquote.
-    if (/^###\s+Not shown above/i.test(ln)) {
+    // Headers are bold labels, not ATX headings (tools.ts, issue #778): the
+    // explore header is `**Exploration: …**`, file sections start with ``**` ``.
+    if (/^\*\*Exploration:/.test(ln) || /^Found \d+ symbols? across \d+ files?/.test(ln)) { i++; continue; }
+    // "Not shown above" pointer section: drop header + its bullets/blanks until the next rule/header/blockquote.
+    if (/^\*\*Not shown above/i.test(ln)) {
       i++;
-      while (i < lines.length && !/^(---|#{2,4}\s|>\s)/.test(lines[i] ?? '')) i++;
+      while (i < lines.length && !/^(---|\*\*|>\s)/.test(lines[i] ?? '')) i++;
       continue;
     }
     // Agent-directed blockquote notes (completeness / budget / trimmed).


Test patch:
diff --git a/__tests__/adaptive-explore-sizing.test.ts b/__tests__/adaptive-explore-sizing.test.ts
index 809a885cf..1d76e611c 100644
--- a/__tests__/adaptive-explore-sizing.test.ts
+++ b/__tests__/adaptive-explore-sizing.test.ts
@@ -35,15 +35,16 @@ import CodeGraph from '../src/index';
 // (the steer-to-explore phrasing changed when the Read invitation was removed).
 const SKELETON_MARK = '· skeleton (signatures only';
 
-/** Return the `#### <path> ...` section for a file basename, header through the
- *  line before the next `###`/`####` header (or end of output). */
+/** Return the ``**`<path>`** ...`` section for a file basename, header through the
+ *  line before the next bold header (or end of output). Headers are bold labels,
+ *  not ATX headings (issue #778); file sections start with ``**` ``. */
 function sectionFor(text: string, basename: string): string {
   const lines = text.split('\n');
-  const start = lines.findIndex((l) => l.startsWith('#### ') && l.includes(basename));
+  const start = lines.findIndex((l) => l.startsWith('**`') && l.includes(basename));
   if (start < 0) return '';
   let end = lines.length;
   for (let i = start + 1; i < lines.length; i++) {
-    if (lines[i].startsWith('### ') || lines[i].startsWith('#### ')) {
+    if (lines[i].startsWith('**')) {
       end = i;
       break;
     }
@@ -284,7 +285,7 @@ export class YamlCodec extends Codec {
     const text = result.content?.[0]?.text ?? '';
 
     // Precondition: the spine must have formed, or nothing skeletonizes.
-    expect(text).toContain('## Flow (call path among the symbols you queried)');
+    expect(text).toContain('**Flow (call path among the symbols you queried)');
 
     for (const [file, marker] of [
       ['bridge-interceptor.ts', 'BRIDGE_BODY_MARKER'],
@@ -345,7 +346,7 @@ export class YamlCodec extends Codec {
   it('spares an off-spine sibling when the agent NAMED a callable in it (RealCall fix)', async () => {
     const result = await handler.execute('codegraph_explore', { query: SPARE_QUERY, maxFiles: 15 });
     const text = result.content?.[0]?.text ?? '';
-    expect(text).toContain('## Flow (call path among the symbols you queried)');
+    expect(text).toContain('**Flow (call path among the symbols you queried)');
 
     // auth-interceptor.ts is an off-spine Interceptor sibling — would skeletonize —
     // but the agent named its method `authenticate`, so it stays FULL.
diff --git a/__tests__/dynamic-boundaries.test.ts b/__tests__/dynamic-boundaries.test.ts
index d6a220637..d9f63552d 100644
--- a/__tests__/dynamic-boundaries.test.ts
+++ b/__tests__/dynamic-boundaries.test.ts
@@ -184,7 +184,7 @@ describe('codegraph_explore — dynamic boundaries', () => {
     const res = await handler.execute('codegraph_explore', { query: 'routeSave onSave' });
     const text = res.content[0].text as string;
 
-    expect(text).toContain('## Dynamic boundaries');
+    expect(text).toContain('**Dynamic boundaries');
     expect(text).toContain('computed member call');
     expect(text).toMatch(/router\.ts:6/); // the exact dispatch site
     expect(text).toContain('candidates for key `save`');
@@ -212,7 +212,7 @@ describe('codegraph_explore — dynamic boundaries', () => {
     const res = await handler.execute('codegraph_explore', { query: 'route onSave' });
     const text = res.content[0].text as string;
 
-    expect(text).toContain('## Dynamic boundaries');
+    expect(text).toContain('**Dynamic boundaries');
     expect(text).toContain('computed member call');
     expect(text).not.toContain('candidates for key'); // runtime key → no shortlist to claim
   });
@@ -234,7 +234,7 @@ describe('codegraph_explore — dynamic boundaries', () => {
     // `processPayment` does not exist anywhere — only `route` resolves.
     const res = await handler.execute('codegraph_explore', { query: 'route processPayment' });
     const text = res.content[0].text as string;
-    expect(text).toContain('## Dynamic boundaries');
+    expect(text).toContain('**Dynamic boundaries');
   });
 
   it('renders a direct synthesized emit→handler hop as a dynamic-dispatch link (#687 criterion 1)', async () => {
@@ -267,11 +267,11 @@ describe('codegraph_explore — dynamic boundaries', () => {
     const res = await handler.execute('codegraph_explore', { query: 'completeCheckout settleInvoice' });
     const text = res.content[0].text as string;
 
-    expect(text).toContain('## Dynamic-dispatch links among your symbols');
+    expect(text).toContain('**Dynamic-dispatch links among your symbols');
     expect(text).toMatch(/completeCheckout → settleInvoice/);
     expect(text).toContain('invoice.settled');
     // Connected via the synthesized edge — no boundary to announce.
-    expect(text).not.toContain('## Dynamic boundaries');
+    expect(text).not.toContain('**Dynamic boundaries');
   });
 
   it('never adds the section to a fully connected flow', async () => {
@@ -285,8 +285,8 @@ describe('codegraph_explore — dynamic boundaries', () => {
 
     const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
     const text = res.content[0].text as string;
-    expect(text).toContain('## Flow');
-    expect(text).not.toContain('## Dynamic boundaries');
+    expect(text).toContain('**Flow');
+    expect(text).not.toContain('**Dynamic boundaries');
   });
 
   it('python getattr dispatch surfaces with a prefix-key candidate', async () => {
@@ -305,7 +305,7 @@ describe('codegraph_explore — dynamic boundaries', () => {
     const res = await handler.execute('codegraph_explore', { query: 'process handle_save' });
     const text = res.content[0].text as string;
 
-    expect(text).toContain('## Dynamic boundaries');
+    expect(text).toContain('**Dynamic boundaries');
     expect(text).toContain('getattr');
     expect(text).toContain('handle_save');
   });
@@ -373,7 +373,7 @@ describe('codegraph_explore — interface dispatch', () => {
     const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
     const text = res.content[0].text as string;
 
-    expect(text).toContain('## Interface dispatch (a named method has many implementations)');
+    expect(text).toContain('**Interface dispatch (a named method has many implementations)');
     expect(text).toMatch(/`execute` → runtime dispatch to \*\*9\*\* types implementing `INodeType`/);
     // a couple of concrete targets, with file:line
     expect(text).toMatch(/\b\w+Node\.execute` \(/);
@@ -392,8 +392,8 @@ describe('codegraph_explore — interface dispatch', () => {
 
     const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
     const text = res.content[0].text as string;
-    expect(text).toContain('## Flow');
-    expect(text).not.toContain('## Interface dispatch');
+    expect(text).toContain('**Flow');
+    expect(text).not.toContain('**Interface dispatch');
   });
 
   it('stays SILENT when the interface family is below the polymorphism threshold (3 impls)', async () => {
@@ -401,6 +401,6 @@ describe('codegraph_explore — interface dispatch', () => {
 
     const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
     const text = res.content[0].text as string;
-    expect(text).not.toContain('## Interface dispatch');
+    expect(text).not.toContain('**Interface dispatch');
   });
 });
diff --git a/__tests__/explore-blast-radius.test.ts b/__tests__/explore-blast-radius.test.ts
index 23bb84b20..e85b0738e 100644
--- a/__tests__/explore-blast-radius.test.ts
+++ b/__tests__/explore-blast-radius.test.ts
@@ -55,7 +55,7 @@ describe('codegraph_explore — blast radius', () => {
     const res = await handler.execute('codegraph_explore', { query: 'target' });
     const text = res.content[0].text;
 
-    expect(text).toContain('### Blast radius');
+    expect(text).toContain('**Blast radius');
     expect(text).toContain('`target`');
     expect(text).toMatch(/caller/); // a caller count is reported
     // It names WHERE (the caller file) — not the caller's source body.
diff --git a/__tests__/explore-corroboration-ranking.test.ts b/__tests__/explore-corroboration-ranking.test.ts
index c9c16188a..444d8440c 100644
--- a/__tests__/explore-corroboration-ranking.test.ts
+++ b/__tests__/explore-corroboration-ranking.test.ts
@@ -28,11 +28,12 @@ import * as os from 'os';
 import CodeGraph from '../src/index';
 import { ToolHandler } from '../src/mcp/tools';
 
-/** Paths that explore rendered as full-body `#### <path> —` source sections. */
+/** Paths that explore rendered as full-body ``**`<path>`** —`` source sections.
+ *  Headers are bold labels, not ATX headings (issue #778). */
 function sourcedFiles(text: string): string[] {
   const out: string[] = [];
   for (const line of text.split('\n')) {
-    const m = line.match(/^#### (.+?) —/);
+    const m = line.match(/^\*\*`(.+?)`\*\* —/);
     if (m) out.push(m[1].trim());
   }
   return out;
diff --git a/__tests__/explore-output-budget.test.ts b/__tests__/explore-output-budget.test.ts
index 0726c5684..5e1464741 100644
--- a/__tests__/explore-output-budget.test.ts
+++ b/__tests__/explore-output-budget.test.ts
@@ -206,8 +206,8 @@ describe('codegraph_explore output respects the adaptive budget', () => {
     const text = result.content?.[0]?.text ?? '';
     // Either there are relationships, or no edges were significant — both are fine.
     // We just want to confirm we did not accidentally gate it off.
-    const hasRelationships = text.includes('### Relationships');
-    const sourceFollowsHeader = text.indexOf('### Source Code') > 0;
+    const hasRelationships = text.includes('**Relationships');
+    const sourceFollowsHeader = text.indexOf('**Source Code') > 0;
     expect(hasRelationships || sourceFollowsHeader).toBe(true);
   });
 
diff --git a/__tests__/explore-synth-constant-endpoints.test.ts b/__tests__/explore-synth-constant-endpoints.test.ts
index 50c8a3565..270cb9092 100644
--- a/__tests__/explore-synth-constant-endpoints.test.ts
+++ b/__tests__/explore-synth-constant-endpoints.test.ts
@@ -9,7 +9,7 @@
  * "### Relationships" section would have caught it, but that is disabled below 500 files.
  * Net: on a small RTK app the synthesized edge existed in the graph yet was invisible to
  * the agent. The fix feeds a `dynNamed` set (named non-callable endpoints that participate
- * in a heuristic edge) to the tier-independent "## Dynamic-dispatch links" scan. This test
+ * in a heuristic edge) to the tier-independent "**Dynamic-dispatch links**" scan. This test
  * pins it on a deliberately tiny (<150-file) fixture so the Relationships gate is OFF and
  * the dynamic-dispatch-links path is the ONLY thing that can surface the hop.
  */
@@ -77,7 +77,7 @@ export const outerThunk = createAsyncThunk('app/outer', async (n: number, { disp
 
     // The synthesized hop now surfaces (was invisible: both endpoints `constant` AND the
     // small-repo Relationships section is off).
-    expect(text).toContain('## Dynamic-dispatch links among your symbols');
+    expect(text).toContain('**Dynamic-dispatch links among your symbols');
     expect(text).toMatch(/outerThunk\s+→\s+innerThunk/);
     // It reads as a dynamic-dispatch bridge with its wiring site, not a bare `calls`.
     expect(text).toMatch(/dynamic: redux thunk @/);
diff --git a/__tests__/mcp-staleness-banner.test.ts b/__tests__/mcp-staleness-banner.test.ts
index bdf17ed48..4b51e1231 100644
--- a/__tests__/mcp-staleness-banner.test.ts
+++ b/__tests__/mcp-staleness-banner.test.ts
@@ -175,7 +175,7 @@ describe('MCP staleness banner', () => {
 
     const res = await handler.execute('codegraph_status', {});
     const text = res.content[0].text;
-    expect(text).toContain('### Pending sync:');
+    expect(text).toContain('**Pending sync:');
     expect(text).toContain('src/charlie-only.ts');
     // Status embeds the info first-class, so the auto-banner is suppressed.
     expect(text.startsWith('⚠️')).toBe(false);
@@ -204,7 +204,7 @@ describe('MCP staleness banner', () => {
 
     const res = await handler.execute('codegraph_status', {});
     const text = res.content[0].text;
-    expect(text).toContain('### Auto-sync disabled:');
+    expect(text).toContain('**Auto-sync disabled:');
     expect(text).toContain('OS watch/file limit exhausted');
     // status renders the notice inline, so the auto-banner is not also prepended.
     expect(text.startsWith('⚠️')).toBe(false);
diff --git a/__tests__/node-file-view.test.ts b/__tests__/node-file-view.test.ts
index 7d2a5703c..9479d8198 100644
--- a/__tests__/node-file-view.test.ts
+++ b/__tests__/node-file-view.test.ts
@@ -99,7 +99,7 @@ describe('codegraph_node file-view (Read replacement)', () => {
 
   it('symbolsOnly returns the structural map, not the source', async () => {
     const out = await text({ file: 'a.ts', symbolsOnly: true });
-    expect(out).toContain('### Symbols');
+    expect(out).toContain('**Symbols');
     expect(out).toContain('helper');
     expect(out).toContain('Widget');
     expect(out).not.toContain('return x + 1'); // bodies are NOT included in the map
diff --git a/__tests__/offload.test.ts b/__tests__/offload.test.ts
index f71c3e864..838b461bc 100644
--- a/__tests__/offload.test.ts
+++ b/__tests__/offload.test.ts
@@ -231,16 +231,16 @@ describe('reasoning offload', () => {
   describe('stripAgentDirectives', () => {
     it('drops the agent-directed header but keeps source sections', () => {
       const ctx = [
-        '## Exploration: how does X work',
+        '**Exploration: how does X work**',
         'Found 12 symbols across 3 files.',
         '',
-        '#### src/a.ts — foo(function)',
+        '**`src/a.ts`** — foo(function)',
         'code body',
       ].join('\n');
       const stripped = stripAgentDirectives(ctx);
-      expect(stripped).not.toContain('## Exploration:');
+      expect(stripped).not.toContain('**Exploration:');
       expect(stripped).not.toContain('Found 12 symbols');
-      expect(stripped).toContain('#### src/a.ts');
+      expect(stripped).toContain('**`src/a.ts`');
       expect(stripped).toContain('code body');
     });
   });
diff --git a/__tests__/same-name-disambiguation.test.ts b/__tests__/same-name-disambiguation.test.ts
index 5c1ae4f1c..7c260c7c4 100644
--- a/__tests__/same-name-disambiguation.test.ts
+++ b/__tests__/same-name-disambiguation.test.ts
@@ -99,7 +99,10 @@ describe('same-named symbols across apps (#764)', () => {
     expect(out).toContain('apps/billing/src/users/user.service.ts');
     // …and the billing section must list the billing controller, not admin's.
     const billingSection = out.slice(out.indexOf('apps/billing/src/users/user.service.ts'));
-    const billingBody = billingSection.slice(0, billingSection.indexOf('###', 3) > 0 ? billingSection.indexOf('###', 3) : undefined);
+    // The next definition heading is a line-start bold label (issue #778: ATX `###`
+    // headings became `**…**`); billingSection starts mid-heading, so `\n**` finds it.
+    const nextDef = billingSection.indexOf('\n**');
+    const billingBody = billingSection.slice(0, nextDef > 0 ? nextDef : undefined);
     expect(billingBody).toContain('apps/billing/src/users/user.controller.ts');
     expect(billingBody).not.toContain('apps/admin/src/users/user.controller.ts');
   });