Procházet zdrojové kódy

feat(ui): the Flow strip — how one symbol reaches another, one card per hop (CG-50)

Ask "how does execute reach getFile" in the search box and the viewer draws the
call path between them, left to right, opening every card at the exact line that
makes the next call. Dynamic-dispatch hops are dashed and name the site they
were wired at; "Read as flow" turns a trail walked by hand into the same strip.

The path finder is NOT new. `codegraph_explore` already leads its answers with
the longest call chain among the symbols an agent named, and a viewer that drew
a different path would get the two quoted against each other in a review. So the
search moved out of `ToolHandler` into `src/graph/named-symbol-flow.ts` and both
callers ride it — same tokens, same overload rules, same synthesized edges. What
stayed behind in `tools.ts` is the prose.

A pinned from/to question is the same search with two options changed, because
both ends being named is the evidence explore's one-unnamed-bridge cap stands in
for: it bridges freely, keeps twelve candidates per endpoint instead of six
(the CLI's own `main` sorts seventh of ten), and searches from both ends at once
— identical paths to the one-way walk on twelve measured pairs, 3-6x faster.

`/api/flow` is deliberately the one endpoint with no cache: its cards carry
source read from disk, and a drift verdict changes without the index changing.

Verified on this repo (`execute` to `rowToFileRecord`, 8 hops; `main` to
`resolveOne`, 7) and on a fresh excalidraw index, where `mutateElement` to
`renderStaticScene` crosses callback, react-render and jsx-child hops and lists
exactly the hops `codegraph_explore` prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry před 1 týdnem
rodič
revize
62e0a89b0e

+ 6 - 0
CHANGELOG.md

@@ -26,6 +26,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   It opens on your project's source directory; a picker switches to any other top-level folder or the whole repository, a checkbox brings tests in, and `depth` splits a large folder into its sub-folders — useful on a monorepo. What you're looking at lives in the address, so the view is shareable.
 
+- **Ask how one symbol reaches another, in `codegraph ui`.** Type "how does execute reach getFile" into the search box — or `execute -> getFile` — and the Flow strip draws the call path between them, left to right, one card per hop. Each card is opened at the exact line that makes the next call rather than at the top of the function, so reading the strip is reading the handful of lines that actually carry the work; the identifier being called is a link, and clicking a card opens it in the symbol screen with the trail already set to the path you've read so far.
+
+  A dashed link is a hop nobody can see in the source — a callback, an interface dispatch, a React re-render, a JSX child — and it names the mechanism and, where CodeGraph knows it, the exact line the handler was wired at. When a name means several definitions, the strip says so and names the one the path runs through, offers the alternatives in a picker, and can draw them together as one branching diagram. "Not connected" is an answer rather than a failure: a flow that runs through a dispatch no static edge records genuinely has no path, and the screen says so instead of inventing one.
+
+  The **"Read as flow"** button on the trail turns a walk you did by hand into the same strip. It is the same path finder `codegraph_explore` leads its answers with, so the picture and what your agent tells you can't disagree.
+
 
 ## [1.6.0] - 2026-08-26
 

+ 2 - 0
README.md

@@ -346,6 +346,8 @@ What you get on that screen:
 - **Honest edges.** A guess CodeGraph isn't sure about is folded away as "uncertain" rather than shown as fact, and a symbol no test reaches within three hops says so.
 - **Search** (`/` or ⌘K) over every symbol and file, **entry points** to start from (routes, hubs, files that run code at import time), and a **trail** of the path you walked that lives in the URL, so you can send someone the exact route you took.
 - Click any file path to open the **file view**: everything that file depends on, its outline in source order, and everything that depends on it.
+- **Ask for a path.** Type "how does execute reach getFile" (or `execute -> getFile`) and you get the **flow**: one card per hop, each opened at the line that makes the next call. Hops that no static edge records — a callback, an interface dispatch, a React re-render — are drawn dashed and name where the handler was wired. "Read as flow" turns a walk you did by hand into the same strip.
+- **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away.
 
 Options: `--port <n>` to pin a port (without it the viewer takes 4747, or the next free one),
 `--no-open` to just print the URL for a headless box or an SSH session, and

+ 452 - 0
__tests__/ui-flow-api.test.ts

@@ -0,0 +1,452 @@
+/**
+ * `GET /api/flow` — the call path behind the Flow strip (CG-50).
+ *
+ * Against a real indexed fixture over a real loopback server, like the rest of
+ * the viewer's API suite. The fixture is shaped to produce the four things this
+ * endpoint has to get right and that a synthetic payload cannot prove:
+ *
+ * - a real five-hop chain of calls, so the hops, their edges, and the line each
+ *   card is opened at all come out of the graph rather than out of a fixture
+ *   object,
+ * - two definitions of the same name, one of them in a test file, so the
+ *   directed search's overload handling and the `ambiguous` report can be
+ *   checked (this is the shape that broke `main` on the engine's own index —
+ *   the right definition sorted seventh),
+ * - a symbol nothing reaches, so "no path" is exercised as the ordinary answer
+ *   it is rather than as an error,
+ * - a Go interface with one implementation, so a SYNTHESIZED hop — the thing
+ *   the strip draws dashed and labels with its wiring site — is a real edge
+ *   from the resolver rather than a hand-written metadata blob.
+ *
+ * The pure geometry is tested without a server in `ui-flow-model.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
+import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
+import type { Edge } from '../src/types';
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+
+function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port: server.port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${server.port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+async function getFlow(query: string, expected = 200): Promise<any> {
+  const res = await request(`/api/flow${query}`);
+  expect(res.type).toBe('application/json; charset=utf-8');
+  expect(res.status).toBe(expected);
+  return JSON.parse(res.body);
+}
+
+function write(root: string, rel: string, body: string): void {
+  const full = path.join(root, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, body);
+}
+
+/** `name` at each hop, so an assertion reads like the strip does. */
+function names(flow: any): string[] {
+  return flow.hops.map((h: any) => h.node.name);
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-flow-'));
+  projectRoot = path.join(tempDir, 'project');
+
+  // A five-hop chain: bootstrap -> handleRequest -> loadRow -> readRow -> toRow.
+  write(
+    projectRoot,
+    'src/main.ts',
+    `import { handleRequest } from './server/handler';
+
+export function bootstrap(): string {
+  const banner = 'ready';
+  return handleRequest(banner);
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/server/handler.ts',
+    `import { loadRow } from '../db/rows';
+
+export function handleRequest(id: string): string {
+  const trimmed = id.trim();
+  return loadRow(trimmed);
+}
+
+/** Nothing on the chain calls this — it is the "no path" endpoint. */
+export function orphanHandler(): string {
+  return 'nobody calls me';
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/db/rows.ts',
+    `export function loadRow(id: string): string {
+  return readRow(id);
+}
+
+function readRow(id: string): string {
+  return toRow(id);
+}
+
+function toRow(id: string): string {
+  return id.toUpperCase();
+}
+`
+  );
+  // Two `describe` definitions, one of them in a test file: the ambiguity the
+  // directed search has to walk past rather than truncate away.
+  write(
+    projectRoot,
+    'src/db/describe.ts',
+    `import { loadRow } from './rows';
+
+export function describeRow(id: string): string {
+  return loadRow(id);
+}
+`
+  );
+  write(
+    projectRoot,
+    '__tests__/rows.test.ts',
+    `export function describeRow(id: string): string {
+  return id;
+}
+`
+  );
+
+  // A Go interface with one implementation: the resolver synthesizes an
+  // interface-impl `calls` edge across it, which is what the strip draws dashed.
+  write(
+    projectRoot,
+    'go/clock.go',
+    `package clock
+
+type Clock interface {
+	Now() string
+}
+
+type SystemClock struct{}
+
+func (SystemClock) Now() string {
+	return stamp()
+}
+
+func stamp() string {
+	return "now"
+}
+
+func Tick(c Clock) string {
+	return c.Now()
+}
+`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', '__tests__/**/*.ts', 'go/**/*.go'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  cg.close();
+
+  const viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  await server?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('parseFlowQuery', () => {
+  it('reads the three shapes and refuses the empty one', () => {
+    expect(parseFlowQuery(new URLSearchParams('from=a&to=b'))).toEqual({
+      kind: 'directed',
+      from: 'a',
+      to: 'b',
+    });
+    expect(parseFlowQuery(new URLSearchParams('symbols=a,b,c'))).toEqual({
+      kind: 'symbols',
+      text: 'a,b,c',
+    });
+    expect(parseFlowQuery(new URLSearchParams('hop=sx&hop=dy&hop=uz'))).toEqual({
+      kind: 'trail',
+      hops: [
+        { id: 'x', dir: 'start' },
+        { id: 'y', dir: 'down' },
+        { id: 'z', dir: 'up' },
+      ],
+    });
+    expect(() => parseFlowQuery(new URLSearchParams(''))).toThrow(/No flow was asked for/);
+  });
+
+  it('refuses a pair that names the same symbol twice', () => {
+    expect(() => parseFlowQuery(new URLSearchParams('from=run&to=run'))).toThrow(/same symbol/);
+  });
+
+  it('takes a trail over a from/to pair, and refuses a one-hop trail', () => {
+    // A hop parameter is only ever sent by "Read as flow", which is a complete
+    // question on its own; a stray `from` alongside it must not be searched.
+    const parsed = parseFlowQuery(new URLSearchParams('from=a&to=b&hop=sx&hop=dy'));
+    expect(parsed.kind).toBe('trail');
+    expect(() => parseFlowQuery(new URLSearchParams('hop=sx'))).toThrow(/at least two hops/);
+  });
+});
+
+describe('flowEdgeLabel', () => {
+  const edge = (metadata: Record<string, unknown>, provenance = 'heuristic'): Edge =>
+    ({ kind: 'calls', source: 'a', target: 'b', provenance, metadata }) as unknown as Edge;
+
+  it('names the mechanism and the wiring site for a synthesized hop', () => {
+    expect(
+      flowEdgeLabel(edge({ synthesizedBy: 'callback', registeredAt: 'src/a.ts:12' }), false)
+    ).toBe('via callback · registered at src/a.ts:12');
+  });
+
+  it('never lets a synthesized hop read as a plain call', () => {
+    expect(flowEdgeLabel(edge({ synthesizedBy: 'react-render' }), false)).toBe('via react render');
+  });
+
+  it('says "called by" when the reader walked the edge backwards', () => {
+    expect(flowEdgeLabel(edge({}, 'resolved'), true)).toBe('called by');
+    expect(flowEdgeLabel(edge({}, 'resolved'), false)).toBe('calls');
+  });
+});
+
+describe('GET /api/flow — a directed question', () => {
+  it('returns the whole chain, one hop per card', async () => {
+    const payload = await getFlow('?from=bootstrap&to=toRow');
+    expect(payload.query).toMatchObject({ kind: 'directed', from: 'bootstrap', to: 'toRow' });
+    expect(payload.reason).toBeNull();
+    expect(payload.flows).toHaveLength(1);
+    expect(names(payload.flows[0])).toEqual([
+      'bootstrap',
+      'handleRequest',
+      'loadRow',
+      'readRow',
+      'toRow',
+    ]);
+    expect(payload.flows[0].label).toBe('bootstrap → toRow');
+  });
+
+  it('opens each card at the line that calls the next one', async () => {
+    const { flows } = await getFlow('?from=bootstrap&to=toRow');
+    const hops = flows[0].hops;
+    for (let i = 0; i < hops.length - 1; i++) {
+      const ref = hops[i].callRef;
+      expect(ref, `hop ${i} has a call site`).not.toBeNull();
+      expect(ref.name).toBe(hops[i + 1].node.name);
+      expect(ref.targetId).toBe(hops[i + 1].node.id);
+      expect(ref.backwards).toBe(false);
+      // The window is centred on it, and the source really contains it.
+      expect(ref.line).toBeGreaterThanOrEqual(hops[i].source.from);
+      expect(ref.line).toBeLessThanOrEqual(hops[i].source.to);
+      const offset = ref.line - hops[i].source.from;
+      expect(hops[i].source.lines[offset]).toContain(hops[i + 1].node.name);
+    }
+    // The last card has nothing to call, so it opens at its own definition.
+    const last = hops[hops.length - 1];
+    expect(last.callRef).toBeNull();
+    expect(last.source.from).toBeLessThanOrEqual(last.node.line);
+    expect(last.source.to).toBeGreaterThanOrEqual(last.node.line);
+  });
+
+  it('carries the edge on every hop but the first, with its line', async () => {
+    const { flows } = await getFlow('?from=bootstrap&to=toRow');
+    const hops = flows[0].hops;
+    expect(hops[0].edge).toBeNull();
+    for (let i = 1; i < hops.length; i++) {
+      expect(hops[i].edge.kind).toBe('calls');
+      expect(hops[i].edge.label).toBe('calls');
+      expect(hops[i].edge.upward).toBe(false);
+      expect(hops[i].edge.synthesized).toBe(false);
+      // The edge's line is the previous card's call site — the two agree, and
+      // the strip prints both, so a disagreement would be visible.
+      expect(hops[i].edge.line).toBe(hops[i - 1].callRef.line);
+    }
+  });
+
+  it('highlights each card with real source, never a drifted slice', async () => {
+    const { flows } = await getFlow('?from=bootstrap&to=toRow');
+    for (const hop of flows[0].hops) {
+      expect(hop.source.drift).toBe(false);
+      expect(hop.source.lines.length).toBeGreaterThan(0);
+      expect(hop.source.lines.length).toBe(hop.source.to - hop.source.from + 1);
+      // Highlight rides with the slice and is line-for-line with it (CG-43).
+      expect(hop.source.highlight.lines).toHaveLength(hop.source.lines.length);
+    }
+  });
+
+  it('answers "not connected" as an ordinary answer, with a reason', async () => {
+    const payload = await getFlow('?from=bootstrap&to=orphanHandler');
+    expect(payload.flows).toEqual([]);
+    expect(payload.reason).toMatch(/No chain of calls reaches orphanHandler/);
+    expect(payload.reason).toMatch(/dynamic dispatch/);
+    expect(payload.unresolved).toEqual([]);
+  });
+
+  it('says which names matched nothing rather than blaming the path', async () => {
+    const payload = await getFlow('?from=bootstrap&to=thisNameIsNotHere');
+    expect(payload.unresolved).toEqual(['thisNameIsNotHere']);
+    expect(payload.reason).toMatch(/thisNameIsNotHere names nothing/);
+  });
+
+  it('walks past an overload in a test file and reports the ambiguity', async () => {
+    const payload = await getFlow('?from=describeRow&to=toRow');
+    expect(names(payload.flows[0])).toEqual(['describeRow', 'loadRow', 'readRow', 'toRow']);
+    const ambiguity = payload.ambiguous.find((a: any) => a.token === 'describeRow');
+    expect(ambiguity).toBeDefined();
+    expect(ambiguity.chosen.file).toBe('src/db/describe.ts');
+    expect(ambiguity.others.map((o: any) => o.file)).toContain('__tests__/rows.test.ts');
+  });
+});
+
+describe('GET /api/flow — a synthesized hop', () => {
+  it('draws the interface bridge as a dashed hop that names its mechanism', async () => {
+    const payload = await getFlow('?from=Tick&to=stamp');
+    expect(payload.flows.length).toBeGreaterThan(0);
+    const hops = payload.flows[0].hops;
+    expect(names(payload.flows[0])[0]).toBe('Tick');
+    expect(names(payload.flows[0]).at(-1)).toBe('stamp');
+    const synthesized = hops.filter((h: any) => h.edge?.synthesized);
+    expect(synthesized.length).toBeGreaterThan(0);
+    for (const hop of synthesized) {
+      expect(hop.edge.provenance).toBe('heuristic');
+      expect(hop.edge.label).toMatch(/^via /);
+      expect(hop.edge.label).not.toBe('calls');
+    }
+  });
+});
+
+describe('GET /api/flow — explore parity', () => {
+  it('answers a ?symbols= question with the chain the explore search finds', async () => {
+    const payload = await getFlow('?symbols=bootstrap,loadRow,toRow');
+    expect(payload.query.kind).toBe('symbols');
+    expect(payload.flows.length).toBeGreaterThan(0);
+
+    // The endpoint must not have its own path finder. Run the engine's directly
+    // and require the same hops, in the same order.
+    const cg = CodeGraph.openSync(projectRoot);
+    try {
+      const flow = resolveNamedSymbolFlow(cg, 'bootstrap,loadRow,toRow');
+      expect(flow.chains[0]?.steps.map((s) => s.node.id)).toEqual(
+        payload.flows[0].hops.map((h: any) => h.node.id)
+      );
+    } finally {
+      cg.close();
+    }
+  });
+});
+
+describe('GET /api/flow — a trail read as a flow', () => {
+  it('draws the hops it was given, finding the edge that already joins them', async () => {
+    const forward = await getFlow('?from=bootstrap&to=toRow');
+    const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id);
+    const query = ids
+      .map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'd'}${id}`)}`)
+      .join('&');
+
+    const payload = await getFlow(`?${query}`);
+    expect(payload.query.kind).toBe('trail');
+    expect(payload.flows[0].hops.map((h: any) => h.node.id)).toEqual(ids);
+    expect(payload.flows[0].hops[1].edge.kind).toBe('calls');
+    expect(payload.flows[0].hops[1].edge.upward).toBe(false);
+  });
+
+  it('reads a trail walked BACKWARDS as caller hops, opened at the calling line', async () => {
+    const forward = await getFlow('?from=bootstrap&to=toRow');
+    const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id).reverse();
+    const query = ids
+      .map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'u'}${id}`)}`)
+      .join('&');
+
+    const payload = await getFlow(`?${query}`);
+    const hops = payload.flows[0].hops;
+    expect(hops.map((h: any) => h.node.id)).toEqual(ids);
+    // Every hop after the first is the caller of the one before it, so its own
+    // body holds the call — and the card opens there, pointing BACK.
+    for (let i = 1; i < hops.length; i++) {
+      expect(hops[i].edge.upward).toBe(true);
+      expect(hops[i].edge.label).toBe('called by');
+      expect(hops[i].callRef.backwards).toBe(true);
+      expect(hops[i].callRef.name).toBe(hops[i - 1].node.name);
+      expect(hops[i].callRef.line).toBe(hops[i].edge.line);
+    }
+    // The first card is the callee: nothing in it calls anything on this trail.
+    expect(hops[0].callRef).toBeNull();
+  });
+
+  it('says so when the ids on a trail are no longer in the index', async () => {
+    const payload = await getFlow('?hop=smethod%3Agone&hop=dmethod%3Aalso-gone');
+    expect(payload.flows).toEqual([]);
+    expect(payload.unresolved).toEqual(['method:gone', 'method:also-gone']);
+    expect(payload.reason).toMatch(/still in the index/);
+  });
+});
+
+describe('GET /api/flow — refusals', () => {
+  it('answers JSON, not text, when the question is malformed', async () => {
+    const payload = await getFlow('', 400);
+    expect(payload.code).toBe('bad-request');
+    expect(payload.error).toMatch(/No flow was asked for/);
+    expect(payload.hint).toMatch(/\?from=/);
+  });
+
+  it('caps the number of trail hops it will read', async () => {
+    const query = Array.from({ length: 40 }, (_, i) => `hop=s${i}xx`).join('&');
+    const payload = await getFlow(`?${query}`, 400);
+    expect(payload.code).toBe('bad-request');
+    expect(payload.error).toMatch(/longer than this endpoint reads/);
+  });
+
+  it('is listed on the API index', async () => {
+    const res = await request('/api');
+    const body = JSON.parse(res.body);
+    const entry = body.endpoints.find((e: any) => e.path === '/api/flow');
+    expect(entry).toBeDefined();
+    expect(entry.params).toContain('from');
+    expect(entry.params).toContain('hop');
+  });
+});

+ 293 - 0
__tests__/ui-flow-model.test.ts

@@ -0,0 +1,293 @@
+/**
+ * The Flow strip's geometry (CG-50) — `ui/src/lib/flow-model.ts`.
+ *
+ * Pure functions, no browser: this is where the strip's two load-bearing claims
+ * are checked. That a card's height is ARITHMETIC (the CSS pins the same
+ * number, so an arrow lands where the layout said it would), and that a column
+ * is a card's LONGEST distance from a start (so two routes that rejoin do so in
+ * the same column, and nothing is ever drawn left of something that calls it).
+ *
+ * The endpoint that feeds it is tested against a real index in
+ * `ui-flow-api.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildFlowLayout,
+  cardHeight,
+  dashFor,
+  labelLinesFor,
+  lineLabelFor,
+  CARD_WIDTH,
+  CODE_LINE_HEIGHT,
+  CODE_PADDING,
+  COLUMN_PITCH,
+  HEADER_HEIGHT,
+  LABEL_MAX_CHARS,
+  LINK_WIDTH,
+  NO_SOURCE_HEIGHT,
+  PADDING,
+  ROW_GAP,
+} from '../ui/src/lib/flow-model';
+import type { WireFlow, WireFlowEdge, WireFlowHop } from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- builders -- */
+
+function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
+  return {
+    kind: 'calls',
+    label: 'calls',
+    upward: false,
+    uncertain: false,
+    synthesized: false,
+    ...over,
+  };
+}
+
+function hop(name: string, opts: { lines?: number; edge?: WireFlowEdge | null } = {}): WireFlowHop {
+  const lines = opts.lines ?? 7;
+  return {
+    node: {
+      id: `method:${name}`,
+      kind: 'method',
+      name,
+      qualifiedName: name,
+      file: `src/${name}.ts`,
+      line: 10,
+      endLine: 40,
+      language: 'typescript',
+      test: false,
+    },
+    edge: opts.edge === undefined ? edge() : opts.edge,
+    callRef: null,
+    source:
+      lines === 0
+        ? null
+        : {
+            file: `src/${name}.ts`,
+            language: 'typescript',
+            from: 7,
+            to: 6 + lines,
+            lines: Array.from({ length: lines }, (_, i) => `line ${i}`),
+            drift: false,
+          },
+  };
+}
+
+function flow(id: string, names: string[]): WireFlow {
+  return {
+    id,
+    label: `${names[0]} → ${names[names.length - 1]}`,
+    hops: names.map((name, i) => hop(name, { edge: i === 0 ? null : edge() })),
+  };
+}
+
+/* ---------------------------------------------------------------- tests -- */
+
+describe('cardHeight', () => {
+  it('is the header plus one row per source line', () => {
+    expect(cardHeight(hop('a', { lines: 7 }))).toBe(HEADER_HEIGHT + 7 * CODE_LINE_HEIGHT + CODE_PADDING);
+    expect(cardHeight(hop('a', { lines: 1 }))).toBe(HEADER_HEIGHT + CODE_LINE_HEIGHT + CODE_PADDING);
+  });
+
+  it('gives a card with no source the height of the sentence that replaces it', () => {
+    expect(cardHeight(hop('a', { lines: 0 }))).toBe(HEADER_HEIGHT + NO_SOURCE_HEIGHT);
+  });
+});
+
+describe('dashFor', () => {
+  it('marks a synthesized hop `5 3` and an uncertain one `2 3`', () => {
+    expect(dashFor(edge({ synthesized: true }))).toBe('5 3');
+    expect(dashFor(edge({ uncertain: true }))).toBe('2 3');
+    expect(dashFor(edge())).toBeNull();
+  });
+
+  it('lets the synthesized pattern win, because it is the stronger claim', () => {
+    // A dynamic-dispatch bridge that also scored low confidence is still first
+    // and foremost a bridge: "we inferred this hop" is what a reader has to see.
+    expect(dashFor(edge({ synthesized: true, uncertain: true }))).toBe('5 3');
+  });
+});
+
+describe('labelLinesFor', () => {
+  it('leaves an ordinary call as one word', () => {
+    expect(labelLinesFor(edge())).toEqual(['calls']);
+  });
+
+  it('stacks a synthesized label and shortens the wiring site to a basename', () => {
+    expect(
+      labelLinesFor(
+        edge({ synthesized: true, label: 'via callback · registered at src/deep/nested/wire.ts:88' })
+      )
+    ).toEqual(['via callback', 'registered at wire.ts:88']);
+  });
+
+  it('cuts anything still too wide for an 86px connector', () => {
+    const lines = labelLinesFor(edge({ label: 'via an extraordinarily long mechanism name' }));
+    expect(lines).toHaveLength(1);
+    expect(lines[0]!.length).toBe(LABEL_MAX_CHARS);
+    expect(lines[0]!.endsWith('…')).toBe(true);
+  });
+});
+
+describe('lineLabelFor', () => {
+  it('prints the recorded line, and nothing when there is none', () => {
+    expect(lineLabelFor(edge({ line: 2029 }))).toBe('line 2029');
+    expect(lineLabelFor(edge())).toBeNull();
+    expect(lineLabelFor(edge({ line: 0 }))).toBeNull();
+  });
+});
+
+describe('buildFlowLayout — one path', () => {
+  const single = flow('f1', ['a', 'b', 'c']);
+
+  it('puts one card per column, left to right, at the spec pitch', () => {
+    const layout = buildFlowLayout([single], 'f1');
+    expect(layout.cards.map((c) => c.hop.node.name)).toEqual(['a', 'b', 'c']);
+    expect(layout.cards.map((c) => c.column)).toEqual([0, 1, 2]);
+    expect(layout.cards.map((c) => c.x)).toEqual([PADDING, PADDING + COLUMN_PITCH, PADDING + 2 * COLUMN_PITCH]);
+    expect(COLUMN_PITCH).toBe(CARD_WIDTH + LINK_WIDTH);
+  });
+
+  it('places every card on one row and numbers its step on the active flow', () => {
+    const layout = buildFlowLayout([single], 'f1');
+    expect(new Set(layout.cards.map((c) => c.y)).size).toBe(1);
+    expect(layout.cards.map((c) => c.step)).toEqual([0, 1, 2]);
+  });
+
+  it('links consecutive cards and nothing else', () => {
+    const layout = buildFlowLayout([single], 'f1');
+    expect(layout.links.map((l) => [l.source, l.target])).toEqual([
+      ['method:a', 'method:b'],
+      ['method:b', 'method:c'],
+    ]);
+  });
+
+  it('sizes the canvas to the cards it drew', () => {
+    const layout = buildFlowLayout([single], 'f1');
+    expect(layout.columns).toBe(3);
+    expect(layout.gaps).toEqual([LINK_WIDTH, LINK_WIDTH]);
+    expect(layout.width).toBe(PADDING * 2 + 3 * CARD_WIDTH + 2 * LINK_WIDTH);
+    expect(layout.height).toBe(PADDING * 2 + cardHeight(single.hops[0] as WireFlowHop));
+  });
+
+  it('widens the gap a long synthesized label has to fit into', () => {
+    // 86px holds `calls`; it does not hold `registered at App.tsx:3764`, which
+    // at a fixed pitch ran under the source of the card it was explaining.
+    const wired: WireFlow = {
+      id: 'f1',
+      label: 'a → b',
+      hops: [
+        hop('a', { edge: null }),
+        hop('b', {
+          edge: edge({
+            synthesized: true,
+            line: 5337,
+            label: 'via callback · onUpdate · registered at src/app/App.tsx:3764',
+          }),
+        }),
+      ],
+    };
+    const layout = buildFlowLayout([wired], 'f1');
+    expect(layout.gaps[0]).toBeGreaterThan(LINK_WIDTH);
+    // Wide enough for the widest line it has to hold.
+    const widest = Math.max(...(layout.links[0]?.labelLines ?? []).map((l) => l.length));
+    expect(layout.gaps[0]).toBeGreaterThanOrEqual(widest * 6.65);
+    // …and the second card starts past it, so nothing is drawn over the label.
+    expect(layout.cards[1]?.x).toBe(PADDING + CARD_WIDTH + (layout.gaps[0] as number));
+  });
+
+  it('answers an empty picture for no flows at all', () => {
+    expect(buildFlowLayout([], null)).toEqual({
+      cards: [],
+      links: [],
+      width: 0,
+      height: 0,
+      columns: 0,
+      gaps: [],
+    });
+  });
+});
+
+describe('buildFlowLayout — two paths that merge', () => {
+  // a → b → d and a → c → d: the same start, the same end, different middles.
+  const left = flow('f1', ['a', 'b', 'd']);
+  const right = flow('f2', ['a', 'c', 'd']);
+
+  it('draws one DAG, not two strips', () => {
+    const layout = buildFlowLayout([left, right], 'f1');
+    expect(layout.cards).toHaveLength(4);
+    expect(layout.links).toHaveLength(4);
+    expect(layout.columns).toBe(3);
+  });
+
+  it('rejoins the shared cards in one column and stacks the branch', () => {
+    const layout = buildFlowLayout([left, right], 'f1');
+    const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
+    expect(at('a').column).toBe(0);
+    expect(at('d').column).toBe(2);
+    expect(at('b').column).toBe(1);
+    expect(at('c').column).toBe(1);
+    // Same column, different rows, exactly one gap apart.
+    expect(at('c').y - at('b').y).toBe(at('b').height + ROW_GAP);
+  });
+
+  it('records which paths a shared card and a branch link belong to', () => {
+    const layout = buildFlowLayout([left, right], 'f1');
+    const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
+    expect(at('a').flows).toEqual(['f1', 'f2']);
+    expect(at('b').flows).toEqual(['f1']);
+    expect(at('c').flows).toEqual(['f2']);
+    expect(layout.links.find((l) => l.target === 'method:c')!.flows).toEqual(['f2']);
+  });
+
+  it('marks the picked path, and only the picked path, with a step', () => {
+    const picked = buildFlowLayout([left, right], 'f2');
+    const at = (name: string) => picked.cards.find((c) => c.hop.node.name === name)!;
+    expect(at('c').step).toBe(1);
+    expect(at('b').step).toBe(-1);
+    // …and the picked path is the one drawn along the top of its columns.
+    expect(at('c').y).toBeLessThan(at('b').y);
+  });
+});
+
+describe('buildFlowLayout — awkward shapes', () => {
+  it('never draws a card left of something that calls it, on a long merge', () => {
+    // a → b → c → d and a → d: `d`'s column must come from the LONGEST route,
+    // or the short path would drag it back on top of `b`.
+    const long = flow('f1', ['a', 'b', 'c', 'd']);
+    const short = flow('f2', ['a', 'd']);
+    const layout = buildFlowLayout([long, short], 'f1');
+    const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
+    expect(at('d').column).toBe(3);
+    for (const link of layout.links) {
+      const from = layout.cards.find((c) => c.id === link.source)!;
+      const to = layout.cards.find((c) => c.id === link.target)!;
+      expect(to.column).toBeGreaterThan(from.column);
+    }
+  });
+
+  it('still draws every card when a flow calls back into itself', () => {
+    // a → b → a: a real shape (recursion through a helper) and one with no
+    // topological order. Nothing may vanish.
+    const cyclic: WireFlow = {
+      id: 'f1',
+      label: 'a → a',
+      hops: [hop('a', { edge: null }), hop('b'), { ...hop('a'), edge: edge() }],
+    };
+    const layout = buildFlowLayout([cyclic], 'f1');
+    expect(layout.cards.map((c) => c.hop.node.name).sort()).toEqual(['a', 'b']);
+    expect(layout.links).toHaveLength(2);
+    expect(layout.cards.every((c) => Number.isFinite(c.x) && Number.isFinite(c.y))).toBe(true);
+  });
+
+  it('centres a short column against a tall one', () => {
+    const tall = flow('f1', ['a', 'b', 'd']);
+    const alt = flow('f2', ['a', 'c', 'd']);
+    const layout = buildFlowLayout([tall, alt], 'f1');
+    const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
+    const columnMiddle = (name: string) => at(name).y + at(name).height / 2;
+    // `a` is alone in its column; `b`/`c` share the next one. Their midpoints line up.
+    expect(columnMiddle('a')).toBeCloseTo((at('b').y + at('c').y + at('c').height) / 2, 5);
+  });
+});

+ 12 - 2
__tests__/ui-search-model.test.ts

@@ -124,16 +124,26 @@ describe('the palette', () => {
     expect(interleaveResults(a, [result({ id: 'a2' })]).map((r) => r.id)).toEqual(['a1', 'a2']);
   });
 
-  it('explains that a flow question is answered by both endpoints for now', () => {
+  it('offers the flow FIRST for a flow question, then what each name matches', () => {
     const palette = buildSearchPalette(
       [answer([result({ id: 'a', name: 'sync' })]), answer([result({ id: 'b', name: 'read' })])],
       { from: 'sync', to: 'read' }
     );
-    expect(palette.items.map((i) => i.id)).toEqual(['a', 'b']);
+    // First row, so Enter opens the path: the question asked for the path.
+    expect(palette.sections[0]?.title).toBe('Flow');
+    expect(palette.items[0]).toMatchObject({ type: 'flow', from: 'sync', to: 'read' });
+    expect(palette.items.map((i) => i.id).slice(1)).toEqual(['a', 'b']);
+    expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
     expect(palette.hint).toContain('sync');
     expect(palette.hint).toContain('read');
   });
 
+  it('offers no flow row when the query is not a flow question', () => {
+    const palette = buildSearchPalette([answer([result({ id: 'a' })])], null);
+    expect(palette.sections.some((s) => s.title === 'Flow')).toBe(false);
+    expect(palette.items.every((i) => i.type !== 'flow')).toBe(true);
+  });
+
   it('names a kind bucket in sentence case, singular when there is one', () => {
     expect(kindGroupTitle('method', 3)).toBe('Methods');
     expect(kindGroupTitle('method', 1)).toBe('Method');

+ 13 - 0
site/src/content/docs/guides/viewer.md

@@ -42,6 +42,19 @@ The viewer never presents a guess as a fact:
 
 Clicking any file path opens the **file view**: everything that file depends on, its outline in source order, and everything that depends on it.
 
+## The flow
+
+Type **"how does execute reach getFile"** into the search box — or `execute -> getFile` — and the first result opens the **Flow** strip: the call path between the two symbols, left to right, one card per hop.
+
+Each card is opened at the line that makes the next call, not at the top of the function, so reading the strip is reading the six or eight lines that actually carry the request. The identifier being called is a link; click a card's header to open it in the symbol screen with the trail already set to the path you have read so far.
+
+- **The link between two cards carries the edge** — what kind it is and the line it was recorded at.
+- **A dashed link is a hop nobody can see in the source**: a callback, an interface dispatch, a React re-render, a JSX child. It names the mechanism and, where the resolver knows it, the exact line the handler was wired at. This is the part grep cannot do.
+- **When a name means several definitions**, the strip says so under the picture and names the one this path runs through — and offers the other paths in the picker at the top. Choosing "All paths" draws them as one diagram, branching where they differ and rejoining where they agree.
+- **"Not connected" is an answer**, not a failure: a flow that runs through a dispatch no static edge records genuinely has no path, and the screen says that rather than inventing one.
+
+The **"Read as flow"** button on the trail turns a walk you did by hand into the same strip. It is the same path finder `codegraph_explore` leads its answers with, so the picture and what your agent tells you cannot disagree.
+
 ## The map
 
 The **Map** tab (`m`) draws the project at module granularity — one box per directory — with dependencies pointing down. Nothing is placed by hand: a module sits one layer above whatever it depends on, so the top of the picture is what runs first and the bottom is what everything else stands on, and the same project always draws the same picture.

+ 5 - 0
src/bin/codegraph.ts

@@ -1884,6 +1884,11 @@ Pick a symbol and you see who calls it on the left, its source in the middle,
 and what it calls on the right at the height of the line that calls it. Search
 with / (or Cmd-K), click a file path for the file's outline and its imports.
 
+Ask "how does execute reach getFile" (or "execute -> getFile") in the search
+box for the flow between two symbols: one card per hop, opened at the line that
+makes the next call, with dynamic-dispatch hops drawn dashed and named. The Map
+tab draws the whole project by module, with dependencies pointing down.
+
 The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
 and it is read-only: it opens an index that already exists and never changes
 your project or your graph. Requests from any other host are refused, and

+ 672 - 0
src/graph/named-symbol-flow.ts

@@ -0,0 +1,672 @@
+/**
+ * The call path among a bag of named symbols — the one path finder.
+ *
+ * `codegraph_explore` leads its answer with a "Flow" section: the longest call
+ * chain among the symbols an agent named, riding synthesized dynamic-dispatch
+ * edges so a controller reaches its implementation through the interface. The
+ * viewer's Flow strip (`/api/flow`, design spec §3.5) draws the same thing as
+ * cards. They must never disagree, so the search lives here once and both
+ * callers ride it: same token parsing, same overload disambiguation, same
+ * bridge budget, same edges.
+ *
+ * What differs between the two callers is expressed as OPTIONS, not as a second
+ * implementation:
+ *
+ * - **`mode: 'named'`** is exactly what explore does. Every resolved symbol is
+ *   both a possible start and a possible end, at most ONE unnamed symbol may
+ *   bridge two named ones ({@link DEFAULT_MAX_BRIDGE}), and the LONGEST chain
+ *   wins. The bridge cap is what stops the search wandering a god-function's
+ *   fan-out: the agent's own naming is the evidence that a hop is on-topic.
+ * - **`mode: 'directed'`** is "how does X reach Y", which the agent has no way
+ *   to ask and the viewer's search box does. Both ends are pinned, so the
+ *   evidence the bridge cap was standing in for is already there and the search
+ *   bridges freely — a two-token query under the named rules could never return
+ *   more than three cards. The SHORTEST path wins, because with both ends fixed
+ *   a longer route is a detour rather than a fuller answer.
+ *
+ * Overloads are handled differently for the same reason. A bare ambiguous name
+ * in `named` mode is filtered by CO-NAMING (keep `list` only where the agent
+ * also named its class); in `directed` mode every candidate for both endpoints
+ * is tried and the pair that actually connects is the answer — which is a
+ * better disambiguator than co-naming and the only one available when the
+ * whole query is two words.
+ */
+
+import type CodeGraph from '../index';
+import type { Node, Edge } from '../types';
+import { isTestFile } from '../search/query-utils';
+
+/**
+ * Rust path roots that have no file-system equivalent — `crate` is the
+ * current crate, `super` is the parent module, `self` is the current
+ * module. Used by `matchesSymbol` to strip these before file-path
+ * matching so `crate::configurator::stage_apply::run` resolves the
+ * same as `configurator::stage_apply::run`.
+ */
+export const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
+
+/**
+ * Last `::` / `.` / `/`-separated segment of a qualified symbol. An Erlang
+ * arity tail (`mod::fn/3`, `fn/3`) is stripped first — the useful last segment
+ * is the function name, never the digits (#1610).
+ */
+export function lastQualifierPart(symbol: string): string {
+  const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
+  const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
+  return parts[parts.length - 1] ?? symbol;
+}
+
+/**
+ * Check if a node matches a symbol query.
+ *
+ * Accepts simple names (`run`) and three flavors of qualifier:
+ *   - dotted     `Session.request`         (TS/JS/Python)
+ *   - colon-pair `stage_apply::run`        (Rust, C++, Ruby)
+ *   - slash      `configurator/stage_apply` (path-ish)
+ *
+ * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
+ * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
+ * the canonical `crate::module::symbol` form resolves.
+ *
+ * Resolution order, last part must always equal `node.name`:
+ *   1. Suffix-match against `qualifiedName` (handles class-scoped methods
+ *      where the extractor builds the qualified name from the AST stack)
+ *   2. File-path containment (handles file-derived modules in Rust/
+ *      Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
+ */
+export function matchesSymbol(node: Node, symbol: string): boolean {
+  // Erlang arity spelling (`fn/3`, `mod:fn/3` → normalized `mod.fn/3`): when
+  // the node's qualifiedName carries an arity (`mod::fn/3`, #1610), the
+  // written arity must match it exactly; the remaining comparison then runs
+  // on the arity-less spelling. A node with no arity in its qualifiedName
+  // keeps the original symbol (a `/` there means a path-ish name instead).
+  const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
+  if (aritySpelling) {
+    const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
+    if (nodeArity !== undefined) {
+      if (nodeArity !== aritySpelling[2]) return false;
+      symbol = aritySpelling[1]!;
+    }
+  }
+  // Simple name match
+  if (node.name === symbol) return true;
+  // File basename match (e.g., "product-card" matches "product-card.liquid")
+  if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
+
+  // Qualified-name lookups: split on any supported separator. `\w` keeps
+  // identifier chars (incl. `_`) intact; everything else is treated as
+  // a separator we tolerate.
+  if (!/[.\/]|::/.test(symbol)) return false;
+  const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
+  if (parts.length < 2) return false;
+
+  const lastPart = parts[parts.length - 1]!;
+  if (node.name !== lastPart) return false;
+
+  // Stage 1: qualified-name suffix match. The extractor joins the
+  // semantic hierarchy with `::`, so `Session.request` and
+  // `Session::request` both become `Session::request` here.
+  const colonSuffix = parts.join('::');
+  if (node.qualifiedName.includes(colonSuffix)) return true;
+
+  // Stage 2: file-path containment. Rust modules and Python packages
+  // are not in `qualifiedName` — they're encoded in the file path. So
+  // `stage_apply::run` matches a `run` in any file whose path
+  // contains a `stage_apply` segment (with or without an extension).
+  //
+  // Filter out Rust path prefixes that have no file-system equivalent.
+  const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
+  if (containerHints.length === 0) return false;
+
+  const segments = node.filePath.split('/').filter((s) => s.length > 0);
+  return containerHints.every((hint) =>
+    segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
+  );
+}
+
+/**
+ * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
+ * results across all matching symbols (e.g., multiple classes with an `execute` method).
+ */
+export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
+  // Nix option paths: the declaration is stored as `options.<path>` and
+  // config writes carry longer/quoted tails (`<path>."git/config".text`),
+  // so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
+  // no exact-name node and would degrade to bare-tail FTS soup — burying
+  // the declaration hub the nix-option-path edges hang off. Resolve the
+  // convention directly: declaration first, then the exact write, then a
+  // capped prefix scan of write sites. Three index hits; non-nix graphs
+  // fall straight through.
+  if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
+    const optionHits = [
+      ...cg.getNodesByName(`options.${symbol}`),
+      ...cg.getNodesByName(symbol),
+      ...cg.getNodesByNamePrefix(`${symbol}.`, 12),
+    ].filter((n) => n.language === 'nix');
+    if (optionHits.length > 0) {
+      const seen = new Set<string>();
+      const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
+      return { nodes, note: '' };
+    }
+  }
+  let results = cg.searchNodes(symbol, { limit: 50 });
+
+  // Mirror the fallback in `findSymbol` for qualified queries — FTS
+  // strips colons, so a module-qualified lookup needs a second pass
+  // by the bare last part.
+  if (results.length === 0 && /[.\/]|::/.test(symbol)) {
+    const tail = lastQualifierPart(symbol);
+    if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
+  }
+
+  if (results.length === 0) {
+    return { nodes: [], note: '' };
+  }
+
+  const exactMatches = results.filter(r => matchesSymbol(r.node, symbol));
+
+  if (exactMatches.length <= 1) {
+    const node = exactMatches[0]?.node ?? results[0]!.node;
+    return { nodes: [node], note: '' };
+  }
+
+  // Same generated-file down-rank as findSymbol — keeps callers/callees
+  // /impact aggregation aligned (a query against "Send" returns the
+  // hand-written implementations before the protobuf scaffold).
+  const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
+  const ranked = [...exactMatches].sort((a, b) => {
+    const aGen = isGen(a.node.filePath) ? 1 : 0;
+    const bGen = isGen(b.node.filePath) ? 1 : 0;
+    return aGen - bGen;
+  });
+
+  const locations = ranked.map(r =>
+    `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
+  );
+  const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
+  return { nodes: ranked.map(r => r.node), note };
+}
+
+/** Node kinds that can sit on a call chain. */
+export const FLOW_CALLABLE_KINDS: ReadonlySet<string> = new Set([
+  'method',
+  'function',
+  'component',
+  'constructor',
+]);
+
+/**
+ * Node kinds that can be an endpoint of a SYNTHESIZED edge without being
+ * callable. An RTK thunk is `const X = createAsyncThunk(...)`, so a thunk →
+ * thunk hop is constant → constant and the callable-only set cannot hold it.
+ */
+const DYN_KINDS: ReadonlySet<string> = new Set(['constant', 'variable', 'field', 'property']);
+
+/** Only a REAL file extension is stripped from a token — `Class.method` is kept. */
+const FILE_EXT =
+  /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
+
+/** Chain length ceiling, in NODES. Explore's Flow section has always used 7. */
+export const DEFAULT_MAX_HOPS = 7;
+
+/**
+ * Longer ceiling for a directed question.
+ *
+ * "How does X reach Y" is asked about two symbols that a reader believes are
+ * connected, and a real call path between a CLI entry point and a storage
+ * primitive runs deeper than seven frames. Explore's ceiling stays where it is:
+ * there, a longer chain is a bigger guess, because nothing pins the far end.
+ */
+export const DIRECTED_MAX_HOPS = 12;
+
+/** At most one consecutive UNNAMED hop may bridge two named symbols. */
+export const DEFAULT_MAX_BRIDGE = 1;
+
+/** Seeds a `named` search starts from, and candidates an ambiguous token keeps. */
+const MAX_SEEDS = 8;
+const MAX_CANDIDATES_PER_TOKEN = 6;
+
+/**
+ * Candidates a DIRECTED endpoint keeps, and the seeds it therefore walks from.
+ *
+ * Higher than the `named` cap, and the reason is a real failure: `main` has ten
+ * definitions in this repository — a Python asset script, a Rust build script,
+ * four `scripts/*.mjs` one-offs, a Go fixture — and the CLI's own `main`, the
+ * one anybody asking "how does main reach X" means, sorts SEVENTH. A cap of six
+ * silently answered "these two symbols are not connected". Both endpoints are
+ * pinned here, so an extra candidate costs one bounded walk that ends the
+ * moment it reaches the destination, and the pair that connects is the answer.
+ */
+const MAX_CANDIDATES_DIRECTED = 12;
+const MAX_TOKENS = 16;
+const MAX_NAMED = 40;
+
+export interface FlowStep {
+  node: Node;
+  /** The edge INTO this node from the previous step; null on the first. */
+  edge: Edge | null;
+}
+
+export interface FlowChain {
+  steps: FlowStep[];
+  /** For each node on the chain, the line where it calls the NEXT one. */
+  callSites: Map<string, number>;
+}
+
+export interface NamedSymbolFlowOptions {
+  /** `named` = explore's rules; `directed` = a pinned from → to question. */
+  mode?: 'named' | 'directed';
+  /** Required in `directed` mode: the token the path must start at. */
+  from?: string;
+  /** Required in `directed` mode: the token the path must end at. */
+  to?: string;
+  maxHops?: number;
+  /** Consecutive unnamed hops allowed. `Infinity` in directed mode. */
+  maxBridge?: number;
+  /** Distinct chains to return. Explore only ever looks at the first. */
+  maxChains?: number;
+}
+
+export interface NamedSymbolFlow {
+  /** The query's symbol tokens, in the order they were written. */
+  tokens: string[];
+  /** Every CALLABLE the tokens resolved to, by node id. */
+  named: Map<string, Node>;
+  /** Non-callable endpoints of synthesized edges (RTK thunks and friends). */
+  dynNamed: Map<string, Node>;
+  /** token → the node ids it resolved to. */
+  tokenNodes: Map<string, string[]>;
+  /** token → its whole same-name callable family, before the container filter. */
+  tokenFamily: Map<string, Node[]>;
+  /** Ids whose token was a (near-)unique callable name — at most 3 defs. */
+  uniqueNamedNodeIds: Set<string>;
+  /** Ids resolved from a shape-precise token (camelCase, dotted, PascalCase…). */
+  preciseNamedIds: Set<string>;
+  /** Chains found, best first. Empty when nothing connects. */
+  chains: FlowChain[];
+}
+
+const EMPTY_FLOW = (): NamedSymbolFlow => ({
+  tokens: [],
+  named: new Map(),
+  dynNamed: new Map(),
+  tokenNodes: new Map(),
+  tokenFamily: new Map(),
+  uniqueNamedNodeIds: new Set(),
+  preciseNamedIds: new Set(),
+  chains: [],
+});
+
+/**
+ * Production code before test and fixture code, otherwise the order the index
+ * ranked them in.
+ *
+ * Only used for a directed question, where the candidates are the two ends of
+ * "how does X reach Y" and a fixture's `main` is never what was meant. In
+ * `named` mode the agent's own co-naming does this job and re-ranking would
+ * change what `codegraph_explore` answers.
+ */
+function rankForDirected(nodes: readonly Node[]): Node[] {
+  return [...nodes].sort(
+    (a, b) => (isTestFile(a.filePath) ? 1 : 0) - (isTestFile(b.filePath) ? 1 : 0)
+  );
+}
+
+/**
+ * A token is shape-precise when it looks like a symbol reference rather than an
+ * English word that happened to exact-match a callable.
+ */
+function isPreciseToken(token: string): boolean {
+  return /[._$]|::|\//.test(token) || /[a-z][A-Z]/.test(token) || /^[A-Z]/.test(token);
+}
+
+/** The symbol-shaped tokens of a query, deduped and capped. */
+export function flowTokens(query: string): string[] {
+  return [
+    ...new Set(
+      query
+        .split(/[\s,()[\]]+/)
+        .map((t) => t.replace(FILE_EXT, '').trim())
+        .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
+    ),
+  ].slice(0, MAX_TOKENS);
+}
+
+/**
+ * Resolve a query's tokens to nodes, with the overload rules described in the
+ * module header. No graph traversal happens here.
+ */
+export function resolveNamedTokens(
+  cg: CodeGraph,
+  query: string,
+  opts: NamedSymbolFlowOptions = {}
+): NamedSymbolFlow {
+  const directed = opts.mode === 'directed';
+  const out = EMPTY_FLOW();
+  const tokens = flowTokens(query);
+  out.tokens = tokens;
+  if (tokens.length < 2) return out;
+
+  // Pool of name SEGMENTS (Class + method from every token), used to keep an
+  // ambiguous simple name only where its CONTAINER class is itself named.
+  const segPool = new Set<string>();
+  for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
+
+  const hasHeuristicEdge = (id: string): boolean =>
+    [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
+
+  for (const t of tokens) {
+    const hits = findAllSymbols(cg, t).nodes;
+    const cands = hits.filter((n) => FLOW_CALLABLE_KINDS.has(n.kind));
+    out.tokenFamily.set(t, cands);
+    // A qualified or otherwise-specific name (<=3 hits) keeps all of them.
+    const specific = cands.length <= 3;
+    // In directed mode every candidate is kept and the search decides: the pair
+    // of overloads that actually connects IS the disambiguation, and co-naming
+    // has nothing to work with when the whole query is two words.
+    const pick =
+      specific || directed
+        ? cands
+        : cands.filter((n) => {
+            const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
+            const container = segs.length >= 2 ? segs[segs.length - 2] : '';
+            return !!container && segPool.has(container);
+          });
+    const kept = directed
+      ? rankForDirected(pick).slice(0, MAX_CANDIDATES_DIRECTED)
+      : pick.slice(0, MAX_CANDIDATES_PER_TOKEN);
+    out.tokenNodes.set(
+      t,
+      kept.map((n) => n.id)
+    );
+    const precise = isPreciseToken(t);
+    for (const n of kept) {
+      out.named.set(n.id, n);
+      if (specific) out.uniqueNamedNodeIds.add(n.id);
+      if (precise) out.preciseNamedIds.add(n.id);
+    }
+    // Same token, non-callable synthesized endpoints. Capped per token so one
+    // token's many endpoints cannot fill the pool before later tokens get a slot,
+    // and gated on an actual heuristic edge so plain constants never qualify.
+    if (out.dynNamed.size < 12) {
+      let tokenDyn = 0;
+      for (const n of hits) {
+        if (FLOW_CALLABLE_KINDS.has(n.kind) || !DYN_KINDS.has(n.kind) || out.dynNamed.has(n.id)) {
+          continue;
+        }
+        if (hasHeuristicEdge(n.id)) {
+          out.dynNamed.set(n.id, n);
+          if (precise) out.preciseNamedIds.add(n.id);
+          tokenDyn++;
+        }
+        if (out.dynNamed.size >= 12 || tokenDyn >= 4) break;
+      }
+    }
+    if (out.named.size > MAX_NAMED) break;
+  }
+  return out;
+}
+
+/** Where each node on a chain calls the next one. */
+function callSitesOf(steps: readonly FlowStep[]): Map<string, number> {
+  const sites = new Map<string, number>();
+  for (let i = 0; i < steps.length - 1; i++) {
+    const line = steps[i + 1]?.edge?.line;
+    const id = steps[i]?.node.id;
+    if (id && line && line > 0 && !sites.has(id)) sites.set(id, line);
+  }
+  return sites;
+}
+
+/**
+ * Nodes one side of a search may visit before it gives up.
+ *
+ * The `named` cap is explore's own, unchanged: with at most one unnamed bridge
+ * between named symbols the frontier cannot run away, so 1 500 is generous.
+ * A directed search bridges freely and needs far more room — but it spends it
+ * from two ends at once, so a side that blows past this has genuinely fanned
+ * out rather than merely gone deep.
+ */
+const NAMED_VISIT_CAP = 1500;
+const DIRECTED_VISIT_CAP = 12_000;
+
+/**
+ * Breadth-first over `calls` edges — synthesized ones included, which is what
+ * carries a flow across a callback, a re-render or a JSX child.
+ *
+ * This is the `named` walk: every named symbol is a possible destination, and
+ * at most `maxBridge` unnamed symbols may sit between two of them. That cap is
+ * what bounds the frontier, so {@link NAMED_VISIT_CAP} is generous.
+ *
+ * Returns the parent map, so a caller can reconstruct any reached node's path.
+ */
+function walkCalls(
+  cg: CodeGraph,
+  seed: Node,
+  named: ReadonlySet<string>,
+  maxHops: number,
+  maxBridge: number
+): { parent: Map<string, { prev: string | null; edge: Edge | null; node: Node }>; reached: string[] } {
+  const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
+  parent.set(seed.id, { prev: null, edge: null, node: seed });
+  const queue: Array<{ id: string; depth: number; streak: number }> = [
+    { id: seed.id, depth: 0, streak: 0 },
+  ];
+  const reached: string[] = [];
+  for (let head = 0; head < queue.length && parent.size < NAMED_VISIT_CAP; head++) {
+    const { id, depth, streak } = queue[head]!;
+    if (id !== seed.id && named.has(id)) reached.push(id);
+    if (depth >= maxHops - 1) continue;
+    for (const c of cg.getCallees(id)) {
+      if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
+      const newStreak = named.has(c.node.id) ? 0 : streak + 1;
+      if (newStreak > maxBridge) continue;
+      parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
+      queue.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
+    }
+  }
+  return { parent, reached };
+}
+
+
+/**
+ * A short call path from `seed` to any of `sinks`, searched from BOTH ends.
+ *
+ * A directed question bridges freely — nothing in the middle is "named" to keep
+ * the frontier small — so a one-way walk from an entry point balloons: `main`
+ * on this repository touches hundreds of symbols within four hops of a
+ * twelve-hop budget. Coming in from both ends halves the depth each side has to
+ * cover, and the destination end is nearly always the cheap one: a leaf has a
+ * handful of callers where an entry point has an enormous fan-out.
+ *
+ * Measured against the one-way walk on twelve pairs from this repository's own
+ * index: **identical paths, 3–6× faster** (`main -> resolveOne` 40 ms → 11 ms,
+ * `main -> scanDynamicDispatch` 33 ms → 7 ms). The one-way search never
+ * actually exhausted its visit cap here, so the reachability headroom below is
+ * insurance for a graph much larger than this one, not a fix for a bug that was
+ * observed.
+ *
+ * It alternates a level at a time, always expanding the SMALLER frontier, and
+ * stops the moment the two sides share a node. Alternating levels this way can
+ * return a path one hop longer than the true shortest — which is why nothing in
+ * the payload claims to be shortest, only to be a path the graph records.
+ */
+function walkBidirectional(
+  cg: CodeGraph,
+  seed: Node,
+  sinks: ReadonlySet<string>,
+  maxHops: number
+): FlowStep[] | null {
+  if (sinks.has(seed.id)) return null;
+
+  const forward = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
+  /** id → the edge OUT of it towards the destination; null AT the destination. */
+  const backward = new Map<string, { next: string; edge: Edge } | null>();
+  const backNodes = new Map<string, Node>();
+
+  forward.set(seed.id, { prev: null, edge: null, node: seed });
+  let frontF: Node[] = [seed];
+  let frontB: Node[] = [];
+  for (const id of sinks) {
+    const node = cg.getNode(id);
+    if (!node) continue;
+    backward.set(id, null);
+    backNodes.set(id, node);
+    frontB.push(node);
+  }
+  if (frontB.length === 0) return null;
+
+  const meetAt = (): string | null => {
+    // The forward side is the one that is walked in full, so scanning it is the
+    // cheaper direction of the check.
+    for (const id of forward.keys()) if (backward.has(id)) return id;
+    return null;
+  };
+
+  const maxEdges = Math.max(1, maxHops - 1);
+  for (let laid = 0; laid < maxEdges; laid++) {
+    if (frontF.length <= frontB.length) {
+      if (forward.size > DIRECTED_VISIT_CAP) break;
+      const next: Node[] = [];
+      for (const node of frontF) {
+        for (const c of cg.getCallees(node.id)) {
+          if (c.edge.kind !== 'calls' || forward.has(c.node.id)) continue;
+          forward.set(c.node.id, { prev: node.id, edge: c.edge, node: c.node });
+          next.push(c.node);
+        }
+      }
+      if (next.length === 0) break;
+      frontF = next;
+    } else {
+      if (backward.size > DIRECTED_VISIT_CAP) break;
+      const next: Node[] = [];
+      for (const node of frontB) {
+        for (const c of cg.getCallers(node.id)) {
+          if (c.edge.kind !== 'calls' || backward.has(c.node.id)) continue;
+          backward.set(c.node.id, { next: node.id, edge: c.edge });
+          backNodes.set(c.node.id, c.node);
+          next.push(c.node);
+        }
+      }
+      if (next.length === 0) break;
+      frontB = next;
+    }
+
+    const meet = meetAt();
+    if (meet === null) continue;
+
+    // Forward half: seed → meet, walking the forward parents back.
+    const steps: FlowStep[] = [];
+    let cur: string | null = meet;
+    while (cur) {
+      const at = forward.get(cur);
+      if (!at) break;
+      steps.push({ node: at.node, edge: at.edge });
+      cur = at.prev;
+    }
+    steps.reverse();
+    // Backward half: meet → sink. An entry holds the edge OUT of its node, so
+    // it is the edge INTO the step after it, which is the shape a step wants.
+    let link = backward.get(meet);
+    while (link) {
+      const node = backNodes.get(link.next);
+      if (!node) break;
+      steps.push({ node, edge: link.edge });
+      link = backward.get(link.next);
+    }
+
+    const last = steps[steps.length - 1];
+    if (steps.length < 2 || !last || !sinks.has(last.node.id)) return null;
+    return steps.length <= maxHops ? steps : null;
+  }
+  return null;
+}
+
+function chainTo(
+  parent: Map<string, { prev: string | null; edge: Edge | null; node: Node }>,
+  target: string
+): FlowStep[] {
+  const steps: FlowStep[] = [];
+  let cur: string | null = target;
+  while (cur) {
+    const at = parent.get(cur);
+    if (!at) break;
+    steps.push({ node: at.node, edge: at.edge });
+    cur = at.prev;
+  }
+  steps.reverse();
+  return steps;
+}
+
+/**
+ * The call path among a query's named symbols. See the module header for what
+ * the two modes mean and why they differ.
+ */
+export function resolveNamedSymbolFlow(
+  cg: CodeGraph,
+  query: string,
+  opts: NamedSymbolFlowOptions = {}
+): NamedSymbolFlow {
+  try {
+    const directed = opts.mode === 'directed';
+    const flow = resolveNamedTokens(cg, query, opts);
+    if (flow.named.size < 2) return flow;
+
+    const maxHops = opts.maxHops ?? (directed ? DIRECTED_MAX_HOPS : DEFAULT_MAX_HOPS);
+    const maxBridge = opts.maxBridge ?? (directed ? Number.POSITIVE_INFINITY : DEFAULT_MAX_BRIDGE);
+    const maxChains = Math.max(1, opts.maxChains ?? 1);
+    const namedIds = new Set(flow.named.keys());
+
+    const found: FlowStep[][] = [];
+    if (directed) {
+      const fromIds = flow.tokenNodes.get(normalizeToken(opts.from ?? '')) ?? [];
+      const toIds = flow.tokenNodes.get(normalizeToken(opts.to ?? '')) ?? [];
+      if (fromIds.length === 0 || toIds.length === 0) return flow;
+      const sinks = new Set(toIds);
+      // Every candidate start is searched: each is a bounded two-ended walk that
+      // ends the moment the frontiers meet, and the start that actually connects
+      // IS the answer to which overload was meant.
+      for (const id of fromIds) {
+        const seed = flow.named.get(id);
+        if (!seed) continue;
+        const steps = walkBidirectional(cg, seed, sinks, maxHops);
+        if (steps) found.push(steps);
+      }
+    } else {
+      for (const seed of [...flow.named.values()].slice(0, MAX_SEEDS)) {
+        const { parent, reached } = walkCalls(cg, seed, namedIds, maxHops, maxBridge);
+        // Explore's rule: the DEEPEST named sink this seed can reach.
+        let deepest: FlowStep[] | null = null;
+        for (const id of reached) {
+          const steps = chainTo(parent, id);
+          if (!deepest || steps.length > deepest.length) deepest = steps;
+        }
+        if (deepest) found.push(deepest);
+      }
+    }
+
+    if (found.length === 0) return flow;
+    found.sort((a, b) => (directed ? a.length - b.length : b.length - a.length));
+
+    // Identical chains, and chains that are just a shorter run along one
+    // already kept, are the same answer twice: `a → b → c` and `b → c` differ
+    // only in where the seed happened to be. Alternatives are for genuinely
+    // different routes — a second overload, a different intermediate.
+    const kept: string[] = [];
+    for (const steps of found) {
+      const key = steps.map((s) => s.node.id).join('>');
+      if (kept.some((other) => other === key || other.includes(key))) continue;
+      kept.push(key);
+      flow.chains.push({ steps, callSites: callSitesOf(steps) });
+      if (flow.chains.length >= maxChains) break;
+    }
+    return flow;
+  } catch {
+    return EMPTY_FLOW();
+  }
+}
+
+/** The token spelling {@link flowTokens} would have produced for one word. */
+export function normalizeToken(token: string): string {
+  return token.replace(FILE_EXT, '').trim();
+}

+ 28 - 268
src/mcp/tools.ts

@@ -41,6 +41,12 @@ import {
 import { createHash } from 'crypto';
 import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
 import { scanDynamicDispatch } from './dynamic-boundaries';
+import {
+  lastQualifierPart,
+  matchesSymbol,
+  findAllSymbols,
+  resolveNamedSymbolFlow,
+} from '../graph/named-symbol-flow';
 import { getUpdateNotice } from '../upgrade/update-check';
 import { ExploreDiagnostics } from './explore-diagnostics';
 import {
@@ -108,14 +114,6 @@ const MAX_INPUT_LENGTH = 10_000;
  */
 const MAX_PATH_LENGTH = 4_096;
 
-/**
- * Rust path roots that have no file-system equivalent — `crate` is the
- * current crate, `super` is the parent module, `self` is the current
- * module. Used by `matchesSymbol` to strip these before file-path
- * matching so `crate::configurator::stage_apply::run` resolves the
- * same as `configurator::stage_apply::run`.
- */
-const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
 
 /**
  * Node kinds that contain other symbols. For these, `codegraph_node` with
@@ -127,16 +125,6 @@ const CONTAINER_NODE_KINDS = new Set<NodeKind>([
   'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
 ]);
 
-/**
- * Last `::` / `.` / `/`-separated segment of a qualified symbol. An Erlang
- * arity tail (`mod::fn/3`, `fn/3`) is stripped first — the useful last segment
- * is the function name, never the digits (#1610).
- */
-function lastQualifierPart(symbol: string): string {
-  const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
-  const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
-  return parts[parts.length - 1] ?? symbol;
-}
 
 /**
  * Normalize Erlang-native symbol spellings in an explore query into the shapes
@@ -2557,98 +2545,13 @@ export class ToolHandler {
     // processRunExecutionData) to the call site instead of dumping the whole body.
     const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>(), spineCallSites: new Map<string, number>() };
     try {
-      const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
-      // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
-      // names (Class.method / Class::method) — the agent's most precise input,
-      // resolved exactly by findAllSymbols. (The old strip mangled Class.method
-      // into Class, throwing the method away.)
-      const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
-      const tokens = [...new Set(
-        query.split(/[\s,()[\]]+/)
-          .map((t) => t.replace(FILE_EXT, '').trim())
-          .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
-      )].slice(0, 16);
-      if (tokens.length < 2) return EMPTY;
-      // Pool of name SEGMENTS (Class + method from every token) used to
-      // disambiguate an ambiguous SIMPLE name: keep a candidate only if its
-      // CONTAINER class is itself named in the query.
-      const segPool = new Set<string>();
-      for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
-      const named = new Map<string, Node>();
-      // Nodes whose token is SPECIFIC — a (near-)unique callable name (<=3 defs in
-      // the whole graph). These are safe to SPARE a file on: the agent named THIS
-      // method (`getResponseWithInterceptorChain`, 1 def). A hyper-polymorphic name
-      // (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
-      // so naming it doesn't keep every backend variant full and flood the budget.
-      const uniqueNamedNodeIds = new Set<string>();
-      // token → resolved node ids: drives the token-coverage check that gates
-      // the dynamic-boundary scan (a token is covered when ANY of its nodes
-      // lands on the main chain — overloads off the chain don't count against).
-      const tokenNodes = new Map<string, string[]>();
-      // token → its full same-name callable family (before the container filter).
-      // A LARGE family that fails to connect on the chain is a polymorphic
-      // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
-      const tokenFamily = new Map<string, Node[]>();
-      // Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED
-      // edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop
-      // is constant→constant — the CALLABLE-only `named` set can't hold it, and
-      // without this the hop is invisible to the Flow path at every tier (the
-      // Relationships section catches it only on repos ≥500 files). Kept SEPARATE
-      // from `named` (which drives the call-chain + source sizing, callable-only);
-      // fed only to the dynamic-dispatch-links scan below.
-      const dynNamed = new Map<string, Node>();
-      const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
-      // Nodes resolved from a SHAPE-PRECISE token (camelCase / PascalCase /
-      // snake_case / qualified) — the same test the gather path uses. It is the
-      // difference between "the agent named this symbol" and "an ordinary English
-      // word in a prose question collided with a callable", and it is what makes
-      // the narrative-less return below safe (see `identityOnly`).
-      const isPreciseToken = (x: string) =>
-        /[._$]|::|\//.test(x) || /[a-z][A-Z]/.test(x) || /^[A-Z]/.test(x);
-      const preciseNamedIds = new Set<string>();
-      const hasHeuristicEdge = (id: string): boolean =>
-        [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
-      for (const t of tokens) {
-        const hits = this.findAllSymbols(cg, t).nodes;
-        const cands = hits.filter((n) => CALLABLE.has(n.kind));
-        tokenFamily.set(t, cands);
-        // A qualified or otherwise-specific name (<=3 hits) keeps all; an
-        // ambiguous simple name keeps only candidates whose container is named.
-        const specific = cands.length <= 3;
-        const pick = specific
-          ? cands
-          : cands.filter((n) => {
-              const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
-              const container = segs.length >= 2 ? segs[segs.length - 2] : '';
-              return !!container && segPool.has(container);
-            });
-        const kept = pick.slice(0, 6);
-        tokenNodes.set(t, kept.map((n) => n.id));
-        const precise = isPreciseToken(t);
-        for (const n of kept) {
-          named.set(n.id, n);
-          if (specific) uniqueNamedNodeIds.add(n.id);
-          if (precise) preciseNamedIds.add(n.id);
-        }
-        // Same token, non-callable synth endpoints (capped, precision-gated on an
-        // actual heuristic edge so plain config constants never qualify).
-        // Per-token sub-cap so one token's many endpoints (10 nix option writes
-        // of `programs.git.enable` across test configs) can't fill the pool
-        // before later tokens (`home.file`) get a slot.
-        if (dynNamed.size < 12) {
-          let tokenDyn = 0;
-          for (const n of hits) {
-            if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue;
-            if (hasHeuristicEdge(n.id)) {
-              dynNamed.set(n.id, n);
-              if (precise) preciseNamedIds.add(n.id);
-              tokenDyn++;
-            }
-            if (dynNamed.size >= 12 || tokenDyn >= 4) break;
-          }
-        }
-        if (named.size > 40) break;
-      }
+      // Token resolution — parsing, overload disambiguation, the CONSTANT/
+      // VARIABLE synth endpoints — is shared with `/api/flow`, so a name written
+      // in the viewer's search box resolves to the same nodes it does here.
+      const flow = resolveNamedSymbolFlow(cg, query);
+      const { named, dynNamed, tokenNodes, tokenFamily, uniqueNamedNodeIds, preciseNamedIds } =
+        flow;
+      if (flow.tokens.length < 2) return EMPTY;
       // Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
       // the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
       // already shown in the rendered main chain (a 2-node chain renders nothing, so a
@@ -2720,47 +2623,16 @@ export class ToolHandler {
         out.push('> Full source for these symbols is below.\n');
         return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map<string, number>() };
       }
-      const MAX_HOPS = 7;
-      let best: Array<{ node: Node; edge: Edge | null }> | null = null;
-      // BFS the full call graph (incl. synth edges) from each named seed, but
-      // only ACCEPT a sink that is also named — both ends anchored to symbols the
-      // agent named, so the chain stays on-topic while bridging intermediates
-      // (e.g. the exact interface overload) that the token resolution missed.
-      for (const seed of [...named.values()].slice(0, 8)) {
-        const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
-        parent.set(seed.id, { prev: null, edge: null, node: seed });
-        const q: Array<{ id: string; depth: number; streak: number }> = [{ id: seed.id, depth: 0, streak: 0 }];
-        let deep: string | null = null, deepDepth = 0;
-        const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
-        for (let h = 0; h < q.length && parent.size < 1500; h++) {
-          const { id, depth, streak } = q[h]!;
-          if (id !== seed.id && named.has(id) && depth > deepDepth) { deep = id; deepDepth = depth; }
-          if (depth >= MAX_HOPS - 1) continue;
-          for (const c of cg.getCallees(id)) {
-            if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
-            const newStreak = named.has(c.node.id) ? 0 : streak + 1;
-            if (newStreak > MAX_BRIDGE) continue;
-            parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
-            q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
-          }
-        }
-        if (!deep) continue;
-        const chain: Array<{ node: Node; edge: Edge | null }> = [];
-        let cur: string | null = deep;
-        while (cur) { const p = parent.get(cur); if (!p) break; chain.push({ node: p.node, edge: p.edge }); cur = p.prev; }
-        chain.reverse();
-        if (!best || chain.length > best.length) best = chain;
-      }
+      // The search itself lives in `../graph/named-symbol-flow`, so the viewer's
+      // Flow strip rides exactly this path finder rather than a second one that
+      // could disagree with it. What stays here is the PROSE — the narrative,
+      // the dynamic-dispatch links, the boundary announcements.
+      const best = flow.chains[0]?.steps ?? null;
       const hasMain = !!best && best.length >= 3;
       const pathIds = new Set((best ?? []).map((s) => s.node.id));
-      // Where each spine node calls the NEXT hop (best[i+1].edge is the edge from
-      // best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets
-      // the assembler window an oversize spine method to the call instead of dumping it.
-      const spineCallSites = new Map<string, number>();
-      if (best) for (let i = 0; i < best.length - 1; i++) {
-        const ln = best[i + 1]?.edge?.line;
-        if (ln && ln > 0 && !spineCallSites.has(best[i]!.node.id)) spineCallSites.set(best[i]!.node.id, ln);
-      }
+      // Where each spine node calls the NEXT hop — lets the assembler window an
+      // oversize spine method to the call instead of dumping the whole body.
+      const spineCallSites = flow.chains[0]?.callSites ?? new Map<string, number>();
 
       // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
       // asked about did not fully connect: some token resolved to nodes but
@@ -6715,71 +6587,11 @@ export class ToolHandler {
    * Returns the best match and a note about alternatives if any.
    */
   /**
-   * Check if a node matches a symbol query.
-   *
-   * Accepts simple names (`run`) and three flavors of qualifier:
-   *   - dotted     `Session.request`         (TS/JS/Python)
-   *   - colon-pair `stage_apply::run`        (Rust, C++, Ruby)
-   *   - slash      `configurator/stage_apply` (path-ish)
-   *
-   * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
-   * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
-   * the canonical `crate::module::symbol` form resolves.
-   *
-   * Resolution order, last part must always equal `node.name`:
-   *   1. Suffix-match against `qualifiedName` (handles class-scoped methods
-   *      where the extractor builds the qualified name from the AST stack)
-   *   2. File-path containment (handles file-derived modules in Rust/
-   *      Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
+   * Check if a node matches a symbol query — see `matchesSymbol` in
+   * `../graph/named-symbol-flow`, which owns the rules.
    */
   private matchesSymbol(node: Node, symbol: string): boolean {
-    // Erlang arity spelling (`fn/3`, `mod:fn/3` → normalized `mod.fn/3`): when
-    // the node's qualifiedName carries an arity (`mod::fn/3`, #1610), the
-    // written arity must match it exactly; the remaining comparison then runs
-    // on the arity-less spelling. A node with no arity in its qualifiedName
-    // keeps the original symbol (a `/` there means a path-ish name instead).
-    const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
-    if (aritySpelling) {
-      const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
-      if (nodeArity !== undefined) {
-        if (nodeArity !== aritySpelling[2]) return false;
-        symbol = aritySpelling[1]!;
-      }
-    }
-    // Simple name match
-    if (node.name === symbol) return true;
-    // File basename match (e.g., "product-card" matches "product-card.liquid")
-    if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
-
-    // Qualified-name lookups: split on any supported separator. `\w` keeps
-    // identifier chars (incl. `_`) intact; everything else is treated as
-    // a separator we tolerate.
-    if (!/[.\/]|::/.test(symbol)) return false;
-    const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
-    if (parts.length < 2) return false;
-
-    const lastPart = parts[parts.length - 1]!;
-    if (node.name !== lastPart) return false;
-
-    // Stage 1: qualified-name suffix match. The extractor joins the
-    // semantic hierarchy with `::`, so `Session.request` and
-    // `Session::request` both become `Session::request` here.
-    const colonSuffix = parts.join('::');
-    if (node.qualifiedName.includes(colonSuffix)) return true;
-
-    // Stage 2: file-path containment. Rust modules and Python packages
-    // are not in `qualifiedName` — they're encoded in the file path. So
-    // `stage_apply::run` matches a `run` in any file whose path
-    // contains a `stage_apply` segment (with or without an extension).
-    //
-    // Filter out Rust path prefixes that have no file-system equivalent.
-    const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
-    if (containerHints.length === 0) return false;
-
-    const segments = node.filePath.split('/').filter((s) => s.length > 0);
-    return containerHints.every((hint) =>
-      segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
-    );
+    return matchesSymbol(node, symbol);
   }
 
   /**
@@ -6843,64 +6655,12 @@ export class ToolHandler {
   /**
    * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
    * results across all matching symbols (e.g., multiple classes with an `execute` method).
+   *
+   * The resolution itself lives in `../graph/named-symbol-flow`, so the Flow
+   * strip and `codegraph_explore` resolve a written name to the same nodes.
    */
   private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
-    // Nix option paths: the declaration is stored as `options.<path>` and
-    // config writes carry longer/quoted tails (`<path>."git/config".text`),
-    // so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
-    // no exact-name node and would degrade to bare-tail FTS soup — burying
-    // the declaration hub the nix-option-path edges hang off. Resolve the
-    // convention directly: declaration first, then the exact write, then a
-    // capped prefix scan of write sites. Three index hits; non-nix graphs
-    // fall straight through.
-    if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
-      const optionHits = [
-        ...cg.getNodesByName(`options.${symbol}`),
-        ...cg.getNodesByName(symbol),
-        ...cg.getNodesByNamePrefix(`${symbol}.`, 12),
-      ].filter((n) => n.language === 'nix');
-      if (optionHits.length > 0) {
-        const seen = new Set<string>();
-        const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
-        return { nodes, note: '' };
-      }
-    }
-    let results = cg.searchNodes(symbol, { limit: 50 });
-
-    // Mirror the fallback in `findSymbol` for qualified queries — FTS
-    // strips colons, so a module-qualified lookup needs a second pass
-    // by the bare last part.
-    if (results.length === 0 && /[.\/]|::/.test(symbol)) {
-      const tail = lastQualifierPart(symbol);
-      if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
-    }
-
-    if (results.length === 0) {
-      return { nodes: [], note: '' };
-    }
-
-    const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
-
-    if (exactMatches.length <= 1) {
-      const node = exactMatches[0]?.node ?? results[0]!.node;
-      return { nodes: [node], note: '' };
-    }
-
-    // Same generated-file down-rank as findSymbol — keeps callers/callees
-    // /impact aggregation aligned (a query against "Send" returns the
-    // hand-written implementations before the protobuf scaffold).
-    const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
-    const ranked = [...exactMatches].sort((a, b) => {
-      const aGen = isGen(a.node.filePath) ? 1 : 0;
-      const bGen = isGen(b.node.filePath) ? 1 : 0;
-      return aGen - bGen;
-    });
-
-    const locations = ranked.map(r =>
-      `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
-    );
-    const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
-    return { nodes: ranked.map(r => r.node), note };
+    return findAllSymbols(cg, symbol);
   }
 
   /**

+ 599 - 0
src/ui-server/api/flow.ts

@@ -0,0 +1,599 @@
+/**
+ * `GET /api/flow` — the call path between two symbols, as cards.
+ *
+ * The Flow strip answers "how does A reach B" (design spec §3.5): one card per
+ * hop, each opened at the exact line that makes the next call, with the
+ * synthesized dynamic-dispatch hops drawn dashed and carrying the site they
+ * were wired at. This endpoint produces that path and the source windows for
+ * it; the geometry is a pure function in the viewer (`ui/src/lib/flow-model.ts`).
+ *
+ * **The path finder is not ours.** It is `resolveNamedSymbolFlow` in
+ * `src/graph/named-symbol-flow.ts` — literally the search `codegraph_explore`
+ * leads its answer with, extracted so both callers ride one implementation.
+ * A viewer that drew a different path from the one the MCP tool describes would
+ * be worse than no viewer: the two would be quoted against each other in a code
+ * review and one of them would be wrong.
+ *
+ * Three questions arrive here, and they are one question with different
+ * bindings:
+ *
+ * - `?from=&to=` — a directed question, from the search box's flow grammar.
+ *   Both ends pinned, shortest path wins.
+ * - `?symbols=a,b,c` — explore's own question, verbatim. Longest chain among
+ *   the named symbols wins, at most one unnamed bridge.
+ * - `?hop=s<id>&hop=d<id>…` — the trail, read as a flow. Nothing is searched:
+ *   the hops are the ones the reader walked, and the work is finding the edge
+ *   that already connects each consecutive pair.
+ *
+ * **Nothing here is cached.** Every other multi-symbol endpoint memoises on the
+ * index version, and this one deliberately does not: a flow card carries source
+ * read from disk, and the drift verdict on it changes without the index
+ * changing. A cached "no drift" is exactly the failure `/api/source` exists to
+ * prevent. The search itself costs tens of milliseconds; the windows are seven
+ * lines each.
+ */
+
+import type CodeGraph from '../../index';
+import type { Edge, Node } from '../../types';
+import {
+  resolveNamedSymbolFlow,
+  normalizeToken,
+  DIRECTED_MAX_HOPS,
+} from '../../graph/named-symbol-flow';
+import { highlightLines, type HighlightResult } from '../highlight';
+import { badRequest, intParam } from './respond';
+import { findIndexedFile, hasDriftedOnDisk, splitLines, toRequestPath } from './source';
+import { resolveProjectFile } from '../security';
+import { toNodeRef, toWireEdge, UNCERTAIN_BELOW, type WireEdge, type WireNodeRef } from './wire';
+import * as fs from 'fs';
+
+/** Lines shown either side of the call site on a card (design spec §3.5). */
+export const SOURCE_WINDOW = 3;
+
+/**
+ * How far above a window the highlighter is allowed to start reading.
+ *
+ * Seven lines tokenised on their own do not know they are inside a block
+ * comment or a template literal, and a window that opens under a JSDoc would
+ * render the prose as code. Leading in from the enclosing symbol's first line
+ * fixes that for every ordinary body; the cap stops a thousand-line god
+ * function from costing a full-file tokenisation for one card. Past it a window
+ * can still open mid-construct — rare, and cheaper than the alternative.
+ */
+const HIGHLIGHT_LEAD_MAX = 200;
+
+/** Distinct paths returned. The header's flow picker is a short list or nothing. */
+export const MAX_FLOWS = 4;
+
+/** Hops accepted from a trail. The trail bar itself is not much longer than this. */
+const MAX_TRAIL_HOPS = 24;
+
+// =============================================================================
+// Wire shapes
+// =============================================================================
+
+export interface WireFlowEdge extends WireEdge {
+  /** The link's label: "calls", "via callback · registered at file:line". */
+  label: string;
+  /** This hop reads callee → caller — the reader stepped UP into it. */
+  upward: boolean;
+  /** `metadata.confidence` below {@link UNCERTAIN_BELOW}: dashed `2 3`. */
+  uncertain: boolean;
+  /** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
+  synthesized: boolean;
+}
+
+export interface WireFlowSource {
+  file: string;
+  language: string;
+  from: number;
+  to: number;
+  /** Absent when `drift` — a mis-sliced window is worse than an empty card. */
+  lines?: string[];
+  highlight?: HighlightResult;
+  drift: boolean;
+  /** Why there are no lines, when there are none. */
+  reason?: string;
+}
+
+/**
+ * The call site this card is opened at — the identifier the strip draws as a
+ * link, and the line the source window is centred on.
+ *
+ * It is not always a call to the NEXT card. Reading a trail backwards steps
+ * from a callee up to its caller, and the line that connects them then lives in
+ * the caller's body and names the symbol on the PREVIOUS card. Either way the
+ * rule is the same: a card opens at the line that ties it to its neighbour.
+ */
+export interface WireFlowCallRef {
+  line: number;
+  /** 0-based column the edge recorded, or null when it carries none. */
+  col: number | null;
+  /** The identifier as the graph names it — what the token must match. */
+  name: string;
+  /** The symbol at the other end of the edge. */
+  targetId: string;
+  /** The link points back at the previous card, not on to the next one. */
+  backwards: boolean;
+}
+
+export interface WireFlowHop {
+  node: WireNodeRef;
+  /** The edge from the PREVIOUS hop into this one; null on the first. */
+  edge: WireFlowEdge | null;
+  /** Where this card is opened, and what it links to. Null when it is neither. */
+  callRef: WireFlowCallRef | null;
+  /** The window this card shows, centred on `callRef` or the definition. */
+  source: WireFlowSource | null;
+}
+
+export interface WireFlow {
+  /** Stable within a payload: the hop ids joined. Used as the picker's value. */
+  id: string;
+  /** "execute → rowToFileRecord", for the header's flow picker. */
+  label: string;
+  hops: WireFlowHop[];
+}
+
+/** An endpoint that named more than one definition, and which one was taken. */
+export interface WireFlowAmbiguity {
+  token: string;
+  chosen: WireNodeRef | null;
+  others: WireNodeRef[];
+}
+
+export interface WireFlowPayload {
+  query: {
+    kind: 'directed' | 'symbols' | 'trail';
+    from: string | null;
+    to: string | null;
+    /** The tokens the search actually used. */
+    symbols: string[];
+  };
+  flows: WireFlow[];
+  ambiguous: WireFlowAmbiguity[];
+  /** Tokens that named nothing in this index. */
+  unresolved: string[];
+  /** Why there is no flow, when there is none. Null when there is one. */
+  reason: string | null;
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number };
+}
+
+// =============================================================================
+// Query
+// =============================================================================
+
+export type FlowQuery =
+  | { kind: 'directed'; from: string; to: string }
+  | { kind: 'symbols'; text: string }
+  | { kind: 'trail'; hops: Array<{ id: string; dir: 'start' | 'down' | 'up' }> };
+
+const DIR_CHARS: Record<string, 'start' | 'down' | 'up'> = { s: 'start', d: 'down', u: 'up' };
+
+/**
+ * Read the question out of the query string.
+ *
+ * A trail hop arrives as its own `hop` parameter rather than in one joined
+ * list, for the same reason `/api/nodes` repeats `id`: a node id can be a file
+ * path and a file path can contain a comma. The one-character direction prefix
+ * mirrors `ui/src/lib/trail-codec.ts`, which owns the format.
+ */
+export function parseFlowQuery(query: URLSearchParams): FlowQuery {
+  const rawHops = query.getAll('hop').filter((h) => h.length > 1);
+  if (rawHops.length > 0) {
+    if (rawHops.length > MAX_TRAIL_HOPS) {
+      throw badRequest(
+        `A trail of ${rawHops.length} hops is longer than this endpoint reads (${MAX_TRAIL_HOPS}).`
+      );
+    }
+    const hops = rawHops.map((raw) => ({
+      id: raw.slice(1),
+      dir: DIR_CHARS[raw[0] as string] ?? ('down' as const),
+    }));
+    if (hops.length < 2) {
+      throw badRequest('A trail needs at least two hops to be read as a flow.');
+    }
+    return { kind: 'trail', hops };
+  }
+
+  const from = (query.get('from') ?? '').trim();
+  const to = (query.get('to') ?? '').trim();
+  if (from && to) {
+    if (normalizeToken(from) === normalizeToken(to)) {
+      throw badRequest('"from" and "to" name the same symbol, so there is no path to draw.');
+    }
+    return { kind: 'directed', from, to };
+  }
+
+  const symbols = (query.get('symbols') ?? '').trim();
+  if (symbols) return { kind: 'symbols', text: symbols };
+
+  throw badRequest(
+    'No flow was asked for.',
+    'Use /api/flow?from=<symbol>&to=<symbol>, ?symbols=a,b,c, or ?hop=s<id>&hop=d<id>.'
+  );
+}
+
+// =============================================================================
+// Edges
+// =============================================================================
+
+/**
+ * The sentence under a link.
+ *
+ * A synthesized hop must never read as a plain `calls`: it is a bridge the
+ * resolver inferred, and the wiring site is the evidence for it. Design spec
+ * §3.5 fixes the phrasing — "via callback · registered at file:line".
+ */
+export function flowEdgeLabel(edge: Edge, upward: boolean): string {
+  const meta = (edge.metadata ?? {}) as Record<string, unknown>;
+  const parts: string[] = [];
+  if (edge.provenance === 'heuristic' && typeof meta.synthesizedBy === 'string') {
+    const mechanism = meta.synthesizedBy.replace(/-/g, ' ');
+    parts.push(`via ${mechanism}`);
+    if (typeof meta.via === 'string' && meta.via) parts.push(meta.via);
+    if (typeof meta.registeredAt === 'string' && meta.registeredAt) {
+      parts.push(`registered at ${meta.registeredAt}`);
+    }
+  } else {
+    parts.push(upward ? 'called by' : edge.kind);
+  }
+  return parts.join(' · ');
+}
+
+function toFlowEdge(edge: Edge, upward: boolean): WireFlowEdge {
+  const meta = (edge.metadata ?? {}) as Record<string, unknown>;
+  const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
+  return {
+    ...toWireEdge(edge),
+    label: flowEdgeLabel(edge, upward),
+    upward,
+    uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
+    synthesized: edge.provenance === 'heuristic',
+  };
+}
+
+// =============================================================================
+// Source windows
+// =============================================================================
+
+/** One read + one hash per file, however many cards land in it. */
+interface FileCache {
+  lines: string[] | null;
+  language: string;
+  drift: boolean;
+  reason?: string;
+}
+
+function loadFile(
+  cg: CodeGraph,
+  projectRoot: string,
+  cache: Map<string, FileCache>,
+  filePath: string
+): FileCache | null {
+  const posix = toRequestPath(filePath);
+  const hit = cache.get(posix);
+  if (hit) return hit;
+
+  const found = findIndexedFile(cg, posix);
+  if (!found) return null;
+
+  let entry: FileCache;
+  if (hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) {
+    entry = {
+      lines: null,
+      language: found.record.language,
+      drift: true,
+      reason:
+        'This file changed on disk after the last index sync, so the recorded call ' +
+        'line no longer reliably points at this call. The window returns after the next sync.',
+    };
+  } else {
+    try {
+      // The chokepoint, before anything is opened — see `source.ts`.
+      const absolute = resolveProjectFile(projectRoot, found.storedPath);
+      entry = {
+        lines: splitLines(fs.readFileSync(absolute, 'utf-8')),
+        language: found.record.language,
+        drift: false,
+      };
+    } catch {
+      entry = {
+        lines: null,
+        language: found.record.language,
+        drift: false,
+        reason: 'This file is in the index but could not be read.',
+      };
+    }
+  }
+  cache.set(posix, entry);
+  return entry;
+}
+
+/**
+ * The ±{@link SOURCE_WINDOW} lines a card shows.
+ *
+ * Anchored on the line that makes the next call. The last card has no next
+ * call, so it anchors on the definition instead — a reader who followed seven
+ * hops to get there wants to see what they arrived at.
+ */
+async function windowFor(
+  cg: CodeGraph,
+  projectRoot: string,
+  cache: Map<string, FileCache>,
+  node: Node,
+  anchor: number
+): Promise<WireFlowSource | null> {
+  const file = loadFile(cg, projectRoot, cache, node.filePath);
+  if (!file) return null;
+  const posix = toRequestPath(node.filePath);
+  if (file.lines === null) {
+    return {
+      file: posix,
+      language: file.language,
+      from: anchor,
+      to: anchor,
+      drift: file.drift,
+      ...(file.reason ? { reason: file.reason } : {}),
+    };
+  }
+
+  const total = file.lines.length;
+  const from = Math.max(1, Math.min(anchor - SOURCE_WINDOW, total));
+  const to = Math.max(from, Math.min(anchor + SOURCE_WINDOW, total));
+  // Tokenise with the lead-in, then keep only the window — see HIGHLIGHT_LEAD_MAX.
+  const leadFrom = Math.max(1, Math.min(from, Math.max(node.startLine, from - HIGHLIGHT_LEAD_MAX)));
+  const highlighted = await highlightLines(file.lines.slice(leadFrom - 1, to), {
+    language: file.language,
+    cacheKey: `${posix}:${leadFrom}:${to}`,
+  });
+  return {
+    file: posix,
+    language: file.language,
+    from,
+    to,
+    lines: file.lines.slice(from - 1, to),
+    highlight: {
+      ...highlighted,
+      lines: highlighted.lines.slice(from - leadFrom),
+    },
+    drift: false,
+  };
+}
+
+// =============================================================================
+// Building the flows
+// =============================================================================
+
+/** The steps of one chain, plus the edge that brought the reader into each. */
+interface RawHop {
+  node: Node;
+  edge: Edge | null;
+  upward: boolean;
+}
+
+async function toWireFlow(
+  cg: CodeGraph,
+  projectRoot: string,
+  cache: Map<string, FileCache>,
+  raw: readonly RawHop[]
+): Promise<WireFlow> {
+  const hops: WireFlowHop[] = [];
+  for (let i = 0; i < raw.length; i++) {
+    const step = raw[i] as RawHop;
+    const previous = raw[i - 1];
+    const next = raw[i + 1];
+    // Forward: the edge into the NEXT hop was recorded at the line inside THIS
+    // body that makes the call. Backwards (a trail read from a callee up to its
+    // caller): this card IS the caller, and its own incoming edge carries the
+    // line where it calls the card before it.
+    let callRef: WireFlowCallRef | null = null;
+    if (next !== undefined && !next.upward && next.edge?.line) {
+      callRef = {
+        line: next.edge.line,
+        col: typeof next.edge.column === 'number' ? next.edge.column : null,
+        name: next.node.name,
+        targetId: next.node.id,
+        backwards: false,
+      };
+    } else if (step.upward && previous !== undefined && step.edge?.line) {
+      callRef = {
+        line: step.edge.line,
+        col: typeof step.edge.column === 'number' ? step.edge.column : null,
+        name: previous.node.name,
+        targetId: previous.node.id,
+        backwards: true,
+      };
+    }
+    hops.push({
+      node: toNodeRef(step.node),
+      edge: step.edge === null ? null : toFlowEdge(step.edge, step.upward),
+      callRef,
+      source: await windowFor(
+        cg,
+        projectRoot,
+        cache,
+        step.node,
+        callRef?.line ?? step.node.startLine
+      ),
+    });
+  }
+  const first = raw[0]?.node.name ?? '?';
+  const last = raw[raw.length - 1]?.node.name ?? '?';
+  return {
+    id: raw.map((h) => h.node.id).join('>'),
+    label: `${first} → ${last}`,
+    hops,
+  };
+}
+
+/**
+ * The edge that already connects two symbols the reader walked between.
+ *
+ * A trail is not searched — the hops are given — so all that is missing is
+ * which recorded edge the reader crossed. A `down` hop is a call out of the
+ * previous symbol; an `up` hop is the same edge read backwards, which is why
+ * `upward` exists and why the link says "called by" rather than "calls".
+ */
+function edgeBetween(
+  cg: CodeGraph,
+  from: Node,
+  to: Node
+): { edge: Edge; upward: boolean } | null {
+  let best: Edge | null = null;
+  for (const { node, edge } of cg.getCallees(from.id)) {
+    if (node.id !== to.id) continue;
+    if (best === null || (edge.kind === 'calls' && best.kind !== 'calls')) best = edge;
+  }
+  if (best) return { edge: best, upward: false };
+  for (const { node, edge } of cg.getCallers(from.id)) {
+    if (node.id !== to.id) continue;
+    if (best === null || (edge.kind === 'calls' && best.kind !== 'calls')) best = edge;
+  }
+  return best ? { edge: best, upward: true } : null;
+}
+
+function ambiguitiesOf(
+  tokenNodes: ReadonlyMap<string, string[]>,
+  named: ReadonlyMap<string, Node>,
+  chosen: ReadonlySet<string>,
+  tokens: readonly string[]
+): WireFlowAmbiguity[] {
+  const out: WireFlowAmbiguity[] = [];
+  for (const token of tokens) {
+    const ids = tokenNodes.get(token) ?? [];
+    if (ids.length < 2) continue;
+    const picked = ids.find((id) => chosen.has(id)) ?? null;
+    out.push({
+      token,
+      chosen: picked ? toNodeRef(named.get(picked) as Node) : null,
+      others: ids
+        .filter((id) => id !== picked)
+        .map((id) => named.get(id))
+        .filter((n): n is Node => !!n)
+        .map(toNodeRef),
+    });
+  }
+  return out;
+}
+
+export async function buildFlow(
+  cg: CodeGraph,
+  projectRoot: string,
+  query: URLSearchParams
+): Promise<WireFlowPayload> {
+  const started = Date.now();
+  const parsed = parseFlowQuery(query);
+  const maxFlows = intParam(query, 'limit', { min: 1, max: MAX_FLOWS, default: MAX_FLOWS });
+  const stats = cg.getStats();
+  const cache = new Map<string, FileCache>();
+
+  const base = {
+    flows: [] as WireFlow[],
+    ambiguous: [] as WireFlowAmbiguity[],
+    unresolved: [] as string[],
+    reason: null as string | null,
+    index: {
+      lastIndexedAt: cg.getLastIndexedAt() ?? null,
+      edges: stats.edgeCount,
+      files: stats.fileCount,
+    },
+  };
+
+  if (parsed.kind === 'trail') {
+    const byId = cg.getNodesByIds(parsed.hops.map((h) => h.id));
+    const raw: RawHop[] = [];
+    const missing: string[] = [];
+    for (const hop of parsed.hops) {
+      const node = byId.get(hop.id);
+      if (!node) {
+        missing.push(hop.id);
+        continue;
+      }
+      const previous = raw[raw.length - 1];
+      const link = previous ? edgeBetween(cg, previous.node, node) : null;
+      raw.push({ node, edge: link?.edge ?? null, upward: link?.upward ?? hop.dir === 'up' });
+    }
+    const flows = raw.length >= 2 ? [await toWireFlow(cg, projectRoot, cache, raw)] : [];
+    return {
+      ...base,
+      query: { kind: 'trail', from: null, to: null, symbols: [] },
+      flows,
+      unresolved: missing,
+      reason:
+        flows.length > 0
+          ? null
+          : 'None of the symbols on this trail are still in the index. Re-index, or start a new trail.',
+      timing: { elapsedMs: Date.now() - started },
+    };
+  }
+
+  const directed = parsed.kind === 'directed';
+  const text = directed ? `${parsed.from} ${parsed.to}` : parsed.text;
+  const flow = resolveNamedSymbolFlow(
+    cg,
+    text,
+    directed
+      ? { mode: 'directed', from: parsed.from, to: parsed.to, maxChains: maxFlows }
+      : { mode: 'named', maxChains: maxFlows }
+  );
+
+  const unresolved = flow.tokens.filter((t) => (flow.tokenNodes.get(t) ?? []).length === 0);
+  const chosen = new Set(flow.chains.flatMap((c) => c.steps.map((s) => s.node.id)));
+  const flows: WireFlow[] = [];
+  for (const chain of flow.chains) {
+    flows.push(
+      await toWireFlow(
+        cg,
+        projectRoot,
+        cache,
+        chain.steps.map((s) => ({ node: s.node, edge: s.edge, upward: false }))
+      )
+    );
+  }
+
+  return {
+    ...base,
+    query: {
+      kind: parsed.kind,
+      from: directed ? parsed.from : null,
+      to: directed ? parsed.to : null,
+      symbols: flow.tokens,
+    },
+    flows,
+    ambiguous: ambiguitiesOf(flow.tokenNodes, flow.named, chosen, flow.tokens),
+    unresolved,
+    reason: flows.length > 0 ? null : noFlowReason(parsed, flow.tokens.length, unresolved),
+    timing: { elapsedMs: Date.now() - started },
+  };
+}
+
+/**
+ * Why there is no strip, in the words that say what to do next.
+ *
+ * "Not connected" is a real answer about this index, not a failure — a flow
+ * that runs through a dynamic dispatch the resolver could not bridge genuinely
+ * has no static path, and saying so is the honest end of the search. CG-51
+ * turns this sentence into the boundary end cap that names the dispatch site.
+ */
+function noFlowReason(
+  parsed: FlowQuery,
+  tokenCount: number,
+  unresolved: readonly string[]
+): string {
+  if (unresolved.length > 0) {
+    return `${unresolved.join(' and ')} ${unresolved.length > 1 ? 'name' : 'names'} nothing in this index.`;
+  }
+  if (parsed.kind === 'directed') {
+    return (
+      `No chain of calls reaches ${parsed.to} from ${parsed.from} within ${DIRECTED_MAX_HOPS} hops. ` +
+      'The path may run through a dynamic dispatch — a callback, a registry, a reflective ' +
+      'call — that no static edge records.'
+    );
+  }
+  if (tokenCount < 2) {
+    return 'Name at least two symbols: a flow is a path between them.';
+  }
+  return 'Those symbols do not call one another, directly or through one intermediate.';
+}

+ 19 - 1
src/ui-server/api/index.ts

@@ -1,7 +1,7 @@
 /**
  * The read-only JSON API the viewer reads its screens from.
  *
- * Nine endpoints, one per screen, each answering in a single round-trip — the
+ * Ten endpoints, one per screen, each answering in a single round-trip — the
  * same principle as `codegraph_explore`: return enough that the caller does not
  * have to ask a follow-up question. Everything here is a *reader* of the
  * existing schema; nothing indexes, resolves, or writes.
@@ -16,6 +16,7 @@
  * GET /api/routes                    the URL to handler map, when there is one
  * GET /api/entrypoints               where to start reading: routes, roots, hubs
  * GET /api/map?root=&depth=          the module map: modules, links, cycles
+ * GET /api/flow?from=&to=            the flow strip: one card per hop
  * ```
  *
  * It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
@@ -39,12 +40,22 @@ import { buildRoutes } from './routes';
 import { buildEntryPoints } from './entrypoints';
 import { buildNodeRefs } from './nodes';
 import { buildMap } from './map';
+import { buildFlow } from './flow';
 
 export { GraphSession } from './session';
 export { ApiError } from './respond';
 export * from './wire';
 export type { WireEntryPoints, WireEntryFile, WireEntryHub } from './entrypoints';
 export type { WireNodeRefs } from './nodes';
+export type {
+  WireFlowPayload,
+  WireFlow,
+  WireFlowHop,
+  WireFlowEdge,
+  WireFlowSource,
+  WireFlowCallRef,
+  WireFlowAmbiguity,
+} from './flow';
 export type {
   WireMapPayload,
   WireMapModule,
@@ -89,6 +100,11 @@ const API_INDEX = {
       description: 'The repository at module granularity: modules, cross-module links, cycles.',
       params: ['root', 'depth'],
     },
+    {
+      path: '/api/flow',
+      description: 'The call path between symbols: one hop per card, opened at the calling line.',
+      params: ['from', 'to', 'symbols', 'hop', 'limit'],
+    },
     {
       path: '/api/entrypoints',
       description: 'Where to start reading: routes, files that run something, and hubs.',
@@ -122,6 +138,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
           return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
         case '/api/source':
           return ok(res, await buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
+        case '/api/flow':
+          return ok(res, await buildFlow(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
         default:
           return dispatchPathRoutes(route, res, ctx, session);
       }

+ 6 - 3
ui/README.md

@@ -43,7 +43,8 @@ src/
   lib/trail.svelte.ts     the walked path; mirrored into the `t` query param
   lib/kinds.ts            kind glyph letters
   lib/map-model.ts        the Map's deterministic layered layout (pure)
-  components/             TopBar, TrailBar, KindGlyph, map/, symbol/, file/
+  lib/flow-model.ts       the Flow strip's card/link geometry — a DAG (pure)
+  components/             TopBar, TrailBar, KindGlyph, map/, flow/, symbol/, file/
   views/                  one component per route
 ```
 
@@ -59,9 +60,11 @@ announce the project to a font CDN.
 | `#/s/<id>?hl=<line>&t=<trail>` | symbol view |
 | `#/file/<path>?hl=<line>` | file view |
 | `#/map?root=&depth=&tests=1` | module map |
-| `#/flow[/<key>]` | flow strip — reserved, phase 2 |
+| `#/flow?from=&to=` | flow strip — the call path between two symbols |
+| `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |
+| `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
 
 Node ids and file paths are encoded per slash-separated segment, so
 `#/file/src/mcp/tools.ts` stays readable and still round-trips a segment
 containing a reserved character. Build hashes with `symbolHref()` /
-`fileHref()` rather than by hand.
+`fileHref()` / `mapHref()` / `flowHref()` rather than by hand.

+ 6 - 1
ui/src/App.svelte

@@ -100,7 +100,12 @@
   {:else if route.view === 'map'}
     <MapView root={route.root} depth={route.depth} tests={route.tests} />
   {:else if route.view === 'flow'}
-    <FlowView flowKey={route.key} />
+    <FlowView
+      from={route.from}
+      to={route.to}
+      symbols={route.symbols}
+      trailParam={route.trail}
+    />
   {:else if route.view === 'unknown'}
     <NotFoundView path={route.path} />
   {:else}

+ 5 - 0
ui/src/components/PaletteRows.svelte

@@ -71,6 +71,11 @@
           <span class="nm">{item.url}</span>
           <span class="sig">{item.handler}</span>
         </span>
+      {:else if item.type === 'flow'}
+        <KindGlyph kind="route" />
+        <span class="mid">
+          <span class="nm">{item.name}</span>
+        </span>
       {:else}
         <KindGlyph kind={item.node.kind} />
         <span class="mid">

+ 8 - 0
ui/src/components/TopBar.svelte

@@ -40,6 +40,14 @@
    * put a `→` in the trail that describes no call.
    */
   export function pick(item: PaletteItem): void {
+    // A flow is not a place in the graph, so it does not join the trail: it is
+    // a question about two symbols, and the Flow view answers it.
+    if (item.type === 'flow') {
+      palette.reset();
+      input?.blur();
+      navigate(flowHref({ from: item.from, to: item.to }));
+      return;
+    }
     const id = item.type === 'route' ? item.nodeId : item.id;
     // A route whose handler never resolved to a node has nowhere to go; the
     // row stays, because "this URL exists and we could not place it" is true.

+ 8 - 11
ui/src/components/TrailBar.svelte

@@ -3,14 +3,6 @@
   import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte';
   import { navigate, symbolHref, flowHref } from '../lib/router.svelte';
 
-  /**
-   * "Read as flow" replays the trail as a computed path in the Flow view,
-   * which is phase 2 (CG-50). The control is built and wired; it stays hidden
-   * until there is a view to send it to, because a button that lands on a
-   * placeholder is worse than no button.
-   */
-  const READ_AS_FLOW = false;
-
   let hops = $derived(trail.hops);
 
   function step(index: number) {
@@ -20,9 +12,14 @@
     navigate(symbolHref(hop.id, { trail: encodeTrail(trail.hops) }));
   }
 
+  /**
+   * The walk itself IS the flow: the Flow view does not search for a path, it
+   * looks up the edge already joining each consecutive pair and draws the cards
+   * at those lines. So the trail travels under the same `t` param it uses
+   * everywhere else — a flow read from a trail is one walk under two lenses.
+   */
   function readAsFlow() {
-    // The flow key is the walk itself; the Flow view (phase 2) replays it.
-    navigate(flowHref(encodeTrail(hops)));
+    navigate(flowHref({ trail: encodeTrail(hops) }));
   }
 
   /**
@@ -84,7 +81,7 @@
 
   <span class="spacer"></span>
 
-  {#if READ_AS_FLOW && hops.length > 1}
+  {#if hops.length > 1}
     <button type="button" class="tb-btn" onclick={readAsFlow}>Read as flow</button>
   {/if}
   {#if hops.length > 0}

+ 246 - 0
ui/src/components/flow/FlowCard.svelte

@@ -0,0 +1,246 @@
+<!--
+  One hop of a flow: the symbol, where it lives, and the seven lines around the
+  call that carries the reader to the next card (design spec §3.5).
+
+  The card is a Svelte Flow node, but nothing about it is Svelte Flow's: the
+  handles are hidden ports at the vertical middle of each side, the position
+  came from `buildFlowLayout`, and the height is the one that layout computed —
+  pinned here so the arrows land where the arithmetic said they would.
+
+  The source window is the Symbol view's code block with the noise removed. It
+  keeps the two things that make the code readable: the server's TextMate
+  classification, and one accent link on the identifier the graph resolved. It
+  drops gutter ports and multi-window folding, because a seven-line card has
+  neither a gutter worth reading nor anything to fold.
+-->
+<script lang="ts">
+  import { Handle, Position } from '@xyflow/svelte';
+  import KindGlyph from '../KindGlyph.svelte';
+  import { tokenClass, tokensByLine, type Token } from '../../lib/highlight';
+  import { assignRefs, basename, type LineRef } from '../../lib/symbol-model';
+  import type { FlowCardLayout } from '../../lib/flow-model';
+
+  interface Props {
+    data: {
+      card: FlowCardLayout;
+      current: boolean;
+      dimmed: boolean;
+      onOpen: (card: FlowCardLayout) => void;
+      onFollow: (card: FlowCardLayout) => void;
+    };
+  }
+
+  let { data }: Props = $props();
+  let card = $derived(data.card);
+  let hop = $derived(card.hop);
+  let source = $derived(hop.source);
+
+  /** The call site as the code block's overlay wants it: one ref on one line. */
+  let refs = $derived.by<Map<number, LineRef[]>>(() => {
+    const byLine = new Map<number, LineRef[]>();
+    const ref = hop.callRef;
+    if (!ref) return byLine;
+    byLine.set(ref.line, [
+      {
+        ident: ref.name,
+        col: ref.col,
+        targetId: ref.targetId,
+        uncertain: false,
+        outside: false,
+        title: ref.backwards
+          ? `${hop.node.name} calls ${ref.name} here`
+          : `calls ${ref.name}`,
+      },
+    ]);
+    return byLine;
+  });
+
+  let tokens = $derived.by<Map<number, Token[]>>(() =>
+    source?.lines ? tokensByLine(source.lines, source.from, source.highlight) : new Map()
+  );
+
+  interface Part {
+    text: string;
+    cls: string | null;
+    ref: LineRef | null;
+  }
+
+  let rows = $derived.by(() => {
+    if (!source?.lines) return [];
+    return source.lines.map((text, offset) => {
+      const n = source.from + offset;
+      const lineTokens = tokens.get(n) ?? [{ cls: 'other' as const, text, col: 0 }];
+      const claimed = assignRefs(lineTokens, refs.get(n) ?? []);
+      return {
+        n,
+        call: n === hop.callRef?.line,
+        parts: lineTokens.map((token, index): Part => {
+          const ref = claimed.get(index) ?? null;
+          return { text: token.text, cls: ref ? null : tokenClass(token.cls), ref };
+        }),
+      };
+    });
+  });
+</script>
+
+<div
+  class="card"
+  class:cur={data.current}
+  class:dim={data.dimmed}
+  style={`width:${card.width}px;height:${card.height}px`}
+>
+  <Handle type="target" position={Position.Left} id="in" isConnectable={false} />
+  <Handle type="source" position={Position.Right} id="out" isConnectable={false} />
+
+  <button type="button" class="head" onclick={() => data.onOpen(card)}>
+    <KindGlyph kind={hop.node.kind} />
+    <span class="nm">{hop.node.name}</span>
+    <span class="loc">{basename(hop.node.file)}:{hop.node.line}</span>
+  </button>
+
+  {#if rows.length > 0}
+    <div class="code">
+      {#each rows as row (row.n)}
+        <div class="ln" class:call={row.call}>
+          <span class="no">{row.n}</span>
+          <span class="tx"
+            >{#each row.parts as part, i (i)}{#if part.ref}<button
+                  type="button"
+                  class="ref"
+                  title={part.ref.title}
+                  onclick={() => data.onFollow(card)}>{part.text}</button
+                >{:else if part.cls}<span class={part.cls}>{part.text}</span
+                >{:else}{part.text}{/if}{/each}</span
+          >
+        </div>
+      {/each}
+    </div>
+  {:else}
+    <p class="nosource">
+      {source?.drift
+        ? 'Changed on disk after the last index sync — source is not shown.'
+        : (source?.reason ?? 'Source outside this slice or this index.')}
+    </p>
+  {/if}
+</div>
+
+<style>
+  .card {
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    background: var(--paper);
+    border: 1px solid var(--rule-soft);
+    text-align: left;
+  }
+
+  .card:hover {
+    border-color: var(--ink);
+  }
+
+  .card.cur {
+    border-color: var(--accent);
+  }
+
+  .card.dim {
+    opacity: 0.4;
+  }
+
+  .head {
+    display: grid;
+    align-items: baseline;
+    padding: 10px 12px 6px;
+    border-bottom: 1px solid var(--rule-faint);
+    background: none;
+    color: var(--ink);
+    gap: 8px;
+    grid-template-columns: 16px 1fr auto;
+    text-align: left;
+  }
+
+  .head:hover .nm {
+    color: var(--accent);
+  }
+
+  .nm {
+    overflow: hidden;
+    font: 600 13px var(--mono);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .loc {
+    color: var(--ink-3);
+    font: 11px var(--mono);
+    white-space: nowrap;
+  }
+
+  .code {
+    padding: 6px 0;
+    font: 12px / 19px var(--mono);
+  }
+
+  .ln {
+    display: grid;
+    align-items: stretch;
+    grid-template-columns: 40px 1fr 6px;
+  }
+
+  .ln.call {
+    background: var(--accent-soft);
+  }
+
+  .no {
+    padding-right: 10px;
+    color: var(--ink-4);
+    font-size: 11px;
+    text-align: right;
+    user-select: none;
+  }
+
+  .tx {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: pre;
+  }
+
+  .nosource {
+    margin: 0;
+    padding: 6px 12px;
+    color: var(--ink-3);
+    font-size: 12px;
+    line-height: 19px;
+  }
+
+  /* Token classes — the same near-monochrome ramp the Symbol view paints
+     (design spec §2.2); the class names come from the server's theme. */
+  .t-c {
+    color: var(--code-comment);
+  }
+  .t-s {
+    color: var(--ink-2);
+  }
+  .t-k {
+    font-weight: 500;
+  }
+  .t-n {
+    color: var(--ink-2);
+  }
+
+  /* The only colour in the window: the call this card is opened at. */
+  .ref {
+    padding: 0;
+    background: none;
+    color: var(--accent);
+    border: 0;
+    cursor: pointer;
+    font: inherit;
+    text-decoration: underline;
+    text-decoration-color: var(--accent-line);
+    text-underline-offset: 3px;
+  }
+
+  .ref:hover {
+    text-decoration-color: var(--accent);
+  }
+</style>

+ 75 - 0
ui/src/components/flow/FlowLink.svelte

@@ -0,0 +1,75 @@
+<!--
+  The connector between two cards (design spec §3.5): an 86px hairline with a
+  filled arrowhead, labelled with the edge and the line it was recorded at.
+
+  The line style is the honesty in the picture. A solid line is a call the
+  resolver read out of the source; `2 3` is a name-only match under 0.6
+  confidence; `5 3` is a synthesized dynamic-dispatch bridge, and its label
+  names the mechanism and the site it was wired at — a hop nobody can see in the
+  source has to say where it came from.
+
+  Straight, not curved: two cards on the same row sit at the same height, and a
+  bezier between them would be a decorative wobble. The path bends only when a
+  branch puts them on different rows.
+-->
+<script lang="ts">
+  import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
+  import type { FlowLinkLayout } from '../../lib/flow-model';
+
+  let { sourceX, sourceY, targetX, targetY, data }: EdgeProps = $props();
+
+  const d = $derived(data as unknown as { link: FlowLinkLayout; dimmed: boolean });
+
+  const path = $derived.by(() => {
+    if (Math.abs(sourceY - targetY) < 0.5) return `M${sourceX},${sourceY} L${targetX},${targetY}`;
+    const midX = (sourceX + targetX) / 2;
+    return `M${sourceX},${sourceY} C${midX},${sourceY} ${midX},${targetY} ${targetX},${targetY}`;
+  });
+
+  /** The spec's `76,3 84,7 76,11` arrowhead, placed at the target's port. */
+  const head = $derived(
+    `${targetX - 10},${targetY - 4} ${targetX - 2},${targetY} ${targetX - 10},${targetY + 4}`
+  );
+
+  const labelX = $derived((sourceX + targetX) / 2);
+  const labelY = $derived((sourceY + targetY) / 2);
+  const dashStyle = $derived(d.link.dash ? `stroke-dasharray:${d.link.dash}` : '');
+  /** Stacked upwards from the line, so the last clause sits nearest it. */
+  const above = $derived(d.link.labelLines);
+</script>
+
+<BaseEdge {path} class={`flink${d.dimmed ? ' dimmed' : ''}`} style={dashStyle} />
+<polygon class={`fhead${d.dimmed ? ' dimmed' : ''}`} points={head} />
+<g class={`flabel${d.dimmed ? ' dimmed' : ''}`}>
+  <title>{d.link.label}{d.link.lineLabel ? ` (${d.link.lineLabel})` : ''}</title>
+  {#each above as line, i (i)}
+    <text x={labelX} y={labelY - 8 - (above.length - 1 - i) * 13} text-anchor="middle">{line}</text>
+  {/each}
+  {#if d.link.lineLabel}
+    <text x={labelX} y={labelY + 17} text-anchor="middle">{d.link.lineLabel}</text>
+  {/if}
+</g>
+
+<style>
+  :global(.svelte-flow__edge-path.flink) {
+    stroke: var(--ink-3);
+    stroke-width: 1px;
+    fill: none;
+  }
+  :global(.svelte-flow__edge-path.flink.dimmed) {
+    stroke-opacity: 0.25;
+  }
+  .fhead {
+    fill: var(--ink-3);
+  }
+  .fhead.dimmed {
+    fill-opacity: 0.25;
+  }
+  .flabel text {
+    fill: var(--ink-3);
+    font: 11px var(--mono);
+  }
+  .flabel.dimmed text {
+    fill-opacity: 0.25;
+  }
+</style>

+ 94 - 0
ui/src/lib/api.ts

@@ -367,6 +367,79 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
   return body as T;
 }
 
+/* ------------------------------------------------------------- flow strip -- */
+
+export interface WireFlowEdge extends WireEdge {
+  /** The link's label: "calls", "via callback · registered at file:line". */
+  label: string;
+  /** This hop reads callee → caller — the reader stepped UP into it. */
+  upward: boolean;
+  /** Confidence below 0.6: the link is dashed `2 3`. */
+  uncertain: boolean;
+  /** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
+  synthesized: boolean;
+}
+
+export interface WireFlowSource {
+  file: string;
+  language: string;
+  from: number;
+  to: number;
+  /** Absent when `drift` — a mis-sliced window is worse than an empty card. */
+  lines?: string[];
+  highlight?: WireHighlight;
+  drift: boolean;
+  reason?: string;
+}
+
+/** The call site a card is opened at — the identifier drawn as an accent link. */
+export interface WireFlowCallRef {
+  line: number;
+  col: number | null;
+  name: string;
+  targetId: string;
+  /** The link points back at the previous card, not on to the next one. */
+  backwards: boolean;
+}
+
+export interface WireFlowHop {
+  node: WireNodeRef;
+  /** The edge from the PREVIOUS hop into this one; null on the first. */
+  edge: WireFlowEdge | null;
+  callRef: WireFlowCallRef | null;
+  source: WireFlowSource | null;
+}
+
+export interface WireFlow {
+  id: string;
+  /** "execute → rowToFileRecord", for the header's flow picker. */
+  label: string;
+  hops: WireFlowHop[];
+}
+
+export interface WireFlowAmbiguity {
+  token: string;
+  chosen: WireNodeRef | null;
+  others: WireNodeRef[];
+}
+
+export interface WireFlowPayload {
+  query: {
+    kind: 'directed' | 'symbols' | 'trail';
+    from: string | null;
+    to: string | null;
+    symbols: string[];
+  };
+  flows: WireFlow[];
+  ambiguous: WireFlowAmbiguity[];
+  /** Tokens that named nothing in this index. */
+  unresolved: string[];
+  /** Why there is no flow, when there is none. */
+  reason: string | null;
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number };
+}
+
 /* -------------------------------------------------------------- the map -- */
 
 export interface WireMapModule {
@@ -487,3 +560,24 @@ export function fetchMap(
   const query = params.toString();
   return getJson<WireMapPayload>(`api/map${query ? `?${query}` : ''}`, signal);
 }
+
+/**
+ * A flow. Exactly one of the three shapes is sent:
+ *
+ * - `{ from, to }` — "how does X reach Y", from the search box.
+ * - `{ symbols }` — `codegraph_explore`'s own question, verbatim.
+ * - `{ trail }` — the hops the reader walked, as `<dir><id>` strings. Each one
+ *   is its own parameter, because a node id can be a file path and a file path
+ *   can contain a comma.
+ */
+export function fetchFlow(
+  spec: { from?: string; to?: string; symbols?: string; trail?: readonly string[] },
+  signal?: AbortSignal
+): Promise<WireFlowPayload> {
+  const params = new URLSearchParams();
+  if (spec.from) params.set('from', spec.from);
+  if (spec.to) params.set('to', spec.to);
+  if (spec.symbols) params.set('symbols', spec.symbols);
+  for (const hop of spec.trail ?? []) params.append('hop', hop);
+  return getJson<WireFlowPayload>(`api/flow?${params}`, signal);
+}

+ 337 - 0
ui/src/lib/flow-model.ts

@@ -0,0 +1,337 @@
+/**
+ * The Flow strip's geometry, without a browser.
+ *
+ * The strip reads left to right: one card per hop, opened at the line that
+ * makes the next call, linked by an 86px connector carrying the edge. That is a
+ * straight line for one path — but two paths that share endpoints are one
+ * picture, not two, so the layout is a small DAG over the union of whatever
+ * flows are on screen, and a single chain is just the DAG with one node per
+ * column.
+ *
+ * Two rules make it deterministic, which is the whole point of not using a
+ * physics layout (design spec §1):
+ *
+ * - **A card's column is its longest distance from a start.** Two routes that
+ *   rejoin therefore rejoin in the same column, and a card never sits left of
+ *   something that calls it.
+ * - **A card's height is computed, not measured.** The number of source lines
+ *   is known before anything renders, so the rows can be packed without waiting
+ *   for a `ResizeObserver` — and the card's CSS pins the same height, so the
+ *   arrows land where the arithmetic said they would. The File view's outline
+ *   works the same way and for the same reason.
+ *
+ * Tested in `__tests__/ui-flow-model.test.ts`.
+ */
+
+import type { WireFlow, WireFlowEdge, WireFlowHop } from './api';
+
+/* ------------------------------------------------------------ dimensions -- */
+
+/** Card width (design spec §3.5). */
+export const CARD_WIDTH = 380;
+/** Connector width between two cards, when the label fits inside it. */
+export const LINK_WIDTH = 86;
+/** Distance between two columns' left edges, for a link with an ordinary label. */
+export const COLUMN_PITCH = CARD_WIDTH + LINK_WIDTH;
+
+/**
+ * Advance of IBM Plex Mono at the 11px a connector label is set in, and the
+ * clear space kept either side of the longest line.
+ *
+ * A gap only ever GROWS past {@link LINK_WIDTH}: 86px holds `calls` and
+ * `line 2029` comfortably, but a synthesized hop's `registered at App.tsx:3764`
+ * is twenty-six characters, and at a fixed pitch it ran underneath the cards on
+ * both sides of it — on excalidraw's `mutateElement` flow, over the source of
+ * the very card the label was explaining. The label is the evidence for a hop
+ * nobody can see in the source, so the picture makes room for it.
+ */
+const LABEL_CHAR_WIDTH = 6.65;
+const LABEL_PAD = 18;
+
+/** Card header: `10px 12px 6px` padding around one 18px row, plus a rule. */
+export const HEADER_HEIGHT = 35;
+/** Source window: `12px/19px` mono with 6px of padding above and below. */
+export const CODE_LINE_HEIGHT = 19;
+export const CODE_PADDING = 12;
+/** A card with no source still says why, in one line of the same height. */
+export const NO_SOURCE_HEIGHT = CODE_LINE_HEIGHT + CODE_PADDING;
+/** Clear space between two cards stacked in one column. */
+export const ROW_GAP = 24;
+/** Canvas padding around the whole strip. */
+export const PADDING = 32;
+
+/** Exact rendered height of a card, which its CSS then pins. */
+export function cardHeight(hop: WireFlowHop): number {
+  const lines = hop.source?.lines?.length ?? 0;
+  const body = lines > 0 ? lines * CODE_LINE_HEIGHT + CODE_PADDING : NO_SOURCE_HEIGHT;
+  return HEADER_HEIGHT + body;
+}
+
+/* ----------------------------------------------------------------- model -- */
+
+export interface FlowCardLayout {
+  /** Node id — unique in the DAG even when two flows both contain it. */
+  id: string;
+  hop: WireFlowHop;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  column: number;
+  /** Flows this card belongs to, by flow id — what dims when one is picked. */
+  flows: string[];
+  /** Position in the ACTIVE flow, or -1 when it is not on it. */
+  step: number;
+}
+
+export interface FlowLinkLayout {
+  id: string;
+  source: string;
+  target: string;
+  edge: WireFlowEdge;
+  /** Flows this link belongs to. */
+  flows: string[];
+  /** The full label, for the connector's tooltip. */
+  label: string;
+  /**
+   * The label broken into short centred lines, longest path segments shortened
+   * to a basename. Eighty-six pixels is about eleven monospace characters, so a
+   * synthesized hop's `via interface impl / registered at payroll.go:37` has to
+   * stack rather than run over both cards it sits between.
+   */
+  labelLines: string[];
+  /** `line 2029` — drawn under the connector, when the edge recorded one. */
+  lineLabel: string | null;
+  /** SVG dasharray, or null for a solid line. */
+  dash: string | null;
+}
+
+export interface FlowLayout {
+  cards: FlowCardLayout[];
+  links: FlowLinkLayout[];
+  width: number;
+  height: number;
+  /** Longest chain on screen, in cards. */
+  columns: number;
+  /** Connector width after each column — {@link LINK_WIDTH} unless a label needed more. */
+  gaps: number[];
+}
+
+/** Dash pattern for a link (design spec §3.5). Heuristic wins over uncertain. */
+export function dashFor(edge: WireFlowEdge): string | null {
+  if (edge.synthesized) return '5 3';
+  if (edge.uncertain) return '2 3';
+  return null;
+}
+
+/** Longest a connector label line may be before it is cut. */
+export const LABEL_MAX_CHARS = 26;
+
+/** `src/a/b/thing.go:37` reads as `thing.go:37` under an 86px connector. */
+function shortenSites(text: string): string {
+  return text.replace(/[\w.@$/\\-]+[/\\]([\w.$-]+:\d+)/g, '$1');
+}
+
+/**
+ * The label, stacked. `\u00b7`-separated clauses become their own lines, and
+ * anything still too long is cut with an ellipsis — the connector's tooltip
+ * carries the untruncated text.
+ */
+export function labelLinesFor(edge: WireFlowEdge): string[] {
+  return shortenSites(edge.label)
+    .split(' \u00b7 ')
+    .map((part) => part.trim())
+    .filter(Boolean)
+    .map((part) =>
+      part.length > LABEL_MAX_CHARS ? `${part.slice(0, LABEL_MAX_CHARS - 1)}\u2026` : part
+    );
+}
+
+/** `line 2029`, or null when the edge carries no line. */
+export function lineLabelFor(edge: WireFlowEdge): string | null {
+  return typeof edge.line === 'number' && edge.line > 0 ? `line ${edge.line}` : null;
+}
+
+/**
+ * Lay out the union of `flows`, highlighting `activeId`.
+ *
+ * Passing one flow gives a single row of cards; passing several gives the DAG
+ * where they share hops. The active flow decides the vertical order — it is
+ * drawn along the top of its columns — so picking a flow never re-sorts the
+ * picture underneath the reader.
+ */
+export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | null): FlowLayout {
+  const active = flows.find((f) => f.id === activeId) ?? flows[0] ?? null;
+  const activeSteps = new Map<string, number>();
+  active?.hops.forEach((hop, index) => activeSteps.set(hop.node.id, index));
+
+  // ---- collect nodes and edges over every flow on screen -------------------
+  const cards = new Map<string, { hop: WireFlowHop; flows: string[]; order: number }>();
+  const links = new Map<
+    string,
+    { source: string; target: string; edge: WireFlowEdge; flows: string[] }
+  >();
+  const successors = new Map<string, Set<string>>();
+  const indegree = new Map<string, number>();
+
+  let order = 0;
+  for (const flow of flows) {
+    for (let i = 0; i < flow.hops.length; i++) {
+      const hop = flow.hops[i] as WireFlowHop;
+      const id = hop.node.id;
+      const existing = cards.get(id);
+      if (existing) {
+        if (!existing.flows.includes(flow.id)) existing.flows.push(flow.id);
+      } else {
+        cards.set(id, { hop, flows: [flow.id], order: order++ });
+        indegree.set(id, 0);
+        successors.set(id, new Set());
+      }
+
+      const previous = flow.hops[i - 1];
+      if (!previous || hop.edge === null) continue;
+      // An upward hop is the same edge read backwards; the ARROW still points
+      // the way the reader travelled, which is what the strip is describing.
+      const from = previous.node.id;
+      const key = `${from} ${id}`;
+      const link = links.get(key);
+      if (link) {
+        if (!link.flows.includes(flow.id)) link.flows.push(flow.id);
+        continue;
+      }
+      links.set(key, { source: from, target: id, edge: hop.edge, flows: [flow.id] });
+      const outs = successors.get(from);
+      if (outs && !outs.has(id)) {
+        outs.add(id);
+        indegree.set(id, (indegree.get(id) ?? 0) + 1);
+      }
+    }
+  }
+
+  if (cards.size === 0) {
+    return { cards: [], links: [], width: 0, height: 0, columns: 0, gaps: [] };
+  }
+
+  // ---- column = longest distance from a start -----------------------------
+  const column = new Map<string, number>();
+  for (const id of cards.keys()) column.set(id, 0);
+  // Kahn order, so a node is placed only after everything that reaches it.
+  const pending = new Map(indegree);
+  const queue = [...cards.keys()].filter((id) => (pending.get(id) ?? 0) === 0);
+  const settled = new Set<string>();
+  while (queue.length > 0) {
+    const id = queue.shift() as string;
+    settled.add(id);
+    for (const next of successors.get(id) ?? []) {
+      column.set(next, Math.max(column.get(next) ?? 0, (column.get(id) ?? 0) + 1));
+      const left = (pending.get(next) ?? 0) - 1;
+      pending.set(next, left);
+      if (left === 0) queue.push(next);
+    }
+  }
+  // A cycle (a flow that calls back into itself) leaves nodes unsettled. They
+  // are still real hops, so they go one column past whatever reached them
+  // rather than disappearing.
+  for (const id of cards.keys()) {
+    if (settled.has(id)) continue;
+    let best = 0;
+    for (const [from, outs] of successors) {
+      if (outs.has(id)) best = Math.max(best, (column.get(from) ?? 0) + 1);
+    }
+    column.set(id, best);
+  }
+
+  // ---- pack each column, active flow first --------------------------------
+  const byColumn = new Map<number, string[]>();
+  for (const id of cards.keys()) {
+    const c = column.get(id) ?? 0;
+    const list = byColumn.get(c);
+    if (list) list.push(id);
+    else byColumn.set(c, [id]);
+  }
+  for (const list of byColumn.values()) {
+    list.sort((a, b) => {
+      const onA = activeSteps.has(a) ? 0 : 1;
+      const onB = activeSteps.has(b) ? 0 : 1;
+      if (onA !== onB) return onA - onB;
+      return (cards.get(a)?.order ?? 0) - (cards.get(b)?.order ?? 0);
+    });
+  }
+
+  const columns = Math.max(...byColumn.keys()) + 1;
+
+  // Each gap is wide enough for the widest label that crosses it. Labels are
+  // built here rather than in the render pass because the geometry depends on
+  // them — see LABEL_CHAR_WIDTH.
+  const labelled = [...links.entries()].map(([key, link]) => ({
+    key,
+    link,
+    lines: labelLinesFor(link.edge),
+    lineLabel: lineLabelFor(link.edge),
+  }));
+  const gaps = Array.from({ length: Math.max(0, columns - 1) }, () => LINK_WIDTH);
+  for (const { link, lines, lineLabel } of labelled) {
+    const from = column.get(link.source) ?? 0;
+    if (from < 0 || from >= gaps.length) continue;
+    const widest = Math.max(0, ...lines.map((l) => l.length), lineLabel?.length ?? 0);
+    gaps[from] = Math.max(gaps[from] as number, Math.ceil(widest * LABEL_CHAR_WIDTH) + LABEL_PAD);
+  }
+  const columnX: number[] = [PADDING];
+  for (let c = 1; c < columns; c++) {
+    columnX[c] = (columnX[c - 1] as number) + CARD_WIDTH + (gaps[c - 1] as number);
+  }
+
+  const heights = new Map<string, number>();
+  for (const [id, card] of cards) heights.set(id, cardHeight(card.hop));
+
+  // Rows are centred on the tallest column, so a one-card column sits opposite
+  // the middle of a two-card one instead of hugging the top of the canvas.
+  const columnHeights = new Map<number, number>();
+  for (const [c, list] of byColumn) {
+    columnHeights.set(
+      c,
+      list.reduce((sum, id) => sum + (heights.get(id) ?? 0), 0) + ROW_GAP * (list.length - 1)
+    );
+  }
+  const tallest = Math.max(...columnHeights.values());
+
+  const laidOut = new Map<string, FlowCardLayout>();
+  for (const [c, list] of byColumn) {
+    let y = PADDING + (tallest - (columnHeights.get(c) ?? 0)) / 2;
+    for (const id of list) {
+      const card = cards.get(id) as { hop: WireFlowHop; flows: string[]; order: number };
+      const height = heights.get(id) ?? 0;
+      laidOut.set(id, {
+        id,
+        hop: card.hop,
+        x: columnX[c] as number,
+        y,
+        width: CARD_WIDTH,
+        height,
+        column: c,
+        flows: card.flows,
+        step: activeSteps.get(id) ?? -1,
+      });
+      y += height + ROW_GAP;
+    }
+  }
+
+  return {
+    cards: [...laidOut.values()].sort((a, b) => a.column - b.column || a.y - b.y),
+    links: labelled.map(({ link, lines, lineLabel }) => ({
+      id: `${link.source}->${link.target}`,
+      source: link.source,
+      target: link.target,
+      edge: link.edge,
+      flows: link.flows,
+      label: link.edge.label,
+      labelLines: lines,
+      lineLabel,
+      dash: dashFor(link.edge),
+    })),
+    width: (columnX[columns - 1] as number) + CARD_WIDTH + PADDING,
+    height: PADDING * 2 + tallest,
+    columns,
+    gaps,
+  };
+}

+ 33 - 6
ui/src/lib/router.svelte.ts

@@ -9,7 +9,7 @@
  *   #/s/<id>               symbol view      (?hl=<line> highlights a line, ?t=<trail>)
  *   #/file/<path>          file view        (?hl=<line>)
  *   #/map                  module map       (?root=&depth=&tests=1)
- *   #/flow[/<key>]         flow strip       — reserved, phase 2
+ *   #/flow                 flow strip       (?from=&to= | ?symbols= | ?t=<trail>)
  *
  * Node ids are opaque engine strings shaped `<kind>:<hash>` or
  * `<kind>:<relative/path>` (see src/extraction/tree-sitter-helpers.ts), so
@@ -24,7 +24,16 @@ export type Route =
   | { view: 'symbol'; id: string; line: number | null }
   | { view: 'file'; path: string; line: number | null }
   | { view: 'map'; root: string | null; depth: number; tests: boolean }
-  | { view: 'flow'; key: string | null }
+  | {
+      view: 'flow';
+      /** "how does X reach Y" — both ends pinned. */
+      from: string | null;
+      to: string | null;
+      /** An explore-shaped bag of names, comma or space separated. */
+      symbols: string | null;
+      /** An encoded trail, read as a flow. Same format the `t` param uses. */
+      trail: string | null;
+    }
   | { view: 'unknown'; path: string };
 
 export type ViewName = Route['view'];
@@ -84,8 +93,16 @@ export function parseHash(hash: string): RouterLocation {
       depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : 1,
       tests: params.get('tests') === '1',
     };
-  } else if (head === 'flow') {
-    route = { view: 'flow', key: rest.length > 0 ? rest.join('/') : null };
+  } else if (head === 'flow' && rest.length === 0) {
+    // The question travels in the URL exactly as it was asked, so a flow can be
+    // linked in a review and reopen as the same path.
+    route = {
+      view: 'flow',
+      from: params.get('from'),
+      to: params.get('to'),
+      symbols: params.get('symbols'),
+      trail: params.get('t'),
+    };
   } else {
     route = { view: 'unknown', path: pathPart };
   }
@@ -119,8 +136,18 @@ export function mapHref(
   return `#/map${query ? `?${query}` : ''}`;
 }
 
-export function flowHref(key?: string): string {
-  return key ? `#/flow/${encodePath(key)}` : '#/flow';
+export function flowHref(
+  opts: { from?: string; to?: string; symbols?: string; trail?: string } = {}
+): string {
+  const params = new URLSearchParams();
+  if (opts.from) params.set('from', opts.from);
+  if (opts.to) params.set('to', opts.to);
+  if (opts.symbols) params.set('symbols', opts.symbols);
+  // `t`, not `trail`: the trail already travels under that name everywhere
+  // else, and a flow read from one is the same walk under a different lens.
+  if (opts.trail) params.set('t', opts.trail);
+  const query = params.toString();
+  return `#/flow${query ? `?${query}` : ''}`;
 }
 
 /* ---------- the live route ---------- */

+ 27 - 6
ui/src/lib/search-model.ts

@@ -30,10 +30,11 @@ export interface FlowQuery {
 /**
  * "how does X reach Y", "X -> Y", "X → Y".
  *
- * Phase 1 has no Flow view to send this to, but the question is worth
- * recognising anyway: someone who types it gets both endpoints looked up
- * instead of a search for the whole sentence, which matches nothing. CG-50
- * turns the same parse into a computed path.
+ * The parse drives two things: the palette's first row, which opens the Flow
+ * strip for exactly this pair, and the search underneath it, which looks up
+ * both endpoints rather than searching the whole sentence (which matches
+ * nothing). Both are useful — the flow answers the question, the endpoints let
+ * a reader who spelled a name wrong see what they actually named.
  */
 const FLOW_SENTENCE =
   /^\s*(?:how\s+(?:does|do|would|can)\s+)?([\w$.]+)\s+(?:reach|reaches|call|calls|hit|hits|get\s+to|end\s+up\s+(?:in|at))\s+([\w$.]+)\s*\??\s*$/i;
@@ -58,7 +59,8 @@ export function parseFlowQuery(query: string): FlowQuery | null {
 
 export type PaletteItem =
   | { type: 'symbol'; id: string; node: WireNodeRef; name: string; meta: string; location: string }
-  | { type: 'route'; id: string; url: string; handler: string; location: string; nodeId: string | null };
+  | { type: 'route'; id: string; url: string; handler: string; location: string; nodeId: string | null }
+  | { type: 'flow'; id: string; from: string; to: string; name: string; meta: string; location: string };
 
 export interface PaletteSection {
   /** Sentence-case caption, e.g. "Methods", "Files that run something". */
@@ -177,10 +179,29 @@ export function buildSearchPalette(
       : answers[0]?.results.items ?? [];
 
   const sections = groupByKind(results);
+  // The flow row goes FIRST, so Enter opens the path: someone who typed
+  // "how does X reach Y" asked for the path, not for a list of symbols.
+  if (flow) {
+    sections.unshift({
+      title: 'Flow',
+      note: 'The call path between them, one card per hop.',
+      items: [
+        {
+          type: 'flow',
+          id: `flow:${flow.from}:${flow.to}`,
+          from: flow.from,
+          to: flow.to,
+          name: `${flow.from} → ${flow.to}`,
+          meta: '',
+          location: 'read as a flow',
+        },
+      ],
+    });
+  }
   const items = sections.flatMap((section) => section.items);
 
   const hint = flow
-    ? `Reading the path between two symbols arrives with the Flow view. Here is what ${flow.from} and ${flow.to} name.`
+    ? `Reading the path from ${flow.from} to ${flow.to}. Below it, what each name matches.`
     : null;
 
   return {

+ 373 - 14
ui/src/views/FlowView.svelte

@@ -1,29 +1,388 @@
 <!--
-  Reserved route. The flow strip (design spec §3.5) is phase 2.
+  The Flow strip (`#/flow`, design spec §3.5): how one symbol reaches another,
+  as one card per hop, each opened at the line that makes the next call.
+
+  The path is not computed here and is not computed by the server either — it
+  comes from `resolveNamedSymbolFlow`, the search `codegraph_explore` leads its
+  answers with. That is deliberate: a viewer that drew a different path from the
+  one the MCP tool describes would get the two quoted against each other in a
+  review, and one of them would be wrong.
+
+  Svelte Flow draws it, for pan, zoom and fit and nothing else: positions come
+  from `buildFlowLayout`, the flow picker is local state, and nothing is
+  draggable. Clicking a card opens the Symbol view with the trail set to the
+  path so far, so the strip hands the reader off to the view that goes deep.
 -->
 <script lang="ts">
+  import { SvelteFlow, Controls, type Node, type Edge } from '@xyflow/svelte';
+  import '@xyflow/svelte/dist/style.css';
+  import FlowCard from '../components/flow/FlowCard.svelte';
+  import FlowLink from '../components/flow/FlowLink.svelte';
+  import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
+  import { navigate, symbolHref } from '../lib/router.svelte';
+  import { trail, encodeTrail, type TrailHop } from '../lib/trail.svelte';
+  import { decodeTrail } from '../lib/trail-codec';
+  import { buildFlowLayout, type FlowCardLayout, type FlowLayout } from '../lib/flow-model';
+  import { basename } from '../lib/symbol-model';
+
   interface Props {
-    flowKey: string | null;
+    from: string | null;
+    to: string | null;
+    symbols: string | null;
+    /** An encoded trail, when the flow is the reader's own walk. */
+    trailParam: string | null;
+  }
+
+  let { from, to, symbols, trailParam }: Props = $props();
+
+  let payload = $state<WireFlowPayload | null>(null);
+  let error = $state<string | null>(null);
+  let loading = $state(true);
+  let picked = $state<string | null>(null);
+  /** True when the picker is on "All paths" — the union is drawn as a DAG. */
+  let showAll = $state(false);
+
+  const ALL = 'all-paths';
+
+  /**
+   * The strip opens at 1:1, top left — it never fits itself to the window.
+   *
+   * Fitting an eight-hop flow into a laptop's width lands at about 0.38 zoom,
+   * which is a picture of eight grey rectangles: the source inside them is the
+   * answer, and source you cannot read is not an answer. So the reader arrives
+   * at the first card, full size, and pans. The Controls' fit button is still
+   * there for anyone who wants the shape rather than the code.
+   */
+  const START_VIEWPORT = { x: 0, y: 0, zoom: 1 };
+  const nodeTypes = { flow: FlowCard };
+  const edgeTypes = { flow: FlowLink };
+
+  /** The hops the trail form asks for, as `<dir><id>` — the wire's own spelling. */
+  const trailHops = $derived<TrailHop[]>(trailParam ? decodeTrail(trailParam) : []);
+
+  $effect(() => {
+    const spec = trailParam
+      ? { trail: trailHops.map((h) => `${h.dir === 'start' ? 's' : h.dir === 'up' ? 'u' : 'd'}${h.id}`) }
+      : symbols
+        ? { symbols }
+        : { from: from ?? '', to: to ?? '' };
+    if (!trailParam && !symbols && !(from && to)) {
+      payload = null;
+      loading = false;
+      error = null;
+      return;
+    }
+    const controller = new AbortController();
+    loading = true;
+    error = null;
+    fetchFlow(spec, controller.signal)
+      .then((next) => {
+        payload = next;
+        picked = next.flows[0]?.id ?? null;
+        showAll = false;
+        loading = false;
+      })
+      .catch((err: unknown) => {
+        if (controller.signal.aborted) return;
+        error = err instanceof Error ? err.message : String(err);
+        loading = false;
+      });
+    return () => controller.abort();
+  });
+
+  const flows = $derived<WireFlow[]>(payload?.flows ?? []);
+  const shown = $derived<WireFlow[]>(
+    showAll ? flows : flows.filter((f) => f.id === picked).slice(0, 1)
+  );
+  const layout = $derived<FlowLayout | null>(
+    shown.length === 0 ? null : buildFlowLayout(showAll ? flows : shown, picked)
+  );
+  const activeFlow = $derived(flows.find((f) => f.id === picked) ?? flows[0] ?? null);
+
+  const nodes = $derived.by<Node[]>(() => {
+    if (layout === null) return [];
+    return layout.cards.map((card) => ({
+      id: card.id,
+      type: 'flow',
+      position: { x: card.x, y: card.y },
+      draggable: false,
+      selectable: false,
+      connectable: false,
+      data: {
+        // The accent border marks the picked path, and only means something
+        // when there is more than one on screen. A single flow whose every
+        // card is accented has said nothing.
+        card,
+        current: showAll && card.step >= 0,
+        dimmed: showAll && card.step < 0,
+        onOpen: openCard,
+        onFollow: followCard,
+      },
+    }));
+  });
+
+  const edges = $derived.by<Edge[]>(() => {
+    if (layout === null) return [];
+    return layout.links.map((link) => ({
+      id: link.id,
+      source: link.source,
+      target: link.target,
+      sourceHandle: 'out',
+      targetHandle: 'in',
+      type: 'flow',
+      selectable: false,
+      deletable: false,
+      data: { link, dimmed: showAll && picked !== null && !link.flows.includes(picked) },
+    }));
+  });
+
+  /**
+   * Open a card in the Symbol view with the trail set to the path so far.
+   *
+   * The prefix, not the whole flow: the reader is standing at that hop, and a
+   * trail that ran on past them would claim a walk they had not taken.
+   */
+  function openCard(card: FlowCardLayout): void {
+    const hops = activeFlow?.hops ?? [];
+    const at = hops.findIndex((hop) => hop.node.id === card.id);
+    const prefix = at >= 0 ? hops.slice(0, at + 1) : [];
+    trail.clear();
+    prefix.forEach((hop, index) =>
+      trail.push({
+        id: hop.node.id,
+        name: hop.node.name,
+        kind: hop.node.kind,
+        dir: index === 0 ? 'start' : hop.edge?.upward ? 'up' : 'down',
+      })
+    );
+    if (prefix.length === 0) {
+      trail.push({ id: card.id, name: card.hop.node.name, kind: card.hop.node.kind, dir: 'start' });
+    }
+    navigate(
+      symbolHref(card.id, {
+        trail: encodeTrail(trail.hops),
+        ...(card.hop.callRef ? { line: card.hop.callRef.line } : {}),
+      })
+    );
+  }
+
+  /** The accent link inside a card: step to the symbol it names. */
+  function followCard(card: FlowCardLayout): void {
+    const target = card.hop.callRef?.targetId;
+    if (!target) return;
+    const next = layout?.cards.find((c) => c.id === target);
+    if (next) openCard(next);
+  }
+
+  function note(p: WireFlowPayload): string {
+    if (p.query.kind === 'trail') {
+      return 'Your trail, read as a flow: each card is opened at the line that carried you to the next one.';
+    }
+    if (p.query.kind === 'directed') {
+      return 'Every card is a call the graph recorded. A dashed link is a hop no one can see in the source — a callback, an interface, a re-render — and it names where it was wired.';
+    }
+    return 'The longest call path among the symbols you named, the same one codegraph_explore leads with.';
   }
-  let { flowKey = null }: Props = $props();
 </script>
 
-<div class="scroll">
-  <div class="emptystate">
-    <h2>Flow</h2>
-    <p>
-      Reading a trail as a left-to-right flow — one card per hop, showing the line that makes each
-      call — is not part of this release.
-    </p>
-    {#if flowKey}
-      <p class="dim">Requested flow: <span class="mono">{flowKey}</span></p>
+<div class="flowview">
+  <header class="fhead">
+    <h1>Flow</h1>
+    {#if flows.length > 0}
+      <select
+        aria-label="Which path to draw"
+        value={showAll ? ALL : (picked ?? '')}
+        onchange={(event) => {
+          const value = (event.currentTarget as HTMLSelectElement).value;
+          showAll = value === ALL;
+          if (!showAll) picked = value;
+        }}
+      >
+        {#each flows as flow (flow.id)}
+          <option value={flow.id}>{flow.label} · {flow.hops.length} hops</option>
+        {/each}
+        {#if flows.length > 1}
+          <option value={ALL}>All {flows.length} paths</option>
+        {/if}
+      </select>
+    {/if}
+    {#if payload}
+      <p class="note">{note(payload)}</p>
+    {/if}
+  </header>
+
+  <div class="fstage">
+    {#if error !== null}
+      <div class="state">
+        <h2>The flow could not be built</h2>
+        <p>{error}</p>
+      </div>
+    {:else if loading && payload === null}
+      <div class="state"><p class="dim">Following the calls…</p></div>
+    {:else if payload === null}
+      <div class="state">
+        <h2>Nothing to follow yet</h2>
+        <p>
+          Ask for a path in the search box — “how does execute reach getFile”, or
+          <span class="mono">execute -&gt; getFile</span> — or walk a trail and read it as a flow.
+        </p>
+      </div>
+    {:else if layout === null}
+      <div class="state">
+        <h2>No path between them</h2>
+        <p>{payload.reason}</p>
+        {#if payload.query.from && payload.query.to}
+          <p class="dim">
+            Asked: <span class="mono">{payload.query.from}</span> to
+            <span class="mono">{payload.query.to}</span>.
+          </p>
+        {/if}
+      </div>
+    {:else}
+      <SvelteFlow
+        {nodes}
+        {edges}
+        {nodeTypes}
+        {edgeTypes}
+        initialViewport={START_VIEWPORT}
+        fitViewOptions={{ padding: 0.1, maxZoom: 1, minZoom: 0.2 }}
+        minZoom={0.2}
+        maxZoom={1.4}
+        nodesDraggable={false}
+        nodesConnectable={false}
+        elementsSelectable={false}
+        panOnDrag
+        proOptions={{ hideAttribution: true }}
+      >
+        <Controls position="bottom-right" showLock={false} />
+      </SvelteFlow>
     {/if}
   </div>
+
+  {#if payload && (payload.ambiguous.length > 0 || payload.unresolved.length > 0)}
+    <footer class="fnote">
+      {#each payload.ambiguous as amb (amb.token)}
+        <p>
+          <span class="mono">{amb.token}</span> names {amb.others.length + 1} definitions.
+          {#if amb.chosen}
+            This path runs through the one in
+            <span class="mono">{basename(amb.chosen.file)}:{amb.chosen.line}</span>.
+          {:else}
+            None of them are on this path.
+          {/if}
+        </p>
+      {/each}
+      {#each payload.unresolved as token (token)}
+        <p><span class="mono">{token}</span> names nothing in this index.</p>
+      {/each}
+    </footer>
+  {/if}
 </div>
 
 <style>
-  .scroll {
+  .flowview {
+    display: grid;
     height: 100%;
-    overflow: auto;
+    min-height: 0;
+    grid-template-rows: auto minmax(0, 1fr) auto;
+  }
+
+  .fhead {
+    display: flex;
+    align-items: center;
+    padding: 12px 18px;
+    border-bottom: 1px solid var(--rule-soft);
+    gap: 12px;
+  }
+
+  .fhead h1 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 600;
+  }
+
+  .fhead select {
+    padding: 3px 6px;
+    background: var(--paper-2);
+    color: var(--ink);
+    border: 1px solid var(--rule-soft);
+    border-radius: 0;
+    font: 12.5px var(--sans);
+  }
+
+  .note {
+    max-width: 78ch;
+    margin: 0;
+    color: var(--ink-3);
+    font-size: 12px;
+  }
+
+  .fstage {
+    position: relative;
+    overflow: hidden;
+    background: var(--paper);
+  }
+
+  /* Svelte Flow paints its own surface and controls; both are re-tokenised so
+     the canvas belongs to the paper/ink system. Same treatment as the Map. */
+  .fstage :global(.svelte-flow) {
+    background: var(--paper);
+  }
+  .fstage :global(.svelte-flow__handle) {
+    width: 1px;
+    height: 1px;
+    min-width: 0;
+    min-height: 0;
+    border: 0;
+    opacity: 0;
+    pointer-events: none;
+  }
+  .fstage :global(.svelte-flow__node) {
+    cursor: default;
+  }
+  .fstage :global(.svelte-flow__controls) {
+    border: 1px solid var(--rule-soft);
+    box-shadow: none;
+  }
+  .fstage :global(.svelte-flow__controls-button) {
+    background: var(--paper);
+    border: 0;
+    border-bottom: 1px solid var(--rule-soft);
+    border-radius: 0;
+    box-shadow: none;
+    fill: var(--ink-2);
+  }
+
+  .state {
+    max-width: 52ch;
+    padding: 40px;
+  }
+  .state h2 {
+    margin: 0 0 8px;
+    font-size: 15px;
+    font-weight: 600;
+  }
+  .state p {
+    margin: 0 0 8px;
+    color: var(--ink-2);
+    font-size: 12.5px;
+    line-height: 1.5;
+  }
+  .dim {
+    color: var(--ink-3);
+  }
+  .mono {
+    font-family: var(--mono);
+  }
+
+  .fnote {
+    padding: 8px 18px;
+    border-top: 1px solid var(--rule-soft);
+    background: var(--paper-2);
+    color: var(--ink-2);
+    font-size: 12px;
+  }
+  .fnote p {
+    margin: 0 0 2px;
   }
 </style>

+ 7 - 1
ui/src/views/HomeView.svelte

@@ -12,7 +12,7 @@
   import PaletteRows from '../components/PaletteRows.svelte';
   import { palette } from '../lib/palette.svelte';
   import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
-  import { fileHref, navigate } from '../lib/router.svelte';
+  import { fileHref, flowHref, navigate } from '../lib/router.svelte';
   import { walkTo } from '../lib/walk';
 
   interface Props {
@@ -27,6 +27,12 @@
   let entries = $derived(buildEntryPalette(palette.entries));
 
   function pick(item: PaletteItem) {
+    // The empty screen only ever shows entry points, which are never flows —
+    // but the row type is shared, so the branch is here rather than assumed away.
+    if (item.type === 'flow') {
+      navigate(flowHref({ from: item.from, to: item.to }));
+      return;
+    }
     const id = item.type === 'route' ? item.nodeId : item.id;
     if (!id) return;
     // A file opens the File view — its outline plus the import rails. The