Browse Source

fix(mcp): bold labels instead of ATX headings in tool results (#778) (#951)

MCP tool results used Markdown ATX headings (##/###/####) for section
headers — the status summary, each search hit, every file section in an
exploration — which Markdown-rendering clients (e.g. the Claude Code
VSCode extension) blow up to H1–H4 font size, filling the transcript with
oversized lines (worst on search/explore, where the noise scales with
result count). Swap them all for bold labels, which render at body size
while keeping the same structure. CLI/TTY output (ContextBuilder) is
unchanged — the issue notes it's fine.

The format is parse-coupled, so kept in sync:
- The explore truncation boundary and the offload chunker
  (reasoning/reasoner.ts) both key off the per-file header, now a unique
  `**`-prefixed marker emitted via a shared fileSectionHeader() helper.
- Updated the offload strip regexes and switched the opt-in report-style
  prompt off ATX headings (same client, same rendering issue).
- Updated test helpers (sectionFor, sourcedFiles, the callers
  section-boundary scan) that scanned the old markers.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby Mchenry 16 hours ago
parent
commit
3e1547bbe1

+ 1 - 0
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)

+ 7 - 6
__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.

+ 12 - 12
__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');
   });
 });

+ 1 - 1
__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.

+ 3 - 2
__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;

+ 2 - 2
__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);
   });
 

+ 2 - 2
__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 @/);

+ 2 - 2
__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);

+ 1 - 1
__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

+ 4 - 4
__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');
     });
   });

+ 4 - 1
__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');
   });

+ 1 - 1
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;

+ 1 - 1
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 {}

+ 56 - 39
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}`,
     ];

+ 9 - 7
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).