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

## Code Changes

```diff
diff --git a/ftl_project_expert/cli.py b/ftl_project_expert/cli.py
index 8e78e20..f2f5511 100644
--- a/ftl_project_expert/cli.py
+++ b/ftl_project_expert/cli.py
@@ -5,21 +5,21 @@ import json
 import os
 import re
 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_sync
+from .llm import check_model_available, invoke, invoke_concurrent_sync, invoke_sync
 from .prompts import (
     PROPOSE_BELIEFS_PROJECT,
     build_explore_prompt,
     build_scan_prompt,
     build_summary_prompt,
 )
 from .sources import GitHubSource, GitLabSource, Issue, JiraSource
 from .topics import (
     Topic,
     add_topics,
@@ -263,22 +263,24 @@ def init(platform, target, domain, jira_url):
 @click.option("--labels", "-l", default=None,
               help="Comma-separated labels to filter by")
 @click.option("--limit", default=100, type=int,
               help="Max issues per page (default: 100)")
 @click.option("--page", default=1, type=int,
               help="Page number for pagination (default: 1)")
 @click.option("--all-pages", is_flag=True, default=False,
               help="Auto-paginate through all issues (uses --limit as page size)")
 @click.option("--jql", default=None,
               help="Custom JQL query (Jira only)")
+@click.option("--per-issue", is_flag=True, default=False,
+              help="Queue each issue as a topic for individual exploration (no LLM)")
 @click.pass_context
-def scan(ctx, state, labels, limit, page, all_pages, jql):
+def scan(ctx, state, labels, limit, page, all_pages, jql, per_issue):
     """Scan project issues and create an overview."""
     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"]
     project_dir = _get_project_dir()
 
@@ -291,71 +293,90 @@ def scan(ctx, state, labels, limit, page, all_pages, jql):
 
     # Set default state per platform
     if state is None:
         if config["platform"] == "gitlab":
             state = "opened"
         elif config["platform"] == "jira":
             state = "open"  # maps to statusCategory != "Done" in JQL
         else:
             state = "open"
 
+    if per_issue:
+        all_pages = True
+
     if all_pages:
         current_page = 1
         total_scanned = 0
         while True:
             click.echo(f"\n{'=' * 40}", err=True)
             click.echo(f"Page {current_page}", err=True)
             click.echo(f"{'=' * 40}", err=True)
             count = _scan_page(
                 ctx, config, source, model, timeout, project_dir,
-                state, label_list, limit, current_page, jql,
+                state, label_list, limit, current_page, jql, per_issue,
             )
             if count == 0:
                 if total_scanned == 0:
                     click.echo("No issues found.")
                 else:
                     click.echo(f"\nDone. Scanned {total_scanned} issues across {current_page - 1} pages.", err=True)
                 break
             total_scanned += count
             if count < limit:
                 click.echo(f"\nDone. Scanned {total_scanned} issues across {current_page} pages.", err=True)
                 break
             current_page += 1
     else:
         _scan_page(
             ctx, config, source, model, timeout, project_dir,
-            state, label_list, limit, page, jql,
+            state, label_list, limit, page, jql, per_issue,
         )
 
 
 def _scan_page(ctx, config, source, model, timeout, project_dir,
-               state, label_list, limit, page, jql):
+               state, label_list, limit, page, jql, per_issue=False):
     """Scan a single page of issues. Returns the number of issues fetched."""
     project_name = config.get("repo", config.get("project", "unknown"))
     click.echo(f"Scanning {project_name} (page {page})...", err=True)
 
     try:
         if config["platform"] == "jira":
             issues = source.list_issues(jql=jql, state=state, labels=label_list, limit=limit, page=page)
         elif config["platform"] == "gitlab":
             issues = source.list_issues(state=state, labels=label_list, limit=limit, page=page)
         else:
             issues = source.list_issues(state=state, labels=label_list, limit=limit)
     except Exception as e:
         click.echo(f"Error fetching issues: {e}", err=True)
         sys.exit(1)
 
     if not issues:
         return 0
 
     click.echo(f"Fetched {len(issues)} issues", err=True)
 
+    if per_issue:
+        from .topics import Topic, add_topics
+        topics = [
+            Topic(
+                title=issue.title,
+                kind="issue",
+                target=issue.id,
+                source=f"scan:{project_name}",
+            )
+            for issue in issues
+        ]
+        added = add_topics(topics, project_dir)
+        _cache_issues(issues, project_dir)
+        click.echo(f"Queued {added} topic(s) from {len(issues)} issues", err=True)
+        return len(issues)
+
     # Fetch PRs for platforms that support them
     prs = []
     if config["platform"] in ("github", "gitlab") and hasattr(source, "list_prs"):
         try:
             prs = source.list_prs(state=state or "open", limit=limit)
             if prs:
                 click.echo(f"Fetched {len(prs)} pull requests", err=True)
         except Exception as e:
             click.echo(f"Warning: Could not fetch PRs: {e}", err=True)
 
@@ -482,30 +503,32 @@ def topics(show_all):
 # --- explore ---
 
 
 @cli.command()
 @click.option("--skip", "do_skip", is_flag=True, default=False,
               help="Skip the next topic")
 @click.option("--pick", "pick_index", type=str, default=None,
               help="Pick topic(s) by index — single (3) or comma-separated (1,3,8)")
 @click.option("--loop", "loop_max", type=int, default=None,
               help="Continuously explore up to N topics")
+@click.option("--parallel", "max_parallel", type=int, default=1,
+              help="Max concurrent LLM calls (default: 1, try 3 for speed)")
 @click.pass_context
-def explore(ctx, do_skip, pick_index, loop_max):
+def explore(ctx, do_skip, pick_index, loop_max, max_parallel):
     """Explore the next topic in the queue."""
     project_dir = _get_project_dir()
 
     if loop_max is not None:
         if do_skip or pick_index:
             click.echo("Error: --loop cannot be combined with --skip or --pick", err=True)
             sys.exit(1)
-        _explore_loop(ctx, project_dir, loop_max)
+        _explore_loop(ctx, project_dir, loop_max, max_parallel)
         return
 
     if do_skip:
         if skip_topic(0, project_dir):
             queue = load_queue(project_dir)
             pending = [t for t in queue if t.status == "pending"]
             if pending:
                 click.echo(f"Skipped. Next: [{pending[0].kind}] {pending[0].target}")
             else:
                 click.echo("Skipped. No more pending topics.")
@@ -547,22 +570,26 @@ def explore(ctx, do_skip, pick_index, loop_max):
 
         _run_topic(ctx, topic)
 
     remaining = pending_count(project_dir)
     if remaining:
         click.echo(f"\n{remaining} topic(s) remaining.", err=True)
     else:
         click.echo("\nNo more topics. Exploration complete.", err=True)
 
 
-def _explore_loop(ctx, project_dir, max_topics):
+def _explore_loop(ctx, project_dir, max_topics, max_parallel=1):
     """Continuously explore topics up to max_topics."""
+    if max_parallel > 1:
+        _explore_loop_parallel(ctx, project_dir, max_topics, max_parallel)
+        return
+
     explored = 0
     while explored < max_topics:
         topic = pop_next(project_dir)
         if topic is None:
             if explored == 0:
                 click.echo("No pending topics. Run `project-expert scan` to discover topics.")
             else:
                 click.echo(f"\nNo more topics after {explored} exploration(s).", err=True)
             return
 
@@ -571,52 +598,86 @@ def _explore_loop(ctx, project_dir, max_topics):
         click.echo(f"\n{'=' * 40}", err=True)
         click.echo(f"[{explored}/{max_topics}] ({remaining} remaining in queue)", err=True)
         click.echo(f"{'=' * 40}", err=True)
 
         _run_topic(ctx, topic)
 
     remaining = pending_count(project_dir)
     click.echo(f"\nExplored {explored} topic(s). {remaining} remaining.", err=True)
 
 
-def _run_topic(ctx, topic: Topic):
-    """Explore a single topic."""
+def _explore_loop_parallel(ctx, project_dir, max_topics, max_parallel):
+    """Explore topics in parallel batches."""
     model = ctx.obj["model"]
     timeout = ctx.obj["timeout"]
-    project_dir = _get_project_dir()
     config = _load_config()
 
     if not check_model_available(model):
         click.echo(f"Error: Model '{model}' CLI not available", err=True)
         sys.exit(1)
 
-    click.echo(f"Topic: [{topic.kind}] {topic.target}", err=True)
-    click.echo(f"  {topic.title}", err=True)
-    click.echo(err=True)
+    explored = 0
+
+    while explored < max_topics:
+        batch_size = min(max_parallel, max_topics - explored)
+        batch_topics = []
+        for _ in range(batch_size):
+            topic = pop_next(project_dir)
+            if topic is None:
+                break
+            batch_topics.append(topic)
 
-    # Fetch issue details if it's an issue/epic topic
+        if not batch_topics:
+            if explored == 0:
+                click.echo("No pending topics. Run `project-expert scan` to discover topics.")
+            else:
+                click.echo(f"\nNo more topics after {explored} exploration(s).", err=True)
+            return
+
+        click.echo(f"\nExploring {len(batch_topics)} topic(s) in parallel...", err=True)
+        for t in batch_topics:
+            click.echo(f"  [{t.kind}] {t.target}: {t.title}", err=True)
+
+        prompts = [_build_topic_prompt(t, config, project_dir) for t in batch_topics]
+        results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,
+                                         max_concurrent=max_parallel)
+
+        for topic, result in zip(batch_topics, results):
+            explored += 1
+            if isinstance(result, Exception):
+                click.echo(f"  ERROR [{topic.target}]: {result}", err=True)
+                continue
+            safe_target = re.sub(r"[^a-zA-Z0-9_-]", "-", topic.target)[:80]
+            _create_entry(f"explore-{safe_target}", f"Explore: {topic.target}", result)
+            _enqueue_topics(result, source=f"explore:{topic.target}", project_dir=project_dir)
+            _report_beliefs(result)
+            _emit(ctx, result)
+
+    remaining = pending_count(project_dir)
+    click.echo(f"\nExplored {explored} topic(s). {remaining} remaining.", err=True)
+
+
+def _build_topic_prompt(topic: Topic, config: dict | None, project_dir: str) -> str:
+    """Build the explore prompt for a topic without invoking the LLM."""
     issue_text = ""
     context_text = ""
 
     if topic.kind in ("issue", "epic") and config:
         try:
             source = _get_source(config)
-            # Extract issue number/key from target
             issue_id = topic.target
             if config["platform"] == "github":
-                # Target might be "GH-123" or just "123"
                 num = re.search(r"\d+", issue_id)
                 if num:
                     issue = source.get_issue(int(num.group()))
                     issue_text = issue.to_prompt_text()
 
-                    # Get related issues from cache
                     cached = _load_cached_issues(project_dir)
                     related_ids = issue.children + issue.linked
                     if issue.parent:
                         related_ids.append(issue.parent)
                     context_parts = []
                     for rid in related_ids:
                         if rid in cached:
                             ci = cached[rid]
                             context_parts.append(
                                 f"### {ci['id']}: {ci['title']}\n"
@@ -632,42 +693,59 @@ def _run_topic(ctx, topic: Topic):
                     issue = source.get_issue(int(num.group()))
                     issue_text = issue.to_prompt_text()
 
             elif config["platform"] == "jira":
                 issue = source.get_issue(issue_id)
                 issue_text = issue.to_prompt_text()
 
         except Exception as e:
             click.echo(f"Warning: Could not fetch issue {topic.target}: {e}", err=True)
 
-    # If we couldn't fetch, use cached data or the title as context
     if not issue_text:
         cached = _load_cached_issues(project_dir)
         if topic.target in cached:
             ci = cached[topic.target]
             issue_text = (
                 f"## {ci['id']}: {ci['title']}\n"
                 f"- State: {ci['state']}\n"
                 f"- Labels: {', '.join(ci.get('labels', []))}\n"
                 f"- Assignees: {', '.join(ci.get('assignees', []))}\n"
             )
             if ci.get("body"):
                 issue_text += f"\n### Description\n\n{ci['body']}"
         else:
             issue_text = f"## {topic.target}\n\n{topic.title}"
 
-    prompt = build_explore_prompt(
+    return build_explore_prompt(
         issue_text=issue_text,
         context_text=context_text or None,
         question=topic.title,
     )
 
+
+def _run_topic(ctx, topic: Topic):
+    """Explore a single topic."""
+    model = ctx.obj["model"]
+    timeout = ctx.obj["timeout"]
+    project_dir = _get_project_dir()
+    config = _load_config()
+
+    if not check_model_available(model):
+        click.echo(f"Error: Model '{model}' CLI not available", err=True)
+        sys.exit(1)
+
+    click.echo(f"Topic: [{topic.kind}] {topic.target}", err=True)
+    click.echo(f"  {topic.title}", err=True)
+    click.echo(err=True)
+
+    prompt = _build_topic_prompt(topic, config, project_dir)
+
     click.echo(f"Exploring with {model}...", err=True)
     try:
         result = asyncio.run(invoke(prompt, model, timeout=timeout))
     except Exception as e:
         click.echo(f"Error: {e}", err=True)
         return
 
     safe_target = re.sub(r"[^a-zA-Z0-9_-]", "-", topic.target)[:80]
     _create_entry(f"explore-{safe_target}", f"Explore: {topic.target}", result)
     _enqueue_topics(result, source=f"explore:{topic.target}", project_dir=project_dir)
@@ -754,22 +832,24 @@ def _entry_date(path: Path) -> str | None:
 
 
 @cli.command("propose-beliefs")
 @click.option("--batch-size", type=int, default=5, help="Entries per LLM batch")
 @click.option("--output", default="proposed-beliefs.md", help="Output file")
 @click.option("--all", "process_all", is_flag=True, help="Re-process all entries")
 @click.option("--auto", "auto_accept", is_flag=True, default=False,
               help="Automatically accept all proposed beliefs (no review step)")
 @click.option("--since", default=None,
               help="Only process entries from this date onward (YYYY-MM-DD)")
+@click.option("--parallel", "max_parallel", type=int, default=1,
+              help="Max concurrent LLM calls (default: 1, try 3 for speed)")
 @click.pass_context
-def propose_beliefs(ctx, batch_size, output, process_all, auto_accept, since):
+def propose_beliefs(ctx, batch_size, output, process_all, auto_accept, since, max_parallel):
     """Extract candidate beliefs from entries for human review."""
     model = ctx.obj["model"]
     timeout = ctx.obj["timeout"]
 
     if not check_model_available(model):
         click.echo(f"Error: Model '{model}' CLI not available", err=True)
         sys.exit(1)
 
     input_dir = Path("entries")
     if not input_dir.exists():
@@ -798,32 +878,43 @@ def propose_beliefs(ctx, batch_size, output, process_all, auto_accept, since):
         content = entry_path.read_text()
         if len(content) > 10000:
             content = content[:10000] + "\n[Truncated]"
         current_batch.append(f"--- FILE: {entry_path} ---\n{content}")
         if len(current_batch) >= batch_size:
             batches.append("\n\n".join(current_batch))
             current_batch = []
     if current_batch:
         batches.append("\n\n".join(current_batch))
 
-    click.echo(f"Processing {len(batches)} batches...")
+    click.echo(f"Processing {len(batches)} batches (parallel={max_parallel})...")
 
-    all_proposals = []
-    for i, batch_text in enumerate(batches):
-        click.echo(f"  Batch {i + 1}/{len(batches)}...")
-        prompt = PROPOSE_BELIEFS_PROJECT.format(entries=batch_text)
-        try:
-            result = invoke_sync(prompt, model=model, timeout=timeout)
-            all_proposals.append(result)
-        except Exception as e:
-            click.echo(f"  ERROR: {e}")
-            continue
+    prompts = [PROPOSE_BELIEFS_PROJECT.format(entries=bt) for bt in batches]
+
+    if max_parallel > 1 and len(prompts) > 1:
+        results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,
+                                         max_concurrent=max_parallel)
+        all_proposals = []
+        for i, r in enumerate(results):
+            if isinstance(r, Exception):
+                click.echo(f"  Batch {i + 1} ERROR: {r}")
+            else:
+                all_proposals.append(r)
+    else:
+        all_proposals = []
+        for i, prompt in enumerate(prompts):
+            click.echo(f"  Batch {i + 1}/{len(prompts)}...")
+            try:
+                result = invoke_sync(prompt, model=model, timeout=timeout)
+                all_proposals.append(result)
+            except Exception as e:
+                click.echo(f"  ERROR: {e}")
+                continue
 
     if auto_accept:
         _auto_accept_proposals(all_proposals)
         return
 
     # Write proposals
     output_path = Path(output)
     with output_path.open("w") as f:
         f.write("# Proposed Beliefs\n\n")
         f.write("Edit each entry: change `[ACCEPT/REJECT]` to `[ACCEPT]` or `[REJECT]`.\n")
@@ -1021,22 +1112,24 @@ def _parse_review_response(response: str) -> dict[str, tuple[bool, str | None]]:
             reason = parts[1] if len(parts) > 1 else "rejected by review"
             decisions[belief_id] = (True, reason)
     return decisions
 
 
 @cli.command("review-proposals")
 @click.option("--file", "proposals_file", default="proposed-beliefs.md",
               help="Proposals file to review")
 @click.option("--batch-size", type=int, default=20,
               help="Proposals per LLM batch (default: 20)")
+@click.option("--parallel", "max_parallel", type=int, default=1,
+              help="Max concurrent LLM calls (default: 1, try 3 for speed)")
 @click.pass_context
-def review_proposals(ctx, proposals_file, batch_size):
+def review_proposals(ctx, proposals_file, batch_size, max_parallel):
     """Filter low-quality belief proposals using LLM review.
 
     Sends proposals in batches to an LLM along with issue state data and
     existing beliefs. The LLM judges each proposal against rejection criteria:
     stale, factually false, meta, duplicate, ephemeral, speculative.
 
     Pipeline position: propose-beliefs → review-proposals → accept-beliefs
     """
     model = ctx.obj["model"]
     timeout = ctx.obj["timeout"]
@@ -1097,37 +1190,50 @@ def review_proposals(ctx, proposals_file, batch_size):
     click.echo(f"Reviewing {len(to_review)} proposals ({already_rejected} already rejected)...", err=True)
 
     # Build context sections (shared across batches)
     issue_state = _build_issue_state_section(cached_issues)
     existing_beliefs = _build_existing_beliefs_section(existing_nodes)
 
     # Batch and review
     all_decisions: dict[str, tuple[bool, str | None]] = {}
     batches = [to_review[i:i + batch_size] for i in range(0, len(to_review), batch_size)]
 
-    for i, batch in enumerate(batches):
-        click.echo(f"  Batch {i + 1}/{len(batches)} ({len(batch)} proposals)...", err=True)
-
-        proposals_section = _build_proposals_section(batch)
-        prompt = _REVIEW_PROMPT.format(
+    prompts = [
+        _REVIEW_PROMPT.format(
             issue_state=issue_state,
             existing_beliefs=existing_beliefs,
-            proposals=proposals_section,
+            proposals=_build_proposals_section(batch),
         )
+        for batch in batches
+    ]
 
-        try:
-            result = invoke_sync(prompt, model=model, timeout=timeout)
-            decisions = _parse_review_response(result)
-            all_decisions.update(decisions)
-        except Exception as e:
-            click.echo(f"  ERROR in batch {i + 1}: {e}", err=True)
-            continue
+    click.echo(f"  {len(batches)} batch(es) (parallel={max_parallel})...", err=True)
+
+    if max_parallel > 1 and len(prompts) > 1:
+        results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,
+                                         max_concurrent=max_parallel)
+        for i, r in enumerate(results):
+            if isinstance(r, Exception):
+                click.echo(f"  ERROR in batch {i + 1}: {r}", err=True)
+            else:
+                decisions = _parse_review_response(r)
+                all_decisions.update(decisions)
+    else:
+        for i, prompt in enumerate(prompts):
+            click.echo(f"  Batch {i + 1}/{len(batches)} ({len(batches[i])} proposals)...", err=True)
+            try:
+                result = invoke_sync(prompt, model=model, timeout=timeout)
+                decisions = _parse_review_response(result)
+                all_decisions.update(decisions)
+            except Exception as e:
+                click.echo(f"  ERROR in batch {i + 1}: {e}", err=True)
+                continue
 
     # Apply decisions
     kept = 0
     rejected = 0
     categories: dict[str, int] = {}
     replacements: list[tuple[str, str]] = []
 
     for proposal in to_review:
         belief_id = proposal["id"]
         match = proposal["match"]
@@ -1684,22 +1790,24 @@ def _load_update_checkpoint(project_dir: str) -> str | None:
 @click.option("--since-last", is_flag=True, default=False,
               help="Resume from last update checkpoint")
 @click.option("--state", "-s", default=None,
               help="Issue state filter (default: all)")
 @click.option("--limit", default=100, type=int,
               help="Max issues per page (default: 100)")
 @click.option("--all-pages", is_flag=True, default=False,
               help="Auto-paginate through all matching issues")
 @click.option("--max-explore", type=int, default=None,
               help="Max topics to explore (default: all pending)")
+@click.option("--parallel", "max_parallel", type=int, default=1,
+              help="Max concurrent LLM calls (default: 1, try 3 for speed)")
 @click.pass_context
-def update(ctx, since, since_last, state, limit, all_pages, max_explore):
+def update(ctx, since, since_last, state, limit, all_pages, max_explore, max_parallel):
     """Automated update pipeline: scan, explore, extract beliefs, derive, summarize.
 
     Pulls all issues/PRs updated since a date, explores them, proposes and
     accepts beliefs, derives logical consequences, and generates a summary.
 
     Examples:
         project-expert update --since 2026-04-01
         project-expert update --since-last
         project-expert update --since "2026-04-01" --all-pages
     """
@@ -1786,65 +1894,52 @@ def update(ctx, since, since_last, state, limit, all_pages, max_explore):
         errors.append(f"scan: {e}")
         click.echo(f"WARN: scan failed: {e}, continuing...", err=True)
 
     # --- Step 2: Explore all pending topics ---
     click.echo(f"\n{'=' * 40}", err=True)
     click.echo("Step 2: Exploring topics", err=True)
     click.echo(f"{'=' * 40}", err=True)
 
     try:
         topic_limit = max_explore or 99
-        explored = 0
-        while explored < topic_limit:
-            topic = pop_next(project_dir)
-            if topic is None:
-                break
-            explored += 1
-            remaining = pending_count(project_dir)
-            click.echo(f"\n  [{explored}] ({remaining} remaining)", err=True)
-            _run_topic(ctx, topic)
-
-        if explored == 0:
-            click.echo("No topics to explore.", err=True)
-        else:
-            click.echo(f"Explored {explored} topic(s).", err=True)
+        _explore_loop(ctx, project_dir, topic_limit, max_parallel)
 
     except SystemExit as e:
         if e.code and e.code != 0:
             errors.append(f"explore exited with code {e.code}")
             click.echo(f"WARN: explore failed (exit {e.code}), continuing...", err=True)
     except Exception as e:
         errors.append(f"explore: {e}")
         click.echo(f"WARN: explore failed: {e}, continuing...", err=True)
 
     # --- Step 3: Propose beliefs ---
     click.echo(f"\n{'=' * 40}", err=True)
     click.echo("Step 3: Proposing beliefs", err=True)
     click.echo(f"{'=' * 40}", err=True)
 
     try:
-        ctx.invoke(propose_beliefs, since=since)
+        ctx.invoke(propose_beliefs, since=since, max_parallel=max_parallel)
     except SystemExit as e:
         if e.code and e.code != 0:
             errors.append(f"propose-beliefs exited with code {e.code}")
             click.echo(f"WARN: propose-beliefs failed (exit {e.code}), continuing...", err=True)
     except Exception as e:
         errors.append(f"propose-beliefs: {e}")
         click.echo(f"WARN: propose-beliefs failed: {e}, continuing...", err=True)
 
     # --- Step 4: Review proposals ---
     click.echo(f"\n{'=' * 40}", err=True)
     click.echo("Step 4: Reviewing proposals", err=True)
     click.echo(f"{'=' * 40}", err=True)
 
     try:
-        ctx.invoke(review_proposals)
+        ctx.invoke(review_proposals, max_parallel=max_parallel)
     except SystemExit as e:
         if e.code and e.code != 0:
             errors.append(f"review-proposals exited with code {e.code}")
             click.echo(f"WARN: review-proposals failed (exit {e.code}), continuing...", err=True)
     except Exception as e:
         errors.append(f"review-proposals: {e}")
         click.echo(f"WARN: review-proposals failed: {e}, continuing...", err=True)
 
     # --- Step 5: Accept beliefs ---
     click.echo(f"\n{'=' * 40}", err=True)
diff --git a/ftl_project_expert/llm.py b/ftl_project_expert/llm.py
index d4b9319..1064631 100644
--- a/ftl_project_expert/llm.py
+++ b/ftl_project_expert/llm.py
@@ -47,10 +47,43 @@ async def invoke(prompt: str, model: str = "claude", timeout: int = DEFAULT_TIME
 
     if proc.returncode != 0:
         raise RuntimeError(f"Model {model} failed: {stderr.decode()}")
 
     return stdout.decode()
 
 
 def invoke_sync(prompt: str, model: str = "claude", timeout: int = DEFAULT_TIMEOUT) -> str:
     """Synchronous wrapper around invoke."""
     return asyncio.run(invoke(prompt, model, timeout))
+
+
+async def invoke_concurrent(
+    prompts: list[str],
+    model: str = "claude",
+    timeout: int = DEFAULT_TIMEOUT,
+    max_concurrent: int = 3,
+) -> list[str | Exception]:
+    """Invoke model on multiple prompts concurrently.
+
+    Returns a list in the same order as prompts. Each element is either
+    the model's response string or the Exception that occurred.
+    """
+    sem = asyncio.Semaphore(max_concurrent)
+
+    async def _guarded(prompt: str) -> str:
+        async with sem:
+            return await invoke(prompt, model, timeout)
+
+    return await asyncio.gather(
+        *[_guarded(p) for p in prompts],
+        return_exceptions=True,
+    )
+
+
+def invoke_concurrent_sync(
+    prompts: list[str],
+    model: str = "claude",
+    timeout: int = DEFAULT_TIMEOUT,
+    max_concurrent: int = 3,
+) -> list[str | Exception]:
+    """Synchronous wrapper around invoke_concurrent."""
+    return asyncio.run(invoke_concurrent(prompts, model, timeout, max_concurrent))

```

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