You are a senior code reviewer preparing to review code changes.

## Code Changes

```diff
diff --git a/ftl_code_expert/cli.py b/ftl_code_expert/cli.py
index 322a1a1..38343b7 100644
--- a/ftl_code_expert/cli.py
+++ b/ftl_code_expert/cli.py
@@ -31,6 +31,7 @@
 from .observations import parse_observation_requests, run_observations
 from .prompts import (
     PROPOSE_BELIEFS_CODE,
+    RESEARCH_INFER_FILES_PROMPT,
     REVIEW_PROMPT,
     build_diff_prompt,
     build_diff_summary_prompt,
@@ -3475,6 +3476,208 @@ def generate_summary(ctx, snapshot_ids):
                f"{len(critical_gated) + len(critical_negative)} critical", err=True)
 
 
+# --- research ---
+
+
+def _get_explored_files() -> set[str]:
+    """Scan entries/ for '# File: <path>' headers to find already-explored files."""
+    explored = set()
+    entries_dir = Path("entries")
+    if not entries_dir.exists():
+        return explored
+    for entry_path in entries_dir.rglob("*.md"):
+        try:
+            for line in entry_path.read_text().splitlines()[:5]:
+                if line.startswith("# File: "):
+                    explored.add(line[8:].strip())
+                    break
+        except OSError:
+            pass
+    return explored
+
+
+def _parse_review_candidates(review_file: str) -> list[dict]:
+    """Parse review JSON and return candidates where sufficient=false or valid=false."""
+    with open(review_file) as f:
+        data = json.load(f)
+    candidates = []
+    for result in data.get("results", []):
+        if not result.get("sufficient", True) or not result.get("valid", True):
+            candidates.append(result)
+    return candidates
+
+
+def _parse_inferred_files(response: str) -> list[str]:
+    """Extract JSON array of file paths from LLM response."""
+    for m in re.finditer(r"\[.*?\]", response, re.DOTALL):
+        try:
+            files = json.loads(m.group(0))
+            if isinstance(files, list) and all(isinstance(f, str) for f in files):
+                return files
+        except json.JSONDecodeError:
+            continue
+    return []
+
+
+@cli.command("research")
+@click.option("--review-file", required=True, type=click.Path(exists=True),
+              help="Path to review JSON file (from review-beliefs)")
+@click.option("--limit", type=int, default=None,
+              help="Max candidates to research")
+@click.option("--dry-run", is_flag=True, default=False,
+              help="Show candidates and inferred files without exploring")
+@click.pass_context
+def research(ctx, review_file, limit, dry_run):
+    """Evidence-driven exploration from belief review gaps.
+
+    Reads a review JSON file (from review-beliefs), identifies beliefs
+    lacking evidence, infers which source files to explore, then runs
+    the explore → propose → accept pipeline.
+
+    Example:
+        code-expert research --review-file reviews/review-beliefs-2026-06-13.json
+        code-expert research --review-file review.json --dry-run
+        code-expert -j 5 research --review-file review.json
+    """
+    from .caffeinate import hold as _caffeinate
+    _caffeinate()
+
+    model = ctx.obj["model"]
+    timeout = ctx.obj["timeout"]
+    parallel = ctx.obj["parallel"]
+    repo_path = _get_repo(ctx)
+    abs_repo = os.path.abspath(repo_path)
+
+    if not check_model_available(model):
+        click.echo(f"Error: Model '{model}' CLI not available", err=True)
+        sys.exit(1)
+
+    # Step 1: Parse review and find candidates
+    candidates = _parse_review_candidates(review_file)
+    if not candidates:
+        click.echo("No research candidates found (all beliefs have sufficient evidence).")
+        return
+
+    if limit:
+        candidates = candidates[:limit]
+
+    click.echo(f"Found {len(candidates)} research candidate(s)", err=True)
+    for c in candidates:
+        status = []
+        if not c.get("valid", True):
+            status.append("invalid")
+        if not c.get("sufficient", True):
+            status.append("insufficient")
+        click.echo(f"  {c['id']} [{', '.join(status)}]", err=True)
+        click.echo(f"    {c['comment'][:120]}", err=True)
+
+    # Step 2: Load belief network for claim text
+    try:
+        network = _load_network()
+        nodes = network.get("nodes", {})
+    except Exception:
+        nodes = {}
+
+    # Step 3: Collect already-explored files
+    explored = _get_explored_files()
+    if explored:
+        click.echo(f"\n{len(explored)} files already explored", err=True)
+
+    # Step 4: Infer source files via LLM
+    click.echo(f"\nInferring source files with {model}...", err=True)
+    repo_tree = get_repo_structure(abs_repo, max_depth=3)
+    explored_list = "\n".join(f"- {f}" for f in sorted(explored)) if explored else "(none)"
+
+    prompts = []
+    for c in candidates:
+        node = nodes.get(c["id"], {})
+        belief_text = node.get("text", c["id"])
+        prompts.append(RESEARCH_INFER_FILES_PROMPT.format(
+            belief_id=c["id"],
+            belief_text=belief_text,
+            comment=c["comment"],
+            repo_tree=repo_tree,
+            explored_files=explored_list,
+        ))
+
+    results = invoke_concurrent_sync(prompts, model=model, timeout=timeout, max_concurrent=parallel)
+
+    # Step 5: Collect and deduplicate inferred files
+    all_files: dict[str, list[str]] = {}  # file_path → list of belief_ids that need it
+    for i, result in enumerate(results):
+        candidate = candidates[i]
+        if isinstance(result, Exception):
+            click.echo(f"  Error inferring files for {candidate['id']}: {result}", err=True)
+            continue
+        files = _parse_inferred_files(result)
+        click.echo(f"  {candidate['id']}: {files}", err=True)
+        for f in files:
+            abs_path = os.path.join(abs_repo, f)
+            if not os.path.isfile(abs_path):
+                click.echo(f"    Skipping {f} (not found)", err=True)
+                continue
+            if f in explored:
+                click.echo(f"    Skipping {f} (already explored)", err=True)
+                continue
+            all_files.setdefault(f, []).append(candidate["id"])
+
+    if not all_files:
+        click.echo("\nNo new files to explore.", err=True)
+        return
+
+    click.echo(f"\n{len(all_files)} file(s) to explore:", err=True)
+    for f, belief_ids in all_files.items():
+        click.echo(f"  {f} (for: {', '.join(belief_ids)})", err=True)
+
+    if dry_run:
+        click.echo("\n--dry-run: stopping before exploration.", err=True)
+        return
+
+    # Step 6: Build topics and explore
+    from .topics import Topic
+    topics = [
+        Topic(
+            title=f"Research: evidence for {', '.join(belief_ids)}",
+            kind="file",
+            target=file_path,
+            source=f"research:{','.join(belief_ids)}",
+        )
+        for file_path, belief_ids in all_files.items()
+    ]
+
+    click.echo(f"\nExploring {len(topics)} file(s)...", err=True)
+    if parallel > 1 and len(topics) > 1:
+        batch_results = asyncio.run(
+            _explore_topics_concurrent(topics, model, abs_repo, timeout, parallel)
+        )
+        for r in batch_results:
+            if isinstance(r, Exception):
+                click.echo(f"  Error: {r}", err=True)
+            elif r is not None:
+                _, result, entry_name, entry_title, source = r
+                _finalize_topic(ctx, entry_name, entry_title, source, result)
+    else:
+        for topic in topics:
+            try:
+                _run_file_topic(ctx, topic, model, abs_repo)
+            except (SystemExit, Exception) as e:
+                click.echo(f"  Error exploring {topic.target}: {e}", err=True)
+
+    # Step 7: Propose and accept beliefs from new entries
+    click.echo(f"\n{'=' * 40}", err=True)
+    click.echo("Proposing and accepting beliefs from new entries...", err=True)
+    click.echo(f"{'=' * 40}", err=True)
+    try:
+        ctx.invoke(propose_beliefs, auto_accept=True)
+    except SystemExit as e:
+        if e.code and e.code != 0:
+            click.echo(f"WARN: propose-beliefs failed (exit {e.code})", err=True)
+    except Exception as e:
+        click.echo(f"WARN: propose-beliefs failed: {e}", err=True)
+
+    click.echo("\nResearch complete. Run `code-expert derive` to attempt rejustification.", err=True)
+
+
 # --- update ---
 
 
diff --git a/ftl_code_expert/prompts/__init__.py b/ftl_code_expert/prompts/__init__.py
index 1c9f252..258543b 100644
--- a/ftl_code_expert/prompts/__init__.py
+++ b/ftl_code_expert/prompts/__init__.py
@@ -7,6 +7,7 @@
 from .observe import build_observe_prompt
 from .derive import DERIVE_BELIEFS_PROMPT
 from .propose import PROPOSE_BELIEFS_CODE
+from .research import RESEARCH_INFER_FILES_PROMPT
 from .review import REVIEW_PROMPT
 from .repo import build_repo_prompt
 from .scan import build_scan_prompt
@@ -17,6 +18,7 @@
     "DERIVE_BELIEFS_PROMPT",
     "GENERATE_SPEC_PROMPT",
     "PROPOSE_BELIEFS_CODE",
+    "RESEARCH_INFER_FILES_PROMPT",
     "REVIEW_PROMPT",
     "TOPICS_INSTRUCTIONS",
     "build_diff_prompt",
diff --git a/ftl_code_expert/prompts/research.py b/ftl_code_expert/prompts/research.py
new file mode 100644
index 0000000..1143a68
--- /dev/null
+++ b/ftl_code_expert/prompts/research.py
@@ -0,0 +1,44 @@
+"""Prompt template for research command — inferring source files from review gaps."""
+
+RESEARCH_INFER_FILES_PROMPT = """\
+You are analyzing a belief that was flagged during code review as lacking sufficient evidence. \
+Your job is to identify which source files in the repository would provide the missing evidence.
+
+## Belief
+
+**ID:** {belief_id}
+
+**Claim:** {belief_text}
+
+**Review comment:** {comment}
+
+## Repository structure
+
+```
+{repo_tree}
+```
+
+## Already explored files
+
+{explored_files}
+
+## Instructions
+
+Based on the belief claim and review comment, identify which source files in the repository \
+would provide evidence to either confirm or refute this belief. Focus on files that:
+
+1. Are mentioned or implied by the belief ID or review comment
+2. Would contain the implementation being claimed
+3. Have NOT already been explored (listed above)
+
+Output a JSON array of file paths relative to the repository root. Only include files that \
+actually exist in the repository structure shown above. Output 1-5 files, prioritizing the \
+most relevant ones.
+
+Example output:
+```json
+["src/auth/handler.py", "src/auth/middleware.py", "tests/test_auth.py"]
+```
+
+Output ONLY the JSON array, nothing else.
+"""

```

## Your Task

Analyze the diff and identify what additional information you need to render confident verdicts.
Do NOT render verdicts yet. Only request observations.

## Available Observation Tools

| Tool | Purpose | When to use |
|------|---------|-------------|
| `exception_hierarchy` | Show exception MRO and subclasses | Retry logic, exception handling |
| `raises_analysis` | What exceptions a function raises | New function calls, error paths |
| `call_graph` | What a function calls | Impact analysis |
| `find_usages` | Where a symbol is used (with prod/test split) | Quick integration lookup |
| `find_callers` | Caller analysis with prod/test split and calling context | Method signature changes, return type changes, constructor modifications, integration verification |
| `test_coverage` | Find tests for a file (uses coverage-map if available) | Test coverage claims |
| `coverage_map_tests` | Find tests covering a file (from coverage-map.json) | Precise test coverage from actual execution |
| `coverage_map_files` | Find files covered by tests matching a pattern | Impact analysis for test changes |
| `function_body` | Full source of a function/method | Need complete function context beyond diff hunks |
| `file_imports` | Extract imports from a file | Verify import changes, check dependencies |
| `project_dependencies` | Get pyproject.toml/requirements.txt | Verify new imports have dependencies |
| `related_test_files` | Find test files for a source file | Discover tests by naming, imports, and coverage map |
| `class_hierarchy` | Show base classes and their `__init__` signatures | Class changes its parent, modifies `__init__`, or uses `super()` |
| `symbol_migration` | Check if a rename is complete across the repo | Symbol renamed in diff — verify old name is fully removed |
| `generator_info` | Report whether a function uses `yield` | Function might be a generator — affects return value semantics |

## What to Look For

1. **Exception handling**: Any `retry_if_exception_type`, `except`, or exception class references
2. **New dependencies**: Calls to external libraries where you don't know the error behavior
3. **Behavioral changes**: Modified logic where you need to verify callers/callees
4. **Test claims**: References to tests you can't see in the diff
5. **Inheritance changes**: Class definition changes, new base classes, `super()` calls
6. **Renames**: Symbols that appear to have been renamed in the diff
7. **Factory methods**: Calls to `@classmethod` / `@staticmethod` constructors (e.g. `Result.error(...)`) — request `function_body` to see their implementation

## Output Format

Output a JSON array of observation requests:

```json
[
  {"name": "descriptive_name", "tool": "tool_name", "params": {"param": "value"}},
  ...
]
```

If you don't need any observations (simple changes, all context is in the diff), output:

```json
[]
```

## Examples

For a diff containing `retry_if_exception_type((OSError, httpx.TransportError))`:
```json
[
  {"name": "oserror_subclasses", "tool": "exception_hierarchy", "params": {"class_name": "builtins.OSError"}},
  {"name": "transport_errors", "tool": "exception_hierarchy", "params": {"class_name": "httpx.TransportError"}}
]
```

For a diff adding a new function that calls `oauth_client.get_access_token()`:
```json
[
  {"name": "oauth_exceptions", "tool": "raises_analysis", "params": {"file_path": "src/auth/oauth.py", "function_name": "get_access_token"}}
]
```

For a diff modifying a method but you need the full function to verify:
```json
[
  {"name": "full_getattr", "tool": "function_body", "params": {"file_path": "src/proxy.py", "function_name": "__getattr__"}}
]
```

For a diff changing a method signature or return type (verify all callers):
```json
[
  {"name": "handle_request_callers", "tool": "find_callers", "params": {"symbol": "handle_request"}}
]
```

For a diff adding new imports (e.g., `import httpx`):
```json
[
  {"name": "file_imports", "tool": "file_imports", "params": {"file_path": "src/client.py"}},
  {"name": "project_deps", "tool": "project_dependencies", "params": {}}
]
```

For a diff calling a factory method like `ModuleResult.error_result(msg)`:
```json
[
  {"name": "error_result_body", "tool": "function_body", "params": {"file_path": "src/models.py", "function_name": "error_result"}}
]
```

For a diff where a class changes its parent class:
```json
[
  {"name": "client_hierarchy", "tool": "class_hierarchy", "params": {"class_name": "MyClient", "file_path": "src/client.py"}}
]
```

For a diff that renames a symbol (e.g., `OldClient` to `NewClient`):
```json
[
  {"name": "client_rename", "tool": "symbol_migration", "params": {"old_name": "OldClient", "new_name": "NewClient"}}
]
```

For a diff modifying a function that might be a generator:
```json
[
  {"name": "process_gen", "tool": "generator_info", "params": {"file_path": "src/pipeline.py", "function_name": "process_items"}}
]
```

Now analyze the diff above and output your observation requests as JSON:
