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 a32ac21..ba0f2bb 100644
--- a/ftl_code_expert/cli.py
+++ b/ftl_code_expert/cli.py
@@ -33,6 +33,7 @@
     PROPOSE_BELIEFS_CODE,
     RESEARCH_INFER_FILES_PROMPT,
     REVIEW_PROMPT,
+    VERIFY_PROMPT,
     build_diff_prompt,
     build_diff_summary_prompt,
     build_file_prompt,
@@ -3681,6 +3682,215 @@ def research(ctx, review_file, limit, dry_run):
     click.echo("\nResearch complete. Run `code-expert derive` to attempt rejustification.", err=True)
 
 
+# --- verify ---
+
+
+def _parse_verify_response(response: str) -> dict[str, dict]:
+    """Parse LLM verify response into {id: {verdict, reason}} dict."""
+    m = re.search(r"\{.*\}", response, re.DOTALL)
+    if not m:
+        return {}
+    try:
+        data = json.loads(m.group(0))
+        results = {}
+        for k, v in data.items():
+            if isinstance(v, dict) and "verdict" in v:
+                results[k] = {
+                    "verdict": v["verdict"].upper(),
+                    "reason": v.get("reason", ""),
+                }
+        return results
+    except (json.JSONDecodeError, TypeError, AttributeError):
+        return {}
+
+
+@cli.command("verify")
+@click.argument("belief_ids", nargs=-1)
+@click.option("--category", default=None,
+              help="Verify IN beliefs matching keyword in ID or text")
+@click.option("--gated", is_flag=True, default=False,
+              help="Verify IN beliefs that actively gate downstream chains")
+@click.option("--negative", is_flag=True, default=False,
+              help="Verify negative IN beliefs (bugs, gaps, risks)")
+@click.option("--all", "verify_all", is_flag=True, default=False,
+              help="Verify all IN beliefs (expensive)")
+@click.option("--retract", is_flag=True, default=False,
+              help="Retract STALE beliefs via reasons")
+@click.option("--dry-run", is_flag=True, default=False,
+              help="Show what would be verified without calling LLM")
+@click.option("--batch-size", type=int, default=10,
+              help="Beliefs per LLM batch (default: 10)")
+@click.pass_context
+def verify(ctx, belief_ids, category, gated, negative, verify_all, retract, dry_run, batch_size):
+    """Check whether beliefs still hold against current source code.
+
+    Reads the current source code for each belief and asks an LLM whether
+    the claim is CONFIRMED, STALE, or INCONCLUSIVE.
+
+    Examples:
+        code-expert verify login-audit-always-success-info
+        code-expert verify --category auth
+        code-expert verify --gated
+        code-expert verify --negative --retract
+        code-expert verify --all --dry-run
+    """
+    from .caffeinate import hold as _caffeinate
+    _caffeinate()
+
+    model = ctx.obj["model"]
+    timeout = ctx.obj["timeout"]
+    repo_path = _get_repo(ctx)
+    abs_repo = os.path.abspath(repo_path)
+    project_dir = _get_project_dir(ctx)
+
+    if not check_model_available(model):
+        click.echo(f"Error: Model '{model}' CLI not available", err=True)
+        sys.exit(1)
+
+    # Load belief network
+    try:
+        network = _load_network()
+        nodes = network.get("nodes", {})
+    except Exception as e:
+        click.echo(f"Error loading belief network: {e}", err=True)
+        sys.exit(1)
+
+    if not nodes:
+        click.echo("No beliefs found. Run explorations and propose-beliefs first.")
+        return
+
+    # Select beliefs to verify
+    beliefs: list[dict] = []
+
+    if belief_ids:
+        for bid in belief_ids:
+            node = nodes.get(bid)
+            if node:
+                beliefs.append({"id": bid, "text": node.get("text", "")})
+            else:
+                click.echo(f"  Belief not found: {bid}", err=True)
+
+    elif gated:
+        gated_beliefs = _find_gated_out_beliefs(nodes)
+        blocker_ids = set()
+        for gb in gated_beliefs:
+            for blocker in gb.get("blockers", []):
+                blocker_ids.add(blocker["id"])
+        for bid in blocker_ids:
+            node = nodes.get(bid, {})
+            beliefs.append({"id": bid, "text": node.get("text", "")})
+        click.echo(f"Found {len(beliefs)} active blocker(s) gating {len(gated_beliefs)} downstream belief(s)", err=True)
+
+    elif negative:
+        beliefs = _get_negative_beliefs(nodes, model=model)
+        click.echo(f"Found {len(beliefs)} negative IN belief(s)", err=True)
+
+    elif category:
+        keyword = category.lower()
+        for nid, node in nodes.items():
+            if node.get("truth_value") != "IN":
+                continue
+            if keyword in nid.lower() or keyword in node.get("text", "").lower():
+                beliefs.append({"id": nid, "text": node.get("text", "")})
+        click.echo(f"Found {len(beliefs)} IN belief(s) matching '{category}'", err=True)
+
+    elif verify_all:
+        for nid, node in nodes.items():
+            if node.get("truth_value") == "IN":
+                beliefs.append({"id": nid, "text": node.get("text", "")})
+        click.echo(f"Found {len(beliefs)} IN belief(s) to verify", err=True)
+
+    else:
+        click.echo("Specify belief IDs, or use --category, --gated, --negative, or --all.", err=True)
+        sys.exit(1)
+
+    if not beliefs:
+        click.echo("No beliefs to verify.")
+        return
+
+    if dry_run:
+        click.echo(f"\n{len(beliefs)} belief(s) would be verified:", err=True)
+        for b in beliefs:
+            click.echo(f"  {b['id']}: {b['text'][:100]}", err=True)
+        click.echo("\n--dry-run: stopping before LLM verification.", err=True)
+        return
+
+    # Verify in batches
+    all_results: dict[str, dict] = {}
+    batches = [beliefs[i:i + batch_size] for i in range(0, len(beliefs), batch_size)]
+
+    for i, batch in enumerate(batches):
+        click.echo(f"\nVerifying batch {i + 1}/{len(batches)} ({len(batch)} beliefs)...", err=True)
+
+        contexts = asyncio.run(
+            _gather_confirmation_context(batch, nodes, abs_repo, project_dir)
+        )
+
+        beliefs_section = []
+        for belief in batch:
+            ctx_text = contexts.get(belief["id"], "(no code context found)")
+            beliefs_section.append(
+                f"### `{belief['id']}`\n{belief['text']}\n\n"
+                f"**Code context:**\n{ctx_text}"
+            )
+
+        prompt = VERIFY_PROMPT.format(beliefs="\n\n---\n\n".join(beliefs_section))
+
+        try:
+            response = invoke_sync(prompt, model=model, timeout=timeout)
+            results = _parse_verify_response(response)
+            all_results.update(results)
+        except Exception as e:
+            click.echo(f"  Error: {e}", err=True)
+
+    # Report results
+    confirmed = []
+    stale = []
+    inconclusive = []
+
+    for belief in beliefs:
+        bid = belief["id"]
+        result = all_results.get(bid)
+        if not result:
+            inconclusive.append(bid)
+            click.echo(f"  {bid}: INCONCLUSIVE (no LLM response)", err=True)
+            continue
+
+        verdict = result["verdict"]
+        reason = result.get("reason", "")
+
+        if verdict == "CONFIRMED":
+            confirmed.append(bid)
+            click.echo(f"  {bid}: CONFIRMED — {reason}", err=True)
+        elif verdict == "STALE":
+            stale.append(bid)
+            click.echo(f"  {bid}: STALE — {reason}", err=True)
+        else:
+            inconclusive.append(bid)
+            click.echo(f"  {bid}: INCONCLUSIVE — {reason}", err=True)
+
+    click.echo(f"\nResults: {len(confirmed)} confirmed, {len(stale)} stale, "
+               f"{len(inconclusive)} inconclusive", err=True)
+
+    # Retract stale beliefs
+    if retract and stale and _has_reasons():
+        click.echo(f"\nRetracting {len(stale)} stale belief(s)...", err=True)
+        for bid in stale:
+            reason = all_results.get(bid, {}).get("reason", "stale per verify")
+            result = subprocess.run(
+                ["reasons", "retract", bid, "--reason", reason],
+                capture_output=True, text=True,
+            )
+            if result.returncode == 0:
+                click.echo(f"  Retracted: {bid}", err=True)
+            else:
+                click.echo(f"  Failed to retract {bid}: {result.stderr.strip()}", err=True)
+        _reasons_export()
+        click.echo("Network updated.", err=True)
+    elif retract and stale:
+        click.echo("\nCannot retract: reasons CLI not available.", err=True)
+
+
 # --- update ---
 
 
diff --git a/ftl_code_expert/prompts/__init__.py b/ftl_code_expert/prompts/__init__.py
index 258543b..b0feca3 100644
--- a/ftl_code_expert/prompts/__init__.py
+++ b/ftl_code_expert/prompts/__init__.py
@@ -9,6 +9,7 @@
 from .propose import PROPOSE_BELIEFS_CODE
 from .research import RESEARCH_INFER_FILES_PROMPT
 from .review import REVIEW_PROMPT
+from .verify import VERIFY_PROMPT
 from .repo import build_repo_prompt
 from .scan import build_scan_prompt
 from .spec import GENERATE_SPEC_PROMPT
@@ -20,6 +21,7 @@
     "PROPOSE_BELIEFS_CODE",
     "RESEARCH_INFER_FILES_PROMPT",
     "REVIEW_PROMPT",
+    "VERIFY_PROMPT",
     "TOPICS_INSTRUCTIONS",
     "build_diff_prompt",
     "build_diff_summary_prompt",
diff --git a/ftl_code_expert/prompts/verify.py b/ftl_code_expert/prompts/verify.py
new file mode 100644
index 0000000..ee72f3c
--- /dev/null
+++ b/ftl_code_expert/prompts/verify.py
@@ -0,0 +1,25 @@
+"""Prompt template for verifying belief staleness against current source code."""
+
+VERIFY_PROMPT = """\
+You are verifying whether beliefs about a codebase still hold by examining the current source code.
+
+For each belief below, I provide the belief text and relevant code context gathered from \
+the current state of the repository.
+
+Determine whether each belief is:
+- **CONFIRMED** — the current code still supports this claim
+- **STALE** — the code has changed and the belief no longer holds (explain what changed)
+- **INCONCLUSIVE** — the provided code context is insufficient to determine either way
+
+Return ONLY a JSON object mapping each belief ID to an object with "verdict" and "reason":
+
+Example:
+```json
+{{"belief-1": {{"verdict": "CONFIRMED", "reason": "The handler still enforces zero-arg construction"}}, \
+"belief-2": {{"verdict": "STALE", "reason": "LoginAttemptAuditHandler now covers failed logins with WARNING severity"}}, \
+"belief-3": {{"verdict": "INCONCLUSIVE", "reason": "The relevant middleware file was not in the provided context"}}}}
+```
+
+## Beliefs to Verify
+
+{beliefs}"""

```

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