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 8e78e20..848d250 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, 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,
@@ -482,30 +482,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 +549,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 +577,81 @@ 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()
+    explored = 0
 
-    if not check_model_available(model):
-        click.echo(f"Error: Model '{model}' CLI not available", err=True)
-        sys.exit(1)
+    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)
 
-    click.echo(f"Topic: [{topic.kind}] {topic.target}", err=True)
-    click.echo(f"  {topic.title}", err=True)
-    click.echo(err=True)
+        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)
 
-    # Fetch issue details if it's an issue/epic topic
+        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 +667,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 +806,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 +852,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 +1086,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 +1164,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 +1764,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 +1868,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))

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "ftl_project_expert/cli.py:explore": {
    "function": "explore",
    "file": "ftl_project_expert/cli.py",
    "start_line": 485,
    "end_line": 556,
    "source": "@cli.command()\n@click.option(\"--skip\", \"do_skip\", is_flag=True, default=False,\n              help=\"Skip the next topic\")\n@click.option(\"--pick\", \"pick_index\", type=str, default=None,\n              help=\"Pick topic(s) by index \u2014 single (3) or comma-separated (1,3,8)\")\n@click.option(\"--loop\", \"loop_max\", type=int, default=None,\n              help=\"Continuously explore up to N topics\")\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 explore(ctx, do_skip, pick_index, loop_max, max_parallel):\n    \"\"\"Explore the next topic in the queue.\"\"\"\n    project_dir = _get_project_dir()\n\n    if loop_max is not None:\n        if do_skip or pick_index:\n            click.echo(\"Error: --loop cannot be combined with --skip or --pick\", err=True)\n            sys.exit(1)\n        _explore_loop(ctx, project_dir, loop_max, max_parallel)\n        return\n\n    if do_skip:\n        if skip_topic(0, project_dir):\n            queue = load_queue(project_dir)\n            pending = [t for t in queue if t.status == \"pending\"]\n            if pending:\n                click.echo(f\"Skipped. Next: [{pending[0].kind}] {pending[0].target}\")\n            else:\n                click.echo(\"Skipped. No more pending topics.\")\n        else:\n            click.echo(\"Nothing to skip.\")\n        return\n\n    if pick_index is not None:\n        try:\n            indices = [int(x.strip()) for x in pick_index.split(\",\")]\n        except ValueError:\n            click.echo(f\"Error: --pick must be integers, got: {pick_index}\", err=True)\n            sys.exit(1)\n        if len(indices) > 1:\n            topic_list = pop_multiple(indices, project_dir)\n        else:\n            topic_list = [pop_at(indices[0], project_dir)]\n    else:\n        topic_list = [pop_next(project_dir)]\n\n    valid_topics = [(i, t) for i, t in zip(\n        indices if pick_index is not None else [0],\n        topic_list,\n    ) if t is not None]\n\n    if not valid_topics:\n        click.echo(\"No pending topics. Run `project-expert scan` to discover topics.\")\n        return\n\n    invalid_count = len(topic_list) - len(valid_topics)\n    if invalid_count:\n        click.echo(f\"Warning: {invalid_count} index(es) out of bounds, skipped.\", err=True)\n\n    for seq, (idx, topic) in enumerate(valid_topics):\n        if len(valid_topics) > 1:\n            click.echo(f\"\\n{'=' * 40}\", err=True)\n            click.echo(f\"[{seq + 1}/{len(valid_topics)}] Topic #{idx}\", err=True)\n            click.echo(f\"{'=' * 40}\", err=True)\n\n        _run_topic(ctx, topic)\n\n    remaining = pending_count(project_dir)\n    if remaining:\n        click.echo(f\"\\n{remaining} topic(s) remaining.\", err=True)\n    else:\n        click.echo(\"\\nNo more topics. Exploration complete.\", err=True)"
  },
  "ftl_project_expert/cli.py:_explore_loop": {
    "function": "_explore_loop",
    "file": "ftl_project_expert/cli.py",
    "start_line": 559,
    "end_line": 584,
    "source": "def _explore_loop(ctx, project_dir, max_topics, max_parallel=1):\n    \"\"\"Continuously explore topics up to max_topics.\"\"\"\n    if max_parallel > 1:\n        _explore_loop_parallel(ctx, project_dir, max_topics, max_parallel)\n        return\n\n    explored = 0\n    while explored < max_topics:\n        topic = pop_next(project_dir)\n        if topic is None:\n            if explored == 0:\n                click.echo(\"No pending topics. Run `project-expert scan` to discover topics.\")\n            else:\n                click.echo(f\"\\nNo more topics after {explored} exploration(s).\", err=True)\n            return\n\n        explored += 1\n        remaining = pending_count(project_dir)\n        click.echo(f\"\\n{'=' * 40}\", err=True)\n        click.echo(f\"[{explored}/{max_topics}] ({remaining} remaining in queue)\", err=True)\n        click.echo(f\"{'=' * 40}\", err=True)\n\n        _run_topic(ctx, topic)\n\n    remaining = pending_count(project_dir)\n    click.echo(f\"\\nExplored {explored} topic(s). {remaining} remaining.\", err=True)"
  },
  "ftl_project_expert/cli.py:_build_topic_prompt": {
    "function": "_build_topic_prompt",
    "file": "ftl_project_expert/cli.py",
    "start_line": 633,
    "end_line": 696,
    "source": "def _build_topic_prompt(topic: Topic, config: dict | None, project_dir: str) -> str:\n    \"\"\"Build the explore prompt for a topic without invoking the LLM.\"\"\"\n    issue_text = \"\"\n    context_text = \"\"\n\n    if topic.kind in (\"issue\", \"epic\") and config:\n        try:\n            source = _get_source(config)\n            issue_id = topic.target\n            if config[\"platform\"] == \"github\":\n                num = re.search(r\"\\d+\", issue_id)\n                if num:\n                    issue = source.get_issue(int(num.group()))\n                    issue_text = issue.to_prompt_text()\n\n                    cached = _load_cached_issues(project_dir)\n                    related_ids = issue.children + issue.linked\n                    if issue.parent:\n                        related_ids.append(issue.parent)\n                    context_parts = []\n                    for rid in related_ids:\n                        if rid in cached:\n                            ci = cached[rid]\n                            context_parts.append(\n                                f\"### {ci['id']}: {ci['title']}\\n\"\n                                f\"- State: {ci['state']}\\n\"\n                                f\"- Labels: {', '.join(ci.get('labels', []))}\"\n                            )\n                    if context_parts:\n                        context_text = \"\\n\\n\".join(context_parts)\n\n            elif config[\"platform\"] == \"gitlab\":\n                num = re.search(r\"\\d+\", issue_id)\n                if num:\n                    issue = source.get_issue(int(num.group()))\n                    issue_text = issue.to_prompt_text()\n\n            elif config[\"platform\"] == \"jira\":\n                issue = source.get_issue(issue_id)\n                issue_text = issue.to_prompt_text()\n\n        except Exception as e:\n            click.echo(f\"Warning: Could not fetch issue {topic.target}: {e}\", err=True)\n\n    if not issue_text:\n        cached = _load_cached_issues(project_dir)\n        if topic.target in cached:\n            ci = cached[topic.target]\n            issue_text = (\n                f\"## {ci['id']}: {ci['title']}\\n\"\n                f\"- State: {ci['state']}\\n\"\n                f\"- Labels: {', '.join(ci.get('labels', []))}\\n\"\n                f\"- Assignees: {', '.join(ci.get('assignees', []))}\\n\"\n            )\n            if ci.get(\"body\"):\n                issue_text += f\"\\n### Description\\n\\n{ci['body']}\"\n        else:\n            issue_text = f\"## {topic.target}\\n\\n{topic.title}\"\n\n    return build_explore_prompt(\n        issue_text=issue_text,\n        context_text=context_text or None,\n        question=topic.title,\n    )"
  },
  "ftl_project_expert/cli.py:_run_topic": {
    "function": "_run_topic",
    "file": "ftl_project_expert/cli.py",
    "start_line": 699,
    "end_line": 728,
    "source": "def _run_topic(ctx, topic: Topic):\n    \"\"\"Explore a single topic.\"\"\"\n    model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n    project_dir = _get_project_dir()\n    config = _load_config()\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    click.echo(f\"Topic: [{topic.kind}] {topic.target}\", err=True)\n    click.echo(f\"  {topic.title}\", err=True)\n    click.echo(err=True)\n\n    prompt = _build_topic_prompt(topic, config, project_dir)\n\n    click.echo(f\"Exploring with {model}...\", err=True)\n    try:\n        result = asyncio.run(invoke(prompt, model, timeout=timeout))\n    except Exception as e:\n        click.echo(f\"Error: {e}\", err=True)\n        return\n\n    safe_target = re.sub(r\"[^a-zA-Z0-9_-]\", \"-\", topic.target)[:80]\n    _create_entry(f\"explore-{safe_target}\", f\"Explore: {topic.target}\", result)\n    _enqueue_topics(result, source=f\"explore:{topic.target}\", project_dir=project_dir)\n    _report_beliefs(result)\n\n    _emit(ctx, result)"
  },
  "ftl_project_expert/cli.py:propose_beliefs": {
    "function": "propose_beliefs",
    "file": "ftl_project_expert/cli.py",
    "start_line": 808,
    "end_line": 903,
    "source": "@cli.command(\"propose-beliefs\")\n@click.option(\"--batch-size\", type=int, default=5, help=\"Entries per LLM batch\")\n@click.option(\"--output\", default=\"proposed-beliefs.md\", help=\"Output file\")\n@click.option(\"--all\", \"process_all\", is_flag=True, help=\"Re-process all entries\")\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.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 propose_beliefs(ctx, batch_size, output, process_all, auto_accept, since, max_parallel):\n    \"\"\"Extract candidate beliefs from entries for human review.\"\"\"\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    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\n    entries = sorted(input_dir.rglob(\"*.md\"))\n    if not entries:\n        click.echo(\"No .md files found.\")\n        return\n\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    click.echo(f\"Reading {len(entries)} entries...\")\n\n    # Batch entries\n    batches = []\n    current_batch = []\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        if len(current_batch) >= batch_size:\n            batches.append(\"\\n\\n\".join(current_batch))\n            current_batch = []\n    if current_batch:\n        batches.append(\"\\n\\n\".join(current_batch))\n\n    click.echo(f\"Processing {len(batches)} batches (parallel={max_parallel})...\")\n\n    prompts = [PROPOSE_BELIEFS_PROJECT.format(entries=bt) for bt in batches]\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        all_proposals = []\n        for i, r in enumerate(results):\n            if isinstance(r, Exception):\n                click.echo(f\"  Batch {i + 1} ERROR: {r}\")\n            else:\n                all_proposals.append(r)\n    else:\n        all_proposals = []\n        for i, prompt in enumerate(prompts):\n            click.echo(f\"  Batch {i + 1}/{len(prompts)}...\")\n            try:\n                result = invoke_sync(prompt, model=model, timeout=timeout)\n                all_proposals.append(result)\n            except Exception as e:\n                click.echo(f\"  ERROR: {e}\")\n                continue\n\n    if auto_accept:\n        _auto_accept_proposals(all_proposals)\n        return\n\n    # Write proposals\n    output_path = Path(output)\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: `project-expert accept-beliefs`\\n\\n---\\n\\n\")\n        f.write(f\"**Generated:** {date.today().isoformat()}\\n\")\n        f.write(f\"**Model:** {model}\\n\\n\")\n        for proposal in all_proposals:\n            f.write(proposal)\n            f.write(\"\\n\\n\")\n\n    click.echo(f\"\\nWrote {output_path}\")\n    click.echo(\"Review the file, then run: project-expert accept-beliefs\")"
  },
  "ftl_project_expert/cli.py:_parse_review_response": {
    "function": "_parse_review_response",
    "file": "ftl_project_expert/cli.py",
    "start_line": 1074,
    "end_line": 1088,
    "source": "def _parse_review_response(response: str) -> dict[str, tuple[bool, str | None]]:\n    \"\"\"Parse LLM review response into accept/reject decisions.\"\"\"\n    decisions = {}\n    for line in response.splitlines():\n        line = line.strip()\n        if line.startswith(\"ACCEPT \"):\n            belief_id = line[7:].strip()\n            decisions[belief_id] = (False, None)\n        elif line.startswith(\"REJECT \"):\n            rest = line[7:].strip()\n            parts = rest.split(\" \", 1)\n            belief_id = parts[0]\n            reason = parts[1] if len(parts) > 1 else \"rejected by review\"\n            decisions[belief_id] = (True, reason)\n    return decisions"
  },
  "ftl_project_expert/cli.py:review_proposals": {
    "function": "review_proposals",
    "file": "ftl_project_expert/cli.py",
    "start_line": 1091,
    "end_line": 1258,
    "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:update": {
    "function": "update",
    "file": "ftl_project_expert/cli.py",
    "start_line": 1761,
    "end_line": 1984,
    "source": "@cli.command(\"update\")\n@click.option(\"--since\", default=None,\n              help=\"Fetch issues updated since date (YYYY-MM-DD)\")\n@click.option(\"--since-last\", is_flag=True, default=False,\n              help=\"Resume from last update checkpoint\")\n@click.option(\"--state\", \"-s\", default=None,\n              help=\"Issue state filter (default: all)\")\n@click.option(\"--limit\", default=100, type=int,\n              help=\"Max issues per page (default: 100)\")\n@click.option(\"--all-pages\", is_flag=True, default=False,\n              help=\"Auto-paginate through all matching issues\")\n@click.option(\"--max-explore\", type=int, default=None,\n              help=\"Max topics to explore (default: all pending)\")\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 update(ctx, since, since_last, state, limit, all_pages, max_explore, max_parallel):\n    \"\"\"Automated update pipeline: scan, explore, extract beliefs, derive, summarize.\n\n    Pulls all issues/PRs updated since a date, explores them, proposes and\n    accepts beliefs, derives logical consequences, and generates a summary.\n\n    Examples:\n        project-expert update --since 2026-04-01\n        project-expert update --since-last\n        project-expert update --since \"2026-04-01\" --all-pages\n    \"\"\"\n    config = _load_config()\n    if not config:\n        click.echo(\"Not initialized. Run: project-expert init <platform> <target>\")\n        sys.exit(1)\n\n    model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n    project_dir = _get_project_dir()\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    # Resolve since date\n    if since_last:\n        since = _load_update_checkpoint(project_dir)\n        if not since:\n            click.echo(\"No checkpoint found. Use --since <date> for the first run.\", err=True)\n            sys.exit(1)\n        click.echo(f\"Resuming from checkpoint: since {since}\", err=True)\n    elif not since:\n        click.echo(\"Error: --since <date> or --since-last required.\", err=True)\n        sys.exit(1)\n\n    # Default to \"all\" states for update (we want to see everything that changed)\n    if state is None:\n        state = \"all\"\n\n    # Snapshot pre-run belief IDs\n    try:\n        network = _load_network()\n        pre_run_ids = set(network.get(\"nodes\", {}).keys())\n    except Exception:\n        pre_run_ids = set()\n\n    errors = []\n    project_name = config.get(\"repo\", config.get(\"project\", \"unknown\"))\n    source = _get_source(config)\n\n    # --- Step 1: Fetch and scan issues ---\n    click.echo(f\"\\n{'=' * 40}\", err=True)\n    click.echo(\"Step 1: Scanning issues/PRs\", err=True)\n    click.echo(f\"{'=' * 40}\", err=True)\n\n    try:\n        label_list = None\n        total_issues = 0\n\n        if all_pages:\n            current_page = 1\n            while True:\n                click.echo(f\"  Fetching page {current_page}...\", err=True)\n                issues = _fetch_issues(source, config, state=state, labels=label_list,\n                                       limit=limit, page=current_page, since=since)\n                if not issues:\n                    break\n                total_issues += len(issues)\n                _run_scan_step(ctx, config, source, issues, project_name,\n                               state, limit, current_page, project_dir, model, timeout)\n                if len(issues) < limit:\n                    break\n                current_page += 1\n        else:\n            issues = _fetch_issues(source, config, state=state, labels=label_list,\n                                   limit=limit, page=1, since=since)\n            if issues:\n                total_issues = len(issues)\n                _run_scan_step(ctx, config, source, issues, project_name,\n                               state, limit, 1, project_dir, model, timeout)\n\n        if total_issues == 0:\n            click.echo(\"No issues found since {since}.\", err=True)\n        else:\n            click.echo(f\"Scanned {total_issues} issue(s).\", err=True)\n\n    except SystemExit as e:\n        if e.code and e.code != 0:\n            errors.append(f\"scan exited with code {e.code}\")\n            click.echo(f\"WARN: scan failed (exit {e.code}), continuing...\", err=True)\n    except Exception as e:\n        errors.append(f\"scan: {e}\")\n        click.echo(f\"WARN: scan failed: {e}, continuing...\", err=True)\n\n    # --- Step 2: Explore all pending topics ---\n    click.echo(f\"\\n{'=' * 40}\", err=True)\n    click.echo(\"Step 2: Exploring topics\", err=True)\n    click.echo(f\"{'=' * 40}\", err=True)\n\n    try:\n        topic_limit = max_explore or 99\n        _explore_loop(ctx, project_dir, topic_limit, max_parallel)\n\n    except SystemExit as e:\n        if e.code and e.code != 0:\n            errors.append(f\"explore exited with code {e.code}\")\n            click.echo(f\"WARN: explore failed (exit {e.code}), continuing...\", err=True)\n    except Exception as e:\n        errors.append(f\"explore: {e}\")\n        click.echo(f\"WARN: explore failed: {e}, continuing...\", err=True)\n\n    # --- Step 3: Propose beliefs ---\n    click.echo(f\"\\n{'=' * 40}\", err=True)\n    click.echo(\"Step 3: Proposing beliefs\", err=True)\n    click.echo(f\"{'=' * 40}\", err=True)\n\n    try:\n        ctx.invoke(propose_beliefs, since=since, max_parallel=max_parallel)\n    except SystemExit as e:\n        if e.code and e.code != 0:\n            errors.append(f\"propose-beliefs exited with code {e.code}\")\n            click.echo(f\"WARN: propose-beliefs failed (exit {e.code}), continuing...\", err=True)\n    except Exception as e:\n        errors.append(f\"propose-beliefs: {e}\")\n        click.echo(f\"WARN: propose-beliefs failed: {e}, continuing...\", err=True)\n\n    # --- Step 4: Review proposals ---\n    click.echo(f\"\\n{'=' * 40}\", err=True)\n    click.echo(\"Step 4: Reviewing proposals\", err=True)\n    click.echo(f\"{'=' * 40}\", err=True)\n\n    try:\n        ctx.invoke(review_proposals, max_parallel=max_parallel)\n    except SystemExit as e:\n        if e.code and e.code != 0:\n            errors.append(f\"review-proposals exited with code {e.code}\")\n            click.echo(f\"WARN: review-proposals failed (exit {e.code}), continuing...\", err=True)\n    except Exception as e:\n        errors.append(f\"review-proposals: {e}\")\n        click.echo(f\"WARN: review-proposals failed: {e}, continuing...\", err=True)\n\n    # --- Step 5: Accept beliefs ---\n    click.echo(f\"\\n{'=' * 40}\", err=True)\n    click.echo(\"Step 5: Accepting beliefs\", err=True)\n    click.echo(f\"{'=' * 40}\", err=True)\n\n    try:\n        ctx.invoke(accept_beliefs)\n    except SystemExit as e:\n        if e.code and e.code != 0:\n            errors.append(f\"accept-beliefs exited with code {e.code}\")\n            click.echo(f\"WARN: accept-beliefs failed (exit {e.code}), continuing...\", err=True)\n    except Exception as e:\n        errors.append(f\"accept-beliefs: {e}\")\n        click.echo(f\"WARN: accept-beliefs failed: {e}, continuing...\", err=True)\n\n    # --- Step 6: Derive (exhaust) ---\n    click.echo(f\"\\n{'=' * 40}\", err=True)\n    click.echo(\"Step 6: Deriving logical consequences\", err=True)\n    click.echo(f\"{'=' * 40}\", err=True)\n\n    try:\n        ctx.invoke(derive, exhaust=True)\n    except SystemExit as e:\n        if e.code and e.code != 0:\n            errors.append(f\"derive exited with code {e.code}\")\n            click.echo(f\"WARN: derive failed (exit {e.code}), continuing...\", err=True)\n    except Exception as e:\n        errors.append(f\"derive: {e}\")\n        click.echo(f\"WARN: derive failed: {e}, continuing...\", err=True)\n\n    # --- Step 7: Summary ---\n    click.echo(f\"\\n{'=' * 40}\", err=True)\n    click.echo(\"Step 7: Generating summary\", err=True)\n    click.echo(f\"{'=' * 40}\", err=True)\n\n    try:\n        ctx.invoke(summary)\n    except SystemExit as e:\n        if e.code and e.code != 0:\n            errors.append(f\"summary exited with code {e.code}\")\n            click.echo(f\"WARN: summary failed (exit {e.code}), continuing...\", err=True)\n    except Exception as e:\n        errors.append(f\"summary: {e}\")",
    "truncated": true,
    "total_lines": 224
  },
  "ftl_project_expert/llm.py:invoke": {
    "function": "invoke",
    "file": "ftl_project_expert/llm.py",
    "start_line": 23,
    "end_line": 51,
    "source": "async def invoke(prompt: str, model: str = \"claude\", timeout: int = DEFAULT_TIMEOUT) -> str:\n    \"\"\"Invoke model via CLI, piping prompt through stdin.\"\"\"\n    if model not in MODEL_COMMANDS:\n        raise ValueError(f\"Unknown model: {model}. Available: {list(MODEL_COMMANDS.keys())}\")\n\n    cmd = MODEL_COMMANDS[model]\n    env = {k: v for k, v in os.environ.items() if k != \"CLAUDECODE\"}\n\n    proc = await asyncio.create_subprocess_exec(\n        *cmd,\n        stdin=asyncio.subprocess.PIPE,\n        stdout=asyncio.subprocess.PIPE,\n        stderr=asyncio.subprocess.PIPE,\n        env=env,\n    )\n\n    try:\n        stdout, stderr = await asyncio.wait_for(\n            proc.communicate(prompt.encode()),\n            timeout=timeout,\n        )\n    except TimeoutError:\n        proc.kill()\n        raise TimeoutError(f\"Model {model} timed out after {timeout}s\") from None\n\n    if proc.returncode != 0:\n        raise RuntimeError(f\"Model {model} failed: {stderr.decode()}\")\n\n    return stdout.decode()"
  },
  "ftl_project_expert/llm.py:invoke_concurrent_sync": {
    "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))"
  },
  "invoke_body": {
    "function": "invoke",
    "file": "ftl_project_expert/llm.py",
    "start_line": 23,
    "end_line": 51,
    "source": "async def invoke(prompt: str, model: str = \"claude\", timeout: int = DEFAULT_TIMEOUT) -> str:\n    \"\"\"Invoke model via CLI, piping prompt through stdin.\"\"\"\n    if model not in MODEL_COMMANDS:\n        raise ValueError(f\"Unknown model: {model}. Available: {list(MODEL_COMMANDS.keys())}\")\n\n    cmd = MODEL_COMMANDS[model]\n    env = {k: v for k, v in os.environ.items() if k != \"CLAUDECODE\"}\n\n    proc = await asyncio.create_subprocess_exec(\n        *cmd,\n        stdin=asyncio.subprocess.PIPE,\n        stdout=asyncio.subprocess.PIPE,\n        stderr=asyncio.subprocess.PIPE,\n        env=env,\n    )\n\n    try:\n        stdout, stderr = await asyncio.wait_for(\n            proc.communicate(prompt.encode()),\n            timeout=timeout,\n        )\n    except TimeoutError:\n        proc.kill()\n        raise TimeoutError(f\"Model {model} timed out after {timeout}s\") from None\n\n    if proc.returncode != 0:\n        raise RuntimeError(f\"Model {model} failed: {stderr.decode()}\")\n\n    return stdout.decode()"
  },
  "invoke_raises": {
    "function": "invoke",
    "file": "ftl_project_expert/llm.py",
    "explicit_raises": [
      "ValueError",
      "RuntimeError",
      "TimeoutError"
    ],
    "calls": [
      "create_subprocess_exec",
      "TimeoutError",
      "ValueError",
      "encode",
      "keys",
      "decode",
      "communicate",
      "RuntimeError",
      "list",
      "wait_for",
      "items",
      "kill"
    ]
  },
  "create_entry_body": {
    "function": "_create_entry",
    "file": "ftl_project_expert/cli.py",
    "start_line": 85,
    "end_line": 107,
    "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)"
  },
  "pop_next_body": {
    "function": "pop_next",
    "file": "ftl_project_expert/topics.py",
    "start_line": 66,
    "end_line": 73,
    "source": "def pop_next(project_dir: str | None = None) -> Topic | None:\n    queue = load_queue(project_dir)\n    for topic in queue:\n        if topic.status == \"pending\":\n            topic.status = \"done\"\n            save_queue(queue, project_dir)\n            return topic\n    return None"
  },
  "check_model_available_body": {
    "function": "check_model_available",
    "file": "ftl_project_expert/llm.py",
    "start_line": 15,
    "end_line": 20,
    "source": "def check_model_available(model: str) -> bool:\n    \"\"\"Check if a model's CLI is available.\"\"\"\n    if model not in MODEL_COMMANDS:\n        return False\n    cmd = MODEL_COMMANDS[model][0]\n    return shutil.which(cmd) is not None"
  },
  "explore_loop_callers": {
    "symbol": "_explore_loop",
    "production_callers": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 503,
        "text": "_explore_loop(ctx, project_dir, loop_max, max_parallel)",
        "context_function": "explore",
        "context_snippet": "   500:         if do_skip or pick_index:\n   501:             click.echo(\"Error: --loop cannot be combined with --skip or --pick\", err=True)\n   502:             sys.exit(1)\n>> 503:         _explore_loop(ctx, project_dir, loop_max, max_parallel)\n   504:         return\n   505: \n   506:     if do_skip:"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 559,
        "text": "def _explore_loop(ctx, project_dir, max_topics, max_parallel=1):",
        "context_function": "explore",
        "context_snippet": "   556:         click.echo(\"\\nNo more topics. Exploration complete.\", err=True)\n   557: \n   558: \n>> 559: def _explore_loop(ctx, project_dir, max_topics, max_parallel=1):\n   560:     \"\"\"Continuously explore topics up to max_topics.\"\"\"\n   561:     if max_parallel > 1:\n   562:         _explore_loop_parallel(ctx, project_dir, max_topics, max_parallel)"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 562,
        "text": "_explore_loop_parallel(ctx, project_dir, max_topics, max_parallel)",
        "context_function": "_explore_loop",
        "context_snippet": "   559: def _explore_loop(ctx, project_dir, max_topics, max_parallel=1):\n   560:     \"\"\"Continuously explore topics up to max_topics.\"\"\"\n   561:     if max_parallel > 1:\n>> 562:         _explore_loop_parallel(ctx, project_dir, max_topics, max_parallel)\n   563:         return\n   564: \n   565:     explored = 0"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 587,
        "text": "def _explore_loop_parallel(ctx, project_dir, max_topics, max_parallel):",
        "context_function": "_explore_loop",
        "context_snippet": "   584:     click.echo(f\"\\nExplored {explored} topic(s). {remaining} remaining.\", err=True)\n   585: \n   586: \n>> 587: def _explore_loop_parallel(ctx, project_dir, max_topics, max_parallel):\n   588:     \"\"\"Explore topics in parallel batches.\"\"\"\n   589:     model = ctx.obj[\"model\"]\n   590:     timeout = ctx.obj[\"timeout\"]"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1878,
        "text": "_explore_loop(ctx, project_dir, topic_limit, max_parallel)",
        "context_function": "update",
        "context_snippet": "   1875: \n   1876:     try:\n   1877:         topic_limit = max_explore or 99\n>> 1878:         _explore_loop(ctx, project_dir, topic_limit, max_parallel)\n   1879: \n   1880:     except SystemExit as e:\n   1881:         if e.code and e.code != 0:"
      }
    ],
    "test_callers": [],
    "production_count": 5,
    "test_count": 0,
    "total_count": 5
  },
  "run_topic_callers": {
    "symbol": "_run_topic",
    "production_callers": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 550,
        "text": "_run_topic(ctx, topic)",
        "context_function": "explore",
        "context_snippet": "   547:             click.echo(f\"[{seq + 1}/{len(valid_topics)}] Topic #{idx}\", err=True)\n   548:             click.echo(f\"{'=' * 40}\", err=True)\n   549: \n>> 550:         _run_topic(ctx, topic)\n   551: \n   552:     remaining = pending_count(project_dir)\n   553:     if remaining:"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 581,
        "text": "_run_topic(ctx, topic)",
        "context_function": "_explore_loop",
        "context_snippet": "   578:         click.echo(f\"[{explored}/{max_topics}] ({remaining} remaining in queue)\", err=True)\n   579:         click.echo(f\"{'=' * 40}\", err=True)\n   580: \n>> 581:         _run_topic(ctx, topic)\n   582: \n   583:     remaining = pending_count(project_dir)\n   584:     click.echo(f\"\\nExplored {explored} topic(s). {remaining} remaining.\", err=True)"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 699,
        "text": "def _run_topic(ctx, topic: Topic):",
        "context_function": "_build_topic_prompt",
        "context_snippet": "   696:     )\n   697: \n   698: \n>> 699: def _run_topic(ctx, topic: Topic):\n   700:     \"\"\"Explore a single topic.\"\"\"\n   701:     model = ctx.obj[\"model\"]\n   702:     timeout = ctx.obj[\"timeout\"]"
      }
    ],
    "test_callers": [],
    "production_count": 3,
    "test_count": 0,
    "total_count": 3
  },
  "invoke_concurrent_sync_callers": {
    "symbol": "invoke_concurrent_sync",
    "production_callers": [
      {
        "file": "ftl_project_expert/llm.py",
        "line": 82,
        "text": "def invoke_concurrent_sync(",
        "context_function": "_guarded",
        "context_snippet": "   79:     )\n   80: \n   81: \n>> 82: def invoke_concurrent_sync(\n   83:     prompts: list[str],\n   84:     model: str = \"claude\",\n   85:     timeout: int = DEFAULT_TIMEOUT,"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 15,
        "text": "from .llm import check_model_available, invoke, invoke_concurrent, invoke_concurrent_sync, invoke_sy",
        "context_function": null,
        "context_snippet": "   12: \n   13: import click\n   14: \n>> 15: from .llm import check_model_available, invoke, invoke_concurrent, invoke_concurrent_sync, invoke_sync\n   16: from .prompts import (\n   17:     PROPOSE_BELIEFS_PROJECT,\n   18:     build_explore_prompt,"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 615,
        "text": "results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,",
        "context_function": "_explore_loop_parallel",
        "context_snippet": "   612:             click.echo(f\"  [{t.kind}] {t.target}: {t.title}\", err=True)\n   613: \n   614:         prompts = [_build_topic_prompt(t, config, project_dir) for t in batch_topics]\n>> 615:         results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,\n   616:                                          max_concurrent=max_parallel)\n   617: \n   618:         for topic, result in zip(batch_topics, results):"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 867,
        "text": "results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,",
        "context_function": "propose_beliefs",
        "context_snippet": "   864:     prompts = [PROPOSE_BELIEFS_PROJECT.format(entries=bt) for bt in batches]\n   865: \n   866:     if max_parallel > 1 and len(prompts) > 1:\n>> 867:         results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,\n   868:                                          max_concurrent=max_parallel)\n   869:         all_proposals = []\n   870:         for i, r in enumerate(results):"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1186,
        "text": "results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,",
        "context_function": "review_proposals",
        "context_snippet": "   1183:     click.echo(f\"  {len(batches)} batch(es) (parallel={max_parallel})...\", err=True)\n   1184: \n   1185:     if max_parallel > 1 and len(prompts) > 1:\n>> 1186:         results = invoke_concurrent_sync(prompts, model=model, timeout=timeout,\n   1187:                                          max_concurrent=max_parallel)\n   1188:         for i, r in enumerate(results):\n   1189:             if isinstance(r, Exception):"
      }
    ],
    "test_callers": [],
    "production_count": 5,
    "test_count": 0,
    "total_count": 5
  },
  "cli_tests": {
    "source_file": "ftl_project_expert/cli.py",
    "test_files": [],
    "test_count": 0
  },
  "llm_tests": {
    "source_file": "ftl_project_expert/llm.py",
    "test_files": [],
    "test_count": 0
  },
  "enqueue_topics_body": {
    "function": "_enqueue_topics",
    "file": "ftl_project_expert/cli.py",
    "start_line": 110,
    "end_line": 116,
    "source": "def _enqueue_topics(response: str, source: str, project_dir: str | None = None) -> None:\n    new_topics = parse_topics_from_response(response, source=source)\n    if new_topics:\n        added = add_topics(new_topics, project_dir)\n        if added:\n            total = pending_count(project_dir)\n            click.echo(f\"Queued {added} new topic(s) ({total} pending)\", err=True)"
  },
  "emit_body": {
    "function": "_emit",
    "file": "ftl_project_expert/cli.py",
    "start_line": 80,
    "end_line": 82,
    "source": "def _emit(ctx, text: str) -> None:\n    if not ctx.obj.get(\"quiet\"):\n        click.echo(text)"
  }
}
```

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.
