You are a senior code reviewer. Review the following code changes.

## Specification

No specification provided. Focus on correctness, tests, and integration.





## Code Changes

```diff
diff --git a/ftl_code_expert/cli.py b/ftl_code_expert/cli.py
index 322a1a1..dbef41c 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,207 @@ 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."""
+    m = re.search(r"\[.*?\]", response, re.DOTALL)
+    if not m:
+        return []
+    try:
+        files = json.loads(m.group(0))
+        if isinstance(files, list):
+            return [f for f in files if isinstance(f, str)]
+    except json.JSONDecodeError:
+        pass
+    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:
+            _run_file_topic(ctx, topic, model, abs_repo)
+
+    # 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.
+"""

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "explore_topics_concurrent_body": {
    "function": "_explore_topics_concurrent",
    "file": "ftl_code_expert/cli.py",
    "start_line": 1137,
    "end_line": 1165,
    "source": "async def _explore_topics_concurrent(topics, model, repo_path, timeout, max_concurrent):\n    \"\"\"Explore multiple topics concurrently. Returns list of (topic, result, entry_name, entry_title, source) or Exception.\"\"\"\n    sem = asyncio.Semaphore(max_concurrent)\n\n    async def _do_topic(topic):\n        async with sem:\n            click.echo(f\"Explaining [{topic.kind}] {topic.target} with {model}...\", err=True)\n            if topic.kind == \"general\":\n                result, entry_name, entry_title, source = await _run_general_topic_async(\n                    topic, model, repo_path, timeout,\n                )\n                return topic, result, entry_name, entry_title, source\n\n            prepare_fn = _PREPARE_DISPATCH.get(topic.kind)\n            if not prepare_fn:\n                raise ValueError(f\"Unknown topic kind: {topic.kind}\")\n\n            prepared = prepare_fn(topic, repo_path)\n            if prepared is None:\n                return None\n\n            prompt, entry_name, entry_title, source = prepared\n            result = await invoke(prompt, model, timeout=timeout)\n            return topic, result, entry_name, entry_title, source\n\n    return await asyncio.gather(\n        *[_do_topic(t) for t in topics],\n        return_exceptions=True,\n    )"
  },
  "finalize_topic_body": {
    "function": "_finalize_topic",
    "file": "ftl_code_expert/cli.py",
    "start_line": 997,
    "end_line": 1002,
    "source": "def _finalize_topic(ctx, entry_name, entry_title, source, result):\n    \"\"\"Write entry, enqueue new topics, and report beliefs from a completed exploration.\"\"\"\n    _create_entry(entry_name, entry_title, result)\n    _enqueue_topics(result, source=source, project_dir=_get_project_dir(ctx))\n    _report_beliefs(result)\n    _emit(ctx, result)"
  },
  "run_file_topic_body": {
    "function": "_run_file_topic",
    "file": "ftl_code_expert/cli.py",
    "start_line": 1013,
    "end_line": 1025,
    "source": "def _run_file_topic(ctx, topic, model, repo_path):\n    \"\"\"Handle a file exploration topic.\"\"\"\n    prepared = _prepare_file_topic(topic, repo_path)\n    if prepared is None:\n        return\n    prompt, entry_name, entry_title, source = prepared\n    click.echo(f\"Explaining {topic.target} with {model}...\", err=True)\n    try:\n        result = asyncio.run(invoke(prompt, model, timeout=ctx.obj[\"timeout\"]))\n    except Exception as e:\n        click.echo(f\"Error: {e}\", err=True)\n        sys.exit(1)\n    _finalize_topic(ctx, entry_name, entry_title, source, result)"
  },
  "load_network_body": {
    "function": "_load_network",
    "file": "ftl_code_expert/cli.py",
    "start_line": 2705,
    "end_line": 2718,
    "source": "def _load_network() -> dict:\n    \"\"\"Load network.json (exported from reasons).\"\"\"\n    network_path = Path(\"network.json\")\n    if not network_path.exists():\n        # Try exporting from reasons\n        if _has_reasons():\n            import subprocess\n            result = subprocess.run(\n                [\"reasons\", \"export\"], capture_output=True, text=True,\n            )\n            if result.returncode == 0:\n                return json.loads(result.stdout)\n        return {\"nodes\": {}}\n    return json.loads(network_path.read_text())"
  },
  "invoke_concurrent_sync_body": {
    "error": "No function found at 'invoke_concurrent_sync'",
    "file": "ftl_code_expert/cli.py"
  },
  "propose_beliefs_body": {
    "function": "propose_beliefs",
    "file": "ftl_code_expert/cli.py",
    "start_line": 1389,
    "end_line": 1586,
    "source": "@cli.command(\"propose-beliefs\")\n@click.option(\"--batch-size\", type=int, default=5, help=\"Entries per LLM batch (default: 5)\")\n@click.option(\"--output\", default=\"proposed-beliefs.md\", help=\"Output file\")\n@click.option(\"--model\", \"-m\", default=None, help=\"Override model\")\n@click.option(\"--entry\", \"entry_paths\", multiple=True, type=click.Path(exists=True),\n              help=\"Process specific entry file(s) instead of all entries\")\n@click.option(\"--all\", \"process_all\", is_flag=True,\n              help=\"Re-process all entries (ignore processed tracking)\")\n@click.option(\"--auto\", \"auto_accept\", is_flag=True, default=False,\n              help=\"Automatically accept all proposed beliefs (no review step)\")\n@click.option(\"--since\", default=None,\n              help=\"Only process entries from this date onward (YYYY-MM-DD)\")\n@click.pass_context\ndef propose_beliefs(ctx, batch_size, output, model, entry_paths, process_all, auto_accept, since):\n    \"\"\"Extract candidate beliefs from entries for human review.\"\"\"\n    from .caffeinate import hold as _caffeinate\n    _caffeinate()\n    if model is None:\n        model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n\n    if not check_model_available(model):\n        click.echo(f\"Error: Model '{model}' CLI not available\", err=True)\n        sys.exit(1)\n\n    # Collect entries\n    if entry_paths:\n        entries = [Path(p) for p in entry_paths]\n    else:\n        input_dir = Path(\"entries\")\n        if not input_dir.exists():\n            click.echo(\"No entries/ directory found. Run explorations first.\")\n            sys.exit(1)\n        entries = sorted(input_dir.rglob(\"*.md\"))\n\n    if not entries:\n        click.echo(\"No .md files found.\")\n        return\n\n    # Filter by date if --since provided\n    if since:\n        before = len(entries)\n        entries = [e for e in entries if (_entry_date(e) or \"\") >= since]\n        click.echo(f\"Filtered to {len(entries)} entries since {since} (from {before} total)\", err=True)\n        if not entries:\n            click.echo(\"No entries found since that date.\")\n            return\n\n    # Filter out already-processed entries (unless --all or --entry)\n    processed_path = Path(PROJECT_DIR) / \"proposed-entries.json\"\n    processed = _load_processed(processed_path)\n    if not process_all and not entry_paths:\n        total = len(entries)\n        entries = _filter_unprocessed(entries, processed)\n        skipped = total - len(entries)\n        if skipped:\n            click.echo(f\"Skipping {skipped} already-processed entries (use --all to reprocess)\")\n        if not entries:\n            click.echo(\"No new entries to process.\")\n            return\n\n    # Load existing beliefs (IDs + text + source) for dedup context\n    existing_beliefs = _load_existing_beliefs(Path(\"beliefs.md\"))\n    existing_ids = {b[\"id\"] for b in existing_beliefs}\n\n    if existing_ids:\n        click.echo(f\"Found {len(existing_ids)} existing beliefs (will skip duplicates)\")\n\n    # Compute belief embeddings once for all batches (if fastembed available)\n    belief_vectors = None\n    if existing_beliefs and _has_embeddings():\n        click.echo(\"Computing belief embeddings for semantic dedup...\")\n        cache_path = Path(PROJECT_DIR) / \"belief-vectors.json\"\n        belief_vectors = _get_belief_embeddings(existing_beliefs, cache_path)\n        click.echo(f\"  {len(belief_vectors)} belief vectors ready\")\n    elif existing_beliefs:\n        click.echo(\"(install fastembed for semantic dedup: uv pip install 'ftl-code-expert[embeddings]')\")\n\n    click.echo(f\"Reading {len(entries)} entries...\")\n\n    # Batch entries \u2014 track paths per batch for relevance scoring\n    batches = []\n    batch_paths = []\n    current_batch = []\n    current_paths = []\n    for entry_path in entries:\n        content = entry_path.read_text()\n        if len(content) > 10000:\n            content = content[:10000] + \"\\n[Truncated]\"\n        current_batch.append(f\"--- FILE: {entry_path} ---\\n{content}\")\n        current_paths.append(str(entry_path))\n        if len(current_batch) >= batch_size:\n            batches.append(\"\\n\\n\".join(current_batch))\n            batch_paths.append(current_paths)\n            current_batch = []\n            current_paths = []\n    if current_batch:\n        batches.append(\"\\n\\n\".join(current_batch))\n        batch_paths.append(current_paths)\n\n    parallel = ctx.obj[\"parallel\"]\n    click.echo(f\"Processing {len(batches)} batches (batch size: {batch_size}, parallel: {parallel})...\")\n\n    prompts = []\n    for i, batch_text in enumerate(batches):\n        existing_context = _build_dedup_context(\n            existing_beliefs, batch_paths[i], batch_text,\n            belief_vectors=belief_vectors,\n        )\n        prompts.append(PROPOSE_BELIEFS_CODE.format(entries=batch_text) + existing_context)\n\n    results = invoke_concurrent_sync(prompts, model=model, timeout=timeout, max_concurrent=parallel)\n\n    all_proposals = []\n    for i, result in enumerate(results):\n        if isinstance(result, Exception):\n            click.echo(f\"  ERROR in batch {i + 1}: {result}\")\n        else:\n            click.echo(f\"  Batch {i + 1}/{len(batches)} done\")\n            all_proposals.append(result)\n\n    # Filter out proposals whose IDs already exist\n    filtered_proposals = []\n    skipped = 0\n    for proposal in all_proposals:\n        lines = proposal.split(\"\\n\")\n        filtered_lines = []\n        skip_until_next = False\n        for line in lines:\n            m = re.match(r\"^### \\[?(?:ACCEPT|REJECT)\\]? (\\S+)\", line)\n            if m:\n                belief_id = m.group(1)\n                if belief_id in existing_ids:\n                    skip_until_next = True\n                    skipped += 1\n                    continue\n                else:\n                    skip_until_next = False\n            if skip_until_next:\n                # Skip lines until the next ### header\n                if line.startswith(\"### \"):\n                    skip_until_next = False\n                    filtered_lines.append(line)\n                continue\n            filtered_lines.append(line)\n        filtered_proposals.append(\"\\n\".join(filtered_lines))\n\n    if skipped:\n        click.echo(f\"  Filtered {skipped} already-accepted beliefs\")\n\n    # Record processed entries\n    _save_processed(processed_path, entries, processed)\n\n    if auto_accept:\n        # Parse proposals and accept all directly\n        accept_pattern = re.compile(\n            r\"^### \\[?(?:ACCEPT(?:/REJECT)?|REJECT)\\]? (\\S+)\\n(.+?)\\n- Source: (.+?)(?:\\n|$)\",\n            re.MULTILINE,\n        )\n        matches = []\n        for proposal in filtered_proposals:\n            matches.extend(accept_pattern.findall(proposal))\n        if not matches:\n            click.echo(\"No beliefs extracted from proposals.\")\n            return\n        click.echo(f\"\\nAuto-accepting {len(matches)} beliefs...\")\n        _accept_proposals(matches)\n        return\n\n    # Write proposals file (append if it already exists)\n    source_desc = \", \".join(str(e) for e in entries) if entry_paths else f\"{len(entries)} entries from entries/\"\n    output_path = Path(output)\n    if output_path.exists() and output_path.stat().st_size > 0:\n        with output_path.open(\"a\") as f:\n            f.write(f\"\\n---\\n\\n\")\n            f.write(f\"**Generated:** {date.today().isoformat()}\\n\")\n            f.write(f\"**Source:** {source_desc}\\n\")\n            f.write(f\"**Model:** {model}\\n\\n\")\n            for proposal in filtered_proposals:\n                f.write(proposal)\n                f.write(\"\\n\\n\")\n        click.echo(f\"\\nAppended to {output_path}\")\n    else:\n        with output_path.open(\"w\") as f:\n            f.write(\"# Proposed Beliefs\\n\\n\")\n            f.write(\"Edit each entry: change `[ACCEPT/REJECT]` to `[ACCEPT]` or `[REJECT]`.\\n\")\n            f.write(\"Then run: `code-expert accept-beliefs`\\n\\n\")\n            f.write(\"---\\n\\n\")\n            f.write(f\"**Generated:** {date.today().isoformat()}\\n\")\n            f.write(f\"**Source:** {source_desc}\\n\")\n            f.write(f\"**Model:** {model}\\n\\n\")\n            for proposal in filtered_proposals:\n                f.write(proposal)\n                f.write(\"\\n\\n\")\n        click.echo(f\"\\nWrote {output_path}\")\n\n    click.echo(\"Review the file, mark entries as [ACCEPT] or [REJECT], then run:\")\n    click.echo(\"  code-expert accept-beliefs\")"
  },
  "topic_class": {
    "class_name": "Topic",
    "file": "ftl_code_expert/topics.py",
    "bases": [],
    "own_init_signature": null,
    "base_init_signatures": {}
  },
  "get_repo_structure_body": {
    "error": "No function found at 'get_repo_structure'",
    "file": "ftl_code_expert/cli.py"
  },
  "research_tests": {
    "source_file": "ftl_code_expert/cli.py",
    "test_files": [],
    "test_count": 0
  },
  "cli_imports": {
    "file": "ftl_code_expert/cli.py",
    "imports": [
      "asyncio",
      "json",
      "os",
      "re",
      "shutil",
      "subprocess",
      "sys",
      "click"
    ],
    "from_imports": [
      {
        "module": "datetime",
        "names": [
          "date",
          "datetime"
        ]
      },
      {
        "module": "pathlib",
        "names": [
          "Path"
        ]
      },
      {
        "module": "git_utils",
        "names": [
          "commits_since_checkpoint",
          "extract_symbol",
          "find_related_tests",
          "get_commit_log",
          "get_diff",
          "get_diff_since",
          "get_diff_since_commit",
          "get_file_content",
          "get_imports",
          "get_repo_structure",
          "list_commits_with_files",
          "load_diff_checkpoint",
          "save_diff_checkpoint"
        ]
      },
      {
        "module": "llm",
        "names": [
          "check_model_available",
          "invoke",
          "invoke_concurrent",
          "invoke_concurrent_sync",
          "invoke_sync"
        ]
      },
      {
        "module": "observations",
        "names": [
          "parse_observation_requests",
          "run_observations"
        ]
      },
      {
        "module": "prompts",
        "names": [
          "PROPOSE_BELIEFS_CODE",
          "RESEARCH_INFER_FILES_PROMPT",
          "REVIEW_PROMPT",
          "build_diff_prompt",
          "build_diff_summary_prompt",
          "build_file_prompt",
          "build_function_prompt",
          "build_observe_prompt",
          "build_repo_prompt",
          "build_scan_prompt"
        ]
      },
      {
        "module": "topics",
        "names": [
          "add_topics",
          "load_queue",
          "parse_topics_from_response",
          "pending_count",
          "pop_at",
          "pop_batch",
          "pop_multiple",
          "pop_next",
          "skip_topic"
        ]
      }
    ],
    "import_section": "\"\"\"Command-line interface for code expert.\"\"\"\n\nimport asyncio\nimport json\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nfrom datetime import date, datetime\nfrom pathlib import Path\n\nimport click\n\nfrom .git_utils import (\n    commits_since_checkpoint,\n    extract_symbol,\n    find_related_tests,\n    get_commit_log,\n    get_diff,\n    get_diff_since,\n    get_diff_since_commit,\n    get_file_content,\n    get_imports,\n    get_repo_structure,\n    list_commits_with_files,\n    load_diff_checkpoint,\n    save_diff_checkpoint,\n)\nfrom .llm import check_model_available, invoke, invoke_concurrent, invoke_concurrent_sync, invoke_sync\nfrom .observations import parse_observation_requests, run_observations\nfrom .prompts import (\n    PROPOSE_BELIEFS_CODE,\n    RESEARCH_INFER_FILES_PROMPT,\n    REVIEW_PROMPT,\n    build_diff_prompt,\n    build_diff_summary_prompt,\n    build_file_prompt,\n    build_function_prompt,\n    build_observe_prompt,\n    build_repo_prompt,\n    build_scan_prompt,\n)\nfrom .topics import (\n    add_topics,\n    load_queue,\n    parse_topics_from_response,\n    pending_count,\n    pop_at,\n    pop_batch,\n    pop_multiple,\n    pop_next,\n    skip_topic,\n)\n\nPROJECT_DIR = \".code-expert\"\n\n\n# --- Config helpers ---\n\n\ndef _load_config() -> dict | None:\n    \"\"\"Load .code-expert/config.json if it exists.\"\"\""
  }
}
```

Use these results to inform your review. Do not request the same observations again.


## Instructions

For each significant change (new file, modified function, etc.), provide a structured verdict.

Use this exact format for each change:

### <file_path or file_path:function_name>
VERDICT: PASS | CONCERN | BLOCK
CORRECTNESS: VALID | QUESTIONABLE | BROKEN
SPEC_COMPLIANCE: MEETS | PARTIAL | VIOLATES | N/A
ISSUE_COMPLIANCE: ADDRESSES | PARTIAL | UNRELATED | N/A
BELIEF_COMPLIANCE: CONSISTENT | VIOLATES | N/A
TEST_COVERAGE: COVERED | PARTIAL | UNTESTED
INTEGRATION: WIRED | PARTIAL | MISSING
REASONING: <brief explanation of your assessment>
---

## Review Criteria

1. **CORRECTNESS**: Does the code do what it claims? Is the logic sound?
   - VALID: Logic is correct, no bugs apparent
   - QUESTIONABLE: Logic may have edge cases or unclear behavior
   - BROKEN: Clear bugs or incorrect behavior

2. **SPEC_COMPLIANCE**: Does it meet MUST requirements from the spec?
   - MEETS: All relevant spec requirements satisfied
   - PARTIAL: Some requirements met, others missing or incomplete
   - VIOLATES: Contradicts spec requirements
   - N/A: No spec provided or not applicable

3. **ISSUE_COMPLIANCE** (only when an issue is provided): Do the changes address the problem or feature described in the issue?
   - ADDRESSES: Changes directly solve the issue's stated problem or implement the requested feature
   - PARTIAL: Changes partially address the issue but leave some aspects unresolved
   - UNRELATED: Changes do not appear related to the issue
   - N/A: No issue provided

4. **TEST_COVERAGE**: Are there tests for the new/changed code?
   - COVERED: Tests exist and cover the changes
   - PARTIAL: Some tests exist but coverage is incomplete
   - UNTESTED: No tests for the changes

5. **INTEGRATION**: Are callers updated? Is the feature usable end-to-end?
   - WIRED: Feature is fully integrated and usable
   - PARTIAL: Interface exists but callers not updated, or integration incomplete
   - MISSING: No integration with existing code

6. **BELIEF_COMPLIANCE** (only when beliefs are provided): Do the changes respect known architectural invariants, contracts, and rules?
   - CONSISTENT: Changes align with or reinforce known beliefs
   - VIOLATES: Changes contradict a specific belief — cite the belief ID
   - N/A: No beliefs provided or no relevant beliefs apply

## Verdict Guidelines

- **BLOCK**: Security issues, broken functionality, spec violations, or missing critical integration
- **CONCERN**: Missing tests, partial integration, questionable patterns, or unclear logic
- **PASS**: Correct, tested, well-integrated code

## Important

- Full function bodies for modified functions may be available in the observations section — use them to verify the complete logic, not just the diff hunks
- Related test files (prefixed with ``related_test:``) may be included in observations — check whether existing test assertions still match modified return types, signatures, or behavior. Flag any test that would break due to the changes
- If duplicate test coverage is detected (multiple test files covering the same source), note it in your review
- Focus on actual issues, not style preferences
- If a method signature is added but callers aren't updated, that's PARTIAL integration
- Be specific in reasoning - reference line numbers or function names
- When in doubt, use CONCERN rather than PASS

## Self-Review

After completing your review, add a brief self-assessment:

### SELF_REVIEW
LIMITATIONS: <what context were you missing that affected review quality?>
---

Examples of limitations:
- "Could not see full class to verify no other methods access the modified field"
- "Test file not included in diff - cannot verify coverage claims"
- "Spec file referenced but not provided"


## Feature Requests

If this review tool could be improved to help you do a better job, suggest features:

### FEATURE_REQUESTS
- <suggestion 1>
- <suggestion 2>
---

Examples:
- "Include full file context for modified functions, not just diff hunks"
- "Show callers of modified methods to verify integration"
- "Include test file alongside implementation changes"

Only include this section if you have specific suggestions. Skip if none.
