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_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":

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "ftl_project_expert/cli.py:review_proposals": {
    "function": "review_proposals",
    "file": "ftl_project_expert/cli.py",
    "start_line": 1149,
    "end_line": 1316,
    "source": "@cli.command(\"review-proposals\")\n@click.option(\"--file\", \"proposals_file\", default=\"proposed-beliefs.md\",\n              help=\"Proposals file to review\")\n@click.option(\"--batch-size\", type=int, default=20,\n              help=\"Proposals per LLM batch (default: 20)\")\n@click.option(\"--parallel\", \"max_parallel\", type=int, default=1,\n              help=\"Max concurrent LLM calls (default: 1, try 3 for speed)\")\n@click.pass_context\ndef review_proposals(ctx, proposals_file, batch_size, max_parallel):\n    \"\"\"Filter low-quality belief proposals using LLM review.\n\n    Sends proposals in batches to an LLM along with issue state data and\n    existing beliefs. The LLM judges each proposal against rejection criteria:\n    stale, factually false, meta, duplicate, ephemeral, speculative.\n\n    Pipeline position: propose-beliefs \u2192 review-proposals \u2192 accept-beliefs\n    \"\"\"\n    model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n\n    proposals_path = Path(proposals_file)\n    if not proposals_path.exists():\n        click.echo(f\"Proposals file not found: {proposals_file}\")\n        sys.exit(1)\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    project_dir = _get_project_dir()\n    text = proposals_path.read_text()\n\n    # Load context\n    cached_issues = _load_cached_issues(project_dir)\n    try:\n        network = _load_network()\n        existing_nodes = network.get(\"nodes\", {})\n    except Exception:\n        existing_nodes = {}\n\n    # Parse all proposals\n    proposal_pattern = re.compile(\n        r\"(### \\[?(?:ACCEPT(?:/REJECT)?|REJECT)\\]?)\\s*(\\S+)\\n\"\n        r\"(.+?)\\n\"\n        r\"(- Source: .+?)(?=\\n###|\\n---|\\Z)\",\n        re.DOTALL,\n    )\n\n    matches = list(proposal_pattern.finditer(text))\n    if not matches:\n        click.echo(\"No proposals found in file.\")\n        return\n\n    # Separate already-rejected from reviewable\n    to_review = []\n    already_rejected = 0\n    for match in matches:\n        header = match.group(1)\n        if \"[REJECT]\" in header and \"[ACCEPT\" not in header:\n            already_rejected += 1\n            continue\n        to_review.append({\n            \"match\": match,\n            \"header\": header,\n            \"id\": match.group(2),\n            \"text\": match.group(3).strip(),\n            \"source\": match.group(4).strip().removeprefix(\"- Source: \"),\n        })\n\n    if not to_review:\n        click.echo(\"No proposals to review (all already rejected).\", err=True)\n        return\n\n    click.echo(f\"Reviewing {len(to_review)} proposals ({already_rejected} already rejected)...\", err=True)\n\n    # Build context sections (shared across batches)\n    issue_state = _build_issue_state_section(cached_issues)\n    existing_beliefs = _build_existing_beliefs_section(existing_nodes)\n\n    # Batch and review\n    all_decisions: dict[str, tuple[bool, str | None]] = {}\n    batches = [to_review[i:i + batch_size] for i in range(0, len(to_review), batch_size)]\n\n    prompts = [\n        _REVIEW_PROMPT.format(\n            issue_state=issue_state,\n            existing_beliefs=existing_beliefs,\n            proposals=_build_proposals_section(batch),\n        )\n        for batch in batches\n    ]\n\n    click.echo(f\"  {len(batches)} batch(es) (parallel={max_parallel})...\", err=True)\n\n    if max_parallel > 1 and len(prompts) > 1:\n        results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,\n                                         max_concurrent=max_parallel)\n        for i, r in enumerate(results):\n            if isinstance(r, Exception):\n                click.echo(f\"  ERROR in batch {i + 1}: {r}\", err=True)\n            else:\n                decisions = _parse_review_response(r)\n                all_decisions.update(decisions)\n    else:\n        for i, prompt in enumerate(prompts):\n            click.echo(f\"  Batch {i + 1}/{len(batches)} ({len(batches[i])} proposals)...\", err=True)\n            try:\n                result = invoke_sync(prompt, model=model, timeout=timeout)\n                decisions = _parse_review_response(result)\n                all_decisions.update(decisions)\n            except Exception as e:\n                click.echo(f\"  ERROR in batch {i + 1}: {e}\", err=True)\n                continue\n\n    # Apply decisions\n    kept = 0\n    rejected = 0\n    categories: dict[str, int] = {}\n    replacements: list[tuple[str, str]] = []\n\n    for proposal in to_review:\n        belief_id = proposal[\"id\"]\n        match = proposal[\"match\"]\n        header = proposal[\"header\"]\n\n        decision = all_decisions.get(belief_id)\n        if decision is None:\n            kept += 1\n            continue\n\n        reject, reason = decision\n        if reject:\n            rejected += 1\n            category = reason.split(\":\")[0].strip() if reason else \"unknown\"\n            categories[category] = categories.get(category, 0) + 1\n\n            old_block = match.group(0)\n            new_header = f\"### [REJECT] {belief_id}\"\n            new_block = old_block.replace(\n                f\"{header} {belief_id}\",\n                new_header,\n                1,\n            )\n            source_line = match.group(4).strip()\n            new_block = new_block.replace(\n                source_line,\n                f\"{source_line}\\n- Rejected: {reason}\",\n                1,\n            )\n            replacements.append((old_block, new_block))\n            click.echo(f\"  REJECT {belief_id}: {reason}\", err=True)\n        else:\n            kept += 1\n\n    # Summary\n    click.echo(f\"\\nReviewed {len(to_review)} proposals: {kept} kept, {rejected} rejected\", err=True)\n    if categories:\n        click.echo(\"Rejections by category:\", err=True)\n        for cat, count in sorted(categories.items(), key=lambda x: -x[1]):\n            click.echo(f\"  {cat}: {count}\", err=True)\n\n    if replacements:\n        for old_block, new_block in replacements:\n            text = text.replace(old_block, new_block, 1)\n        proposals_path.write_text(text)\n        click.echo(f\"Updated {proposals_file}\", err=True)\n    else:\n        click.echo(\"No changes needed.\", err=True)"
  },
  "ftl_project_expert/cli.py:_load_network": {
    "function": "_load_network",
    "file": "ftl_project_expert/cli.py",
    "start_line": 1617,
    "end_line": 1628,
    "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        if _has_reasons():\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())"
  },
  "ftl_project_expert/sources/github.py:GitHubSource.get_issue": {
    "function": "get_issue",
    "file": "ftl_project_expert/sources/github.py",
    "start_line": 50,
    "end_line": 63,
    "source": "    def get_issue(self, number: int) -> Issue:\n        \"\"\"Get a single issue with full details and comments.\"\"\"\n        cmd = [\n            \"gh\", \"issue\", \"view\", str(number),\n            \"--repo\", self.repo,\n            \"--json\", \"number,title,url,body,state,labels,assignees,\"\n                      \"milestone,author,createdAt,updatedAt,closedAt,comments\",\n        ]\n        result = subprocess.run(cmd, capture_output=True, text=True)\n        if result.returncode != 0:\n            raise RuntimeError(f\"gh issue view failed: {result.stderr.strip()}\")\n\n        raw = json.loads(result.stdout)\n        return self._normalize(raw)",
    "class_name": "GitHubSource"
  },
  "ftl_project_expert/sources/github.py:GitHubSource.list_prs": {
    "function": "list_prs",
    "file": "ftl_project_expert/sources/github.py",
    "start_line": 82,
    "end_line": 111,
    "source": "    def list_prs(\n        self,\n        state: str = \"open\",\n        limit: int = 100,\n        since: str | None = None,\n    ) -> list[PullRequest]:\n        \"\"\"List pull requests from the repository.\"\"\"\n        # Map issue states to PR states\n        pr_state = state\n        if state == \"closed\":\n            pr_state = \"merged\"\n        cmd = [\n            \"gh\", \"pr\", \"list\",\n            \"--repo\", self.repo,\n            \"--state\", pr_state,\n            \"--json\", \"number,title,url,body,state,labels,author,\"\n                      \"createdAt,updatedAt,mergedAt,mergedBy,\"\n                      \"files,additions,deletions,changedFiles,\"\n                      \"reviews,comments,closingIssuesReferences\",\n            \"--limit\", str(limit),\n        ]\n        if since:\n            cmd.extend([\"--search\", f\"updated:>={since}\"])\n\n        result = subprocess.run(cmd, capture_output=True, text=True)\n        if result.returncode != 0:\n            raise RuntimeError(f\"gh pr list failed: {result.stderr.strip()}\")\n\n        raw_prs = json.loads(result.stdout)\n        return [self._normalize_pr(raw) for raw in raw_prs]",
    "class_name": "GitHubSource"
  },
  "normalize_pr_body": {
    "function": "_normalize_pr",
    "file": "ftl_project_expert/sources/github.py",
    "start_line": 113,
    "end_line": 174,
    "source": "    def _normalize_pr(self, raw: dict) -> PullRequest:\n        \"\"\"Convert gh PR JSON to normalized PullRequest.\"\"\"\n        number = raw.get(\"number\", 0)\n        labels = [l.get(\"name\", \"\") for l in raw.get(\"labels\", [])]\n\n        # Extract linked issues\n        linked_issues = []\n        for ref in raw.get(\"closingIssuesReferences\", []):\n            num = ref.get(\"number\")\n            if num:\n                linked_issues.append(f\"GH-{num}\")\n\n        # Extract files\n        files = [f.get(\"path\", \"\") for f in raw.get(\"files\", [])]\n\n        # Extract reviews\n        reviews = []\n        for r in raw.get(\"reviews\", []):\n            reviews.append({\n                \"author\": r.get(\"author\", {}).get(\"login\", \"\"),\n                \"state\": r.get(\"state\", \"\"),\n                \"body\": r.get(\"body\", \"\"),\n            })\n\n        # Extract comments\n        comments = []\n        for c in raw.get(\"comments\", []):\n            comments.append(IssueComment(\n                author=c.get(\"author\", {}).get(\"login\", \"\"),\n                body=c.get(\"body\", \"\"),\n                created=c.get(\"createdAt\", \"\"),\n                url=c.get(\"url\", \"\"),\n            ))\n\n        merged_by = \"\"\n        mb = raw.get(\"mergedBy\")\n        if mb and isinstance(mb, dict):\n            merged_by = mb.get(\"login\", \"\")\n\n        return PullRequest(\n            id=f\"PR-{number}\",\n            title=raw.get(\"title\", \"\"),\n            url=raw.get(\"url\", \"\"),\n            platform=self.platform,\n            body=raw.get(\"body\", \"\"),\n            state=raw.get(\"state\", \"\").lower(),\n            labels=labels,\n            author=raw.get(\"author\", {}).get(\"login\", \"\"),\n            created=raw.get(\"createdAt\", \"\"),\n            updated=raw.get(\"updatedAt\", \"\"),\n            merged=raw.get(\"mergedAt\", \"\") or \"\",\n            merged_by=merged_by,\n            linked_issues=linked_issues,\n            files=files,\n            additions=raw.get(\"additions\", 0),\n            deletions=raw.get(\"deletions\", 0),\n            changed_files=raw.get(\"changedFiles\", 0),\n            reviews=reviews,\n            comments=comments,\n            comment_count=len(comments),\n            raw=raw,\n        )",
    "class_name": "GitHubSource"
  },
  "pr_to_prompt_text": {
    "error": "No function found at 'to_prompt_text'",
    "file": "ftl_project_expert/sources/github.py"
  },
  "issue_model": {
    "error": "No function found at 'to_prompt_text'",
    "file": "ftl_project_expert/sources/__init__.py"
  },
  "get_issue_github": {
    "function": "get_issue",
    "file": "ftl_project_expert/sources/github.py",
    "start_line": 50,
    "end_line": 63,
    "source": "    def get_issue(self, number: int) -> Issue:\n        \"\"\"Get a single issue with full details and comments.\"\"\"\n        cmd = [\n            \"gh\", \"issue\", \"view\", str(number),\n            \"--repo\", self.repo,\n            \"--json\", \"number,title,url,body,state,labels,assignees,\"\n                      \"milestone,author,createdAt,updatedAt,closedAt,comments\",\n        ]\n        result = subprocess.run(cmd, capture_output=True, text=True)\n        if result.returncode != 0:\n            raise RuntimeError(f\"gh issue view failed: {result.stderr.strip()}\")\n\n        raw = json.loads(result.stdout)\n        return self._normalize(raw)",
    "class_name": "GitHubSource"
  },
  "load_network_body": {
    "function": "_load_network",
    "file": "ftl_project_expert/cli.py",
    "start_line": 1617,
    "end_line": 1628,
    "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        if _has_reasons():\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())"
  },
  "create_entry_body": {
    "function": "_create_entry",
    "file": "ftl_project_expert/cli.py",
    "start_line": 86,
    "end_line": 108,
    "source": "def _create_entry(topic: str, title: str, content: str) -> None:\n    # Add HHMM timestamp so multiple scans on the same day don't collide\n    timestamp = datetime.now().strftime(\"%H%M\")\n    entry_name = f\"{topic}-{timestamp}\"\n    try:\n        result = subprocess.run(\n            [\"entry\", \"create\", entry_name, title, \"--content\", content],\n            capture_output=True, text=True,\n        )\n        if result.returncode == 0:\n            click.echo(f\"Entry: {result.stdout.strip()}\", err=True)\n        else:\n            result = subprocess.run(\n                [\"entry\", \"create\", entry_name, title],\n                input=content,\n                capture_output=True, text=True,\n            )\n            if result.returncode == 0:\n                click.echo(f\"Entry: {result.stdout.strip()}\", err=True)\n            else:\n                click.echo(f\"WARN: entry create failed: {result.stderr.strip()}\", err=True)\n    except FileNotFoundError:\n        click.echo(\"WARN: entry CLI not found. Install with: uv tool install ftl-entry\", err=True)"
  },
  "emit_body": {
    "function": "_emit",
    "file": "ftl_project_expert/cli.py",
    "start_line": 81,
    "end_line": 83,
    "source": "def _emit(ctx, text: str) -> None:\n    if not ctx.obj.get(\"quiet\"):\n        click.echo(text)"
  },
  "has_reasons_body": {
    "function": "_has_reasons",
    "file": "ftl_project_expert/cli.py",
    "start_line": 120,
    "end_line": 121,
    "source": "def _has_reasons() -> bool:\n    return shutil.which(\"reasons\") is not None"
  },
  "get_source_body": {
    "function": "_get_source",
    "file": "ftl_project_expert/cli.py",
    "start_line": 62,
    "end_line": 75,
    "source": "def _get_source(config: dict) -> GitHubSource | GitLabSource | JiraSource:\n    \"\"\"Create the appropriate source from config.\"\"\"\n    platform = config[\"platform\"]\n    if platform == \"github\":\n        return GitHubSource(config[\"repo\"])\n    elif platform == \"gitlab\":\n        return GitLabSource(config[\"repo\"])\n    elif platform == \"jira\":\n        return JiraSource(\n            config[\"project\"],\n            url=config.get(\"jira_url\"),\n        )\n    else:\n        raise ValueError(f\"Unknown platform: {platform}\")"
  },
  "get_project_dir_body": {
    "function": "_get_project_dir",
    "file": "ftl_project_expert/cli.py",
    "start_line": 55,
    "end_line": 56,
    "source": "def _get_project_dir() -> str:\n    return str(Path.cwd() / PROJECT_DIR)"
  },
  "invoke_sync_sig": {
    "function": "invoke_sync",
    "file": "ftl_project_expert/llm.py",
    "start_line": 54,
    "end_line": 56,
    "source": "def invoke_sync(prompt: str, model: str = \"claude\", timeout: int = DEFAULT_TIMEOUT) -> str:\n    \"\"\"Synchronous wrapper around invoke.\"\"\"\n    return asyncio.run(invoke(prompt, model, timeout))"
  },
  "invoke_concurrent_sync_sig": {
    "function": "invoke_concurrent_sync",
    "file": "ftl_project_expert/llm.py",
    "start_line": 82,
    "end_line": 89,
    "source": "def invoke_concurrent_sync(\n    prompts: list[str],\n    model: str = \"claude\",\n    timeout: int = DEFAULT_TIMEOUT,\n    max_concurrent: int = 3,\n) -> list[str | Exception]:\n    \"\"\"Synchronous wrapper around invoke_concurrent.\"\"\"\n    return asyncio.run(invoke_concurrent(prompts, model, timeout, max_concurrent))"
  },
  "fetch_artifacts_callers": {
    "symbol": "get_pr",
    "usages": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 55,
        "text": "def _get_project_dir() -> str:"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 286,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 473,
        "text": "queue = load_queue(_get_project_dir())"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 522,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 761,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1178,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1395,
        "text": "elif ref[\"type\"] == \"pr\" and platform == \"github\" and hasattr(source, \"get_pr\"):"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1396,
        "text": "pr = source.get_pr(ref[\"number\"])"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1512,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2068,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2148,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 65,
        "text": "def get_pr(self, number: int) -> PullRequest:"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/requests/utils.py",
        "line": 761,
        "text": "def get_proxy(key):"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/requests/utils.py",
        "line": 768,
        "text": "no_proxy = get_proxy(\"no_proxy\")"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/urllib3/contrib/pyopenssl.py",
        "line": 403,
        "text": "return self.connection.get_protocol_version_name()  # type: ignore[no-any-return]"
      }
    ],
    "production_usages": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 55,
        "text": "def _get_project_dir() -> str:"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 286,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 473,
        "text": "queue = load_queue(_get_project_dir())"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 522,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 761,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1178,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1395,
        "text": "elif ref[\"type\"] == \"pr\" and platform == \"github\" and hasattr(source, \"get_pr\"):"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1396,
        "text": "pr = source.get_pr(ref[\"number\"])"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1512,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2068,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2148,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 65,
        "text": "def get_pr(self, number: int) -> PullRequest:"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/requests/utils.py",
        "line": 761,
        "text": "def get_proxy(key):"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/requests/utils.py",
        "line": 768,
        "text": "no_proxy = get_proxy(\"no_proxy\")"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/urllib3/contrib/pyopenssl.py",
        "line": 403,
        "text": "return self.connection.get_protocol_version_name()  # type: ignore[no-any-return]"
      }
    ],
    "test_usages": [],
    "production_count": 15,
    "test_count": 0,
    "total_count": 15
  },
  "research_tests": {
    "source_file": "ftl_project_expert/cli.py",
    "test_files": [],
    "test_count": 0
  },
  "pullrequest_class": {
    "error": "Class 'PullRequest' not found in ftl_project_expert/sources/__init__.py",
    "file": "ftl_project_expert/sources/__init__.py"
  },
  "gitlab_get_pr": {
    "symbol": "get_pr",
    "usages": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 55,
        "text": "def _get_project_dir() -> str:"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 286,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 473,
        "text": "queue = load_queue(_get_project_dir())"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 522,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 761,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1178,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1395,
        "text": "elif ref[\"type\"] == \"pr\" and platform == \"github\" and hasattr(source, \"get_pr\"):"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1396,
        "text": "pr = source.get_pr(ref[\"number\"])"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1512,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2068,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2148,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 65,
        "text": "def get_pr(self, number: int) -> PullRequest:"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/requests/utils.py",
        "line": 761,
        "text": "def get_proxy(key):"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/requests/utils.py",
        "line": 768,
        "text": "no_proxy = get_proxy(\"no_proxy\")"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/urllib3/contrib/pyopenssl.py",
        "line": 403,
        "text": "return self.connection.get_protocol_version_name()  # type: ignore[no-any-return]"
      }
    ],
    "production_usages": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 55,
        "text": "def _get_project_dir() -> str:"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 286,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 473,
        "text": "queue = load_queue(_get_project_dir())"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 522,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 761,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1178,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1395,
        "text": "elif ref[\"type\"] == \"pr\" and platform == \"github\" and hasattr(source, \"get_pr\"):"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1396,
        "text": "pr = source.get_pr(ref[\"number\"])"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1512,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2068,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2148,
        "text": "project_dir = _get_project_dir()"
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 65,
        "text": "def get_pr(self, number: int) -> PullRequest:"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/requests/utils.py",
        "line": 761,
        "text": "def get_proxy(key):"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/requests/utils.py",
        "line": 768,
        "text": "no_proxy = get_proxy(\"no_proxy\")"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/urllib3/contrib/pyopenssl.py",
        "line": 403,
        "text": "return self.connection.get_protocol_version_name()  # type: ignore[no-any-return]"
      }
    ],
    "test_usages": [],
    "production_count": 15,
    "test_count": 0,
    "total_count": 15
  },
  "jira_get_issue": {
    "function": "get_issue",
    "file": "ftl_project_expert/sources/jira.py",
    "start_line": 128,
    "end_line": 136,
    "source": "    def get_issue(self, key: str) -> Issue:\n        \"\"\"Get a single issue with full details.\"\"\"\n        data = self._get(f\"issue/{key}\", params={\n            \"fields\": \"summary,description,status,labels,assignee,reporter,\"\n                      \"priority,issuetype,parent,issuelinks,created,updated,\"\n                      \"resolutiondate,comment,fixVersions,subtasks\",\n            \"expand\": \"renderedFields\",\n        })\n        return self._normalize(data)",
    "class_name": "JiraSource"
  }
}
```

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.
