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

## Code Changes

```diff
diff --git a/ftl_project_expert/cli.py b/ftl_project_expert/cli.py
index 5c7c97a..4e0ac08 100644
--- a/ftl_project_expert/cli.py
+++ b/ftl_project_expert/cli.py
@@ -8,20 +8,21 @@ import shutil
 import subprocess
 import sys
 from datetime import date, datetime
 from pathlib import Path
 
 import click
 
 from .llm import check_model_available, invoke, invoke_concurrent_sync, invoke_sync
 from .prompts import (
     PROPOSE_BELIEFS_PROJECT,
+    RESEARCH_PROMPT,
     build_explore_prompt,
     build_scan_prompt,
     build_summary_prompt,
 )
 from .sources import GitHubSource, GitLabSource, Issue, JiraSource
 from .topics import (
     Topic,
     add_topics,
     load_queue,
     parse_topics_from_response,
@@ -1308,20 +1309,315 @@ def review_proposals(ctx, proposals_file, batch_size, max_parallel):
 
     if replacements:
         for old_block, new_block in replacements:
             text = text.replace(old_block, new_block, 1)
         proposals_path.write_text(text)
         click.echo(f"Updated {proposals_file}", err=True)
     else:
         click.echo("No changes needed.", err=True)
 
 
+# --- research ---
+
+
+def _get_belief_info(belief_id: str) -> dict | None:
+    """Run `reasons show` and parse output to extract belief metadata."""
+    result = subprocess.run(
+        ["reasons", "show", belief_id],
+        capture_output=True, text=True,
+    )
+    if result.returncode != 0:
+        return None
+
+    info = {"id": belief_id, "text": "", "source": "", "status": "",
+            "dependents": []}
+    for line in result.stdout.splitlines():
+        line = line.strip()
+        if line.startswith("Text:"):
+            info["text"] = line[5:].strip()
+        elif line.startswith("Status:"):
+            info["status"] = line[7:].strip()
+        elif line.startswith("Source:"):
+            info["source"] = line[7:].strip()
+        elif line.startswith("Dependents:"):
+            deps = line[11:].strip()
+            if deps:
+                info["dependents"] = [d.strip() for d in deps.split(",") if d.strip()]
+    return info
+
+
+def _extract_issue_refs(text: str) -> list[dict]:
+    """Extract issue and PR references from text.
+
+    Returns list of dicts with 'type' (issue/pr) and 'number' or 'key'.
+    """
+    refs = []
+    seen = set()
+
+    # GH-123, PR-123
+    for match in re.finditer(r"\b(GH|PR)-(\d+)\b", text):
+        kind = "pr" if match.group(1) == "PR" else "issue"
+        num = int(match.group(2))
+        key = (kind, num)
+        if key not in seen:
+            seen.add(key)
+            refs.append({"type": kind, "number": num})
+
+    # #123 (common GitHub shorthand)
+    for match in re.finditer(r"(?<!\w)#(\d+)\b", text):
+        num = int(match.group(1))
+        key = ("issue", num)
+        if key not in seen:
+            seen.add(key)
+            refs.append({"type": "issue", "number": num})
+
+    # PROJ-123 (Jira-style keys)
+    for match in re.finditer(r"\b([A-Z][A-Z0-9]+-\d+)\b", text):
+        jira_key = match.group(1)
+        if jira_key.startswith(("GH-", "PR-", "GL-", "MR-")):
+            continue
+        if jira_key not in seen:
+            seen.add(jira_key)
+            refs.append({"type": "issue", "key": jira_key})
+
+    return refs
+
+
+def _fetch_artifacts(refs: list[dict], source, config: dict) -> str:
+    """Fetch current state for each issue/PR reference."""
+    parts = []
+    platform = config["platform"]
+
+    for ref in refs:
+        try:
+            if "key" in ref:
+                issue = source.get_issue(ref["key"])
+                parts.append(issue.to_prompt_text())
+            elif ref["type"] == "pr" and platform == "github" and hasattr(source, "get_pr"):
+                pr = source.get_pr(ref["number"])
+                parts.append(pr.to_prompt_text())
+            elif ref["type"] == "issue":
+                issue = source.get_issue(ref["number"])
+                parts.append(issue.to_prompt_text())
+            else:
+                parts.append(f"(Could not fetch {ref['type']} #{ref.get('number', ref.get('key'))})")
+        except Exception as e:
+            parts.append(f"(Error fetching {ref['type']} #{ref.get('number', ref.get('key'))}: {e})")
+
+    return "\n\n---\n\n".join(parts) if parts else "(No artifacts fetched)"
+
+
+def _get_dependent_beliefs(belief_id: str, network: dict) -> str:
+    """Format dependent beliefs for the research prompt."""
+    nodes = network.get("nodes", {})
+    node = nodes.get(belief_id, {})
+    dep_ids = node.get("dependents", [])
+
+    if not dep_ids:
+        return "(No dependent beliefs)"
+
+    lines = []
+    for dep_id in dep_ids:
+        dep_node = nodes.get(dep_id, {})
+        text = dep_node.get("text", "")[:120]
+        status = dep_node.get("truth_value", "?")
+        lines.append(f"- `{dep_id}` [{status}]: {text}")
+    return "\n".join(lines)
+
+
+def _select_beliefs_for_research(network: dict, negative: bool = False,
+                                  high_impact: bool = False,
+                                  limit: int = 10) -> list[str]:
+    """Select belief IDs for research based on criteria."""
+    nodes = network.get("nodes", {})
+    candidates = []
+
+    for node_id, node in nodes.items():
+        tv = node.get("truth_value", "")
+        has_justifications = bool(node.get("justifications"))
+
+        if negative and tv != "OUT":
+            continue
+        if not negative and not has_justifications:
+            pass  # premises are valid candidates
+
+        dep_count = len(node.get("dependents", []))
+        candidates.append((node_id, dep_count, tv))
+
+    if high_impact:
+        candidates.sort(key=lambda x: -x[1])
+    else:
+        candidates.sort(key=lambda x: x[0])
+
+    return [c[0] for c in candidates[:limit]]
+
+
+@cli.command("research")
+@click.argument("belief_id", required=False)
+@click.option("--negative", is_flag=True, default=False,
+              help="Select from OUT/negative beliefs")
+@click.option("--high-impact", is_flag=True, default=False,
+              help="Select by dependent count (highest first)")
+@click.option("--limit", "select_limit", type=int, default=1,
+              help="Number of beliefs to research (default: 1)")
+@click.option("--parallel", "max_parallel", type=int, default=1,
+              help="Max concurrent LLM calls (default: 1, try 3 for speed)")
+@click.pass_context
+def research(ctx, belief_id, negative, high_impact, select_limit, max_parallel):
+    """Verify a belief against its source system.
+
+    Fetches the current state of issues/PRs referenced by a belief,
+    compares against the belief's claims, and reports discrepancies.
+
+    Examples:
+        project-expert research my-belief-id
+        project-expert research --negative
+        project-expert research --high-impact --limit 5
+        project-expert research --high-impact --limit 5 --parallel 3
+    """
+    config = _load_config()
+    if not config:
+        click.echo("Not initialized. Run: project-expert init <platform> <target>")
+        sys.exit(1)
+
+    model = ctx.obj["model"]
+    timeout = ctx.obj["timeout"]
+
+    if not _has_reasons():
+        click.echo("Error: reasons CLI required. Install with: uv tool install ftl-reasons", err=True)
+        sys.exit(1)
+
+    if not check_model_available(model):
+        click.echo(f"Error: Model '{model}' CLI not available", err=True)
+        sys.exit(1)
+
+    # Determine which beliefs to research
+    if belief_id:
+        belief_ids = [belief_id]
+    else:
+        network = _load_network()
+        belief_ids = _select_beliefs_for_research(
+            network, negative=negative, high_impact=high_impact,
+            limit=select_limit,
+        )
+        if not belief_ids:
+            label = "negative " if negative else ""
+            click.echo(f"No {label}beliefs found to research.")
+            return
+        click.echo(f"Selected {len(belief_ids)} belief(s) for research:", err=True)
+        for bid in belief_ids:
+            click.echo(f"  {bid}", err=True)
+
+    source = _get_source(config)
+    network = _load_network()
+    project_dir = _get_project_dir()
+
+    def _research_one(bid: str) -> str | None:
+        """Build a research prompt for a single belief."""
+        info = _get_belief_info(bid)
+        if not info:
+            click.echo(f"Belief not found: {bid}", err=True)
+            return None
+
+        click.echo(f"\nResearching: {bid}", err=True)
+        click.echo(f"  Claim: {info['text'][:100]}", err=True)
+        click.echo(f"  Status: {info['status']}", err=True)
+
+        # Read source entry
+        source_entry = "(No source entry found)"
+        source_path = info.get("source", "")
+        if source_path:
+            entry_path = Path(source_path)
+            if entry_path.is_file():
+                content = entry_path.read_text()
+                if len(content) > 15000:
+                    content = content[:15000] + "\n[Truncated]"
+                source_entry = content
+                click.echo(f"  Source: {source_path}", err=True)
+
+        # Extract and fetch artifacts
+        all_text = f"{info['text']}\n{source_entry}"
+        refs = _extract_issue_refs(all_text)
+        click.echo(f"  Found {len(refs)} reference(s)", err=True)
+
+        artifacts = _fetch_artifacts(refs, source, config)
+
+        # Get dependent beliefs
+        dependents = _get_dependent_beliefs(bid, network)
+        dep_count = len(info.get("dependents", []))
+        if dep_count:
+            click.echo(f"  {dep_count} dependent belief(s)", err=True)
+
+        return RESEARCH_PROMPT.format(
+            belief_id=bid,
+            belief_text=info["text"],
+            belief_status=info["status"],
+            source_entry=source_entry,
+            artifacts=artifacts,
+            dependents=dependents,
+        )
+
+    # Build prompts
+    prompts_with_ids = []
+    for bid in belief_ids:
+        prompt = _research_one(bid)
+        if prompt:
+            prompts_with_ids.append((bid, prompt))
+
+    if not prompts_with_ids:
+        click.echo("No beliefs could be researched.")
+        return
+
+    # Invoke LLM
+    ids = [p[0] for p in prompts_with_ids]
+    prompts = [p[1] for p in prompts_with_ids]
+
+    click.echo(f"\nRunning {model} on {len(prompts)} belief(s)...", err=True)
+
+    if max_parallel > 1 and len(prompts) > 1:
+        results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,
+                                         max_concurrent=max_parallel)
+    else:
+        results = []
+        for i, prompt in enumerate(prompts):
+            click.echo(f"  [{i + 1}/{len(prompts)}] {ids[i]}...", err=True)
+            try:
+                result = invoke_sync(prompt, model=model, timeout=timeout)
+                results.append(result)
+            except Exception as e:
+                click.echo(f"  ERROR: {e}", err=True)
+                results.append(e)
+
+    # Process results
+    for bid, result in zip(ids, results):
+        if isinstance(result, Exception):
+            click.echo(f"\nERROR researching {bid}: {result}", err=True)
+            continue
+
+        # Extract verdict
+        verdict = "UNKNOWN"
+        for line in result.splitlines():
+            line = line.strip()
+            if line.startswith("VERDICT:"):
+                verdict = line[8:].strip()
+                break
+
+        click.echo(f"\n{'=' * 40}", err=True)
+        click.echo(f"  {bid}: {verdict}", err=True)
+        click.echo(f"{'=' * 40}", err=True)
+
+        safe_id = re.sub(r"[^a-zA-Z0-9_-]", "-", bid)[:80]
+        _create_entry(f"research-{safe_id}", f"Research: {bid} [{verdict}]", result)
+
+        _emit(ctx, result)
+
+
 # --- derive ---
 
 
 def _load_network() -> dict:
     """Load network.json (exported from reasons)."""
     network_path = Path("network.json")
     if not network_path.exists():
         if _has_reasons():
             result = subprocess.run(
                 ["reasons", "export"], capture_output=True, text=True,
diff --git a/ftl_project_expert/prompts/__init__.py b/ftl_project_expert/prompts/__init__.py
index a671162..d0a9d17 100644
--- a/ftl_project_expert/prompts/__init__.py
+++ b/ftl_project_expert/prompts/__init__.py
@@ -1,13 +1,15 @@
 """Prompt templates for project expert."""
 
 from .scan import build_scan_prompt
 from .explore import build_explore_prompt
 from .propose import PROPOSE_BELIEFS_PROJECT
+from .research import RESEARCH_PROMPT
 from .summary import build_summary_prompt
 
 __all__ = [
     "build_scan_prompt",
     "build_explore_prompt",
     "PROPOSE_BELIEFS_PROJECT",
+    "RESEARCH_PROMPT",
     "build_summary_prompt",
 ]
diff --git a/ftl_project_expert/prompts/research.py b/ftl_project_expert/prompts/research.py
new file mode 100644
index 0000000..5088f71
--- /dev/null
+++ b/ftl_project_expert/prompts/research.py
@@ -0,0 +1,65 @@
+"""Research prompt — verify a belief against its source system."""
+
+RESEARCH_PROMPT = """\
+You are verifying a belief from a project knowledge base against current source system data.
+
+## Belief Under Investigation
+
+**ID:** {belief_id}
+**Claim:** {belief_text}
+**Status in network:** {belief_status}
+
+## Source Entry
+
+The belief was extracted from this exploration entry:
+
+{source_entry}
+
+## Current Issue/PR State
+
+Live data fetched from the issue tracker:
+
+{artifacts}
+
+## Dependent Beliefs
+
+These beliefs depend on the one under investigation. If the investigated belief \
+is disputed or stale, these may also be affected:
+
+{dependents}
+
+## Instructions
+
+Compare the belief claim against the current source data. Look for:
+
+1. **State changes** — Does the belief reference something as open/blocking/unresolved \
+that is now closed, merged, or resolved?
+2. **Factual accuracy** — Are specific claims (assignees, timelines, PR references, \
+comment content) consistent with the current data?
+3. **Reference integrity** — Do linked PRs/commits/issues actually exist and match \
+what the belief claims about them? (e.g., a PR attributed to one purpose may actually \
+be a different change by a different author)
+4. **Staleness** — Has significant activity occurred since the belief was extracted \
+that changes its validity?
+
+## Output Format
+
+Start with a verdict line:
+
+VERDICT: VERIFIED | STALE | DISPUTED | UNVERIFIABLE
+
+Then provide:
+
+### Evidence
+Specific observations from the source data that support your verdict. \
+Reference issue IDs, PR numbers, dates, and quotes.
+
+### Discrepancies
+Any mismatches between the belief claim and current reality. Be specific.
+
+### Impact
+If the belief is stale or disputed, which dependent beliefs are affected and how?
+
+### Recommendations
+What should happen next? Retract the belief? Update it? Investigate further?
+"""
diff --git a/ftl_project_expert/sources/github.py b/ftl_project_expert/sources/github.py
index 6b3c0e3..5ec3576 100644
--- a/ftl_project_expert/sources/github.py
+++ b/ftl_project_expert/sources/github.py
@@ -55,20 +55,37 @@ class GitHubSource:
             "--json", "number,title,url,body,state,labels,assignees,"
                       "milestone,author,createdAt,updatedAt,closedAt,comments",
         ]
         result = subprocess.run(cmd, capture_output=True, text=True)
         if result.returncode != 0:
             raise RuntimeError(f"gh issue view failed: {result.stderr.strip()}")
 
         raw = json.loads(result.stdout)
         return self._normalize(raw)
 
+    def get_pr(self, number: int) -> PullRequest:
+        """Get a single pull request with full details."""
+        cmd = [
+            "gh", "pr", "view", str(number),
+            "--repo", self.repo,
+            "--json", "number,title,url,body,state,labels,author,"
+                      "createdAt,updatedAt,mergedAt,mergedBy,"
+                      "files,additions,deletions,changedFiles,"
+                      "reviews,comments,closingIssuesReferences",
+        ]
+        result = subprocess.run(cmd, capture_output=True, text=True)
+        if result.returncode != 0:
+            raise RuntimeError(f"gh pr view failed: {result.stderr.strip()}")
+
+        raw = json.loads(result.stdout)
+        return self._normalize_pr(raw)
+
     def list_prs(
         self,
         state: str = "open",
         limit: int = 100,
         since: str | None = None,
     ) -> list[PullRequest]:
         """List pull requests from the repository."""
         # Map issue states to PR states
         pr_state = state
         if state == "closed":

```

## 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:
