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

## Code Changes

```diff
diff --git a/expert_build/cli.py b/expert_build/cli.py
index 42beb32..0ac957f 100644
--- a/expert_build/cli.py
+++ b/expert_build/cli.py
@@ -73,6 +73,8 @@ def main():
     prop_p.add_argument("--output", default="proposed-beliefs.md",
                         help="Output file (default: proposed-beliefs.md)")
     prop_p.add_argument("--model", default="claude", help="Model to use (default: claude)")
+    prop_p.add_argument("--parallel", type=int, default=1,
+                        help="Number of parallel LLM calls (default: 1)")
     prop_p.add_argument("--batch-size", type=int, default=5,
                         help="Entries per LLM batch (default: 5)")
     prop_p.add_argument("--entry", action="append",
diff --git a/expert_build/propose.py b/expert_build/propose.py
index 4320712..b7cd699 100644
--- a/expert_build/propose.py
+++ b/expert_build/propose.py
@@ -1,5 +1,6 @@
 """Propose and accept beliefs from entries."""
 
+import asyncio
 import hashlib
 import json
 import re
@@ -9,7 +10,7 @@
 
 from reasons_lib.api import add_node, list_nodes
 
-from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON
+from .llm import check_model_available, extract_json, invoke, RETRY_JSON
 from .prompts import PROPOSE_BELIEFS
 
 PROJECT_DIR = ".expert-build"
@@ -376,49 +377,63 @@ def cmd_propose_beliefs(args):
             f.write(f"**Source:** {source_desc}\n")
             f.write(f"**Model:** {args.model}\n\n")
 
+    async def _process_batch(i, batch_text, semaphore):
+        """Process one batch under concurrency limit. Returns (filtered, paths) or None."""
+        async with semaphore:
+            print(f"  Batch {i + 1}/{len(batches)}...")
+            existing_context = _build_dedup_context(
+                existing_beliefs, batch_paths[i], batch_text,
+                belief_vectors=belief_vectors,
+            )
+            prompt = PROPOSE_BELIEFS.format(entries=batch_text) + existing_context
+            try:
+                result = await invoke(prompt, model=args.model, timeout=600)
+            except Exception as e:
+                print(f"  ERROR: {e}")
+                return None
+
+            beliefs = extract_json(result)
+            if not isinstance(beliefs, list):
+                print("    WARN: response not valid JSON, retrying...", file=sys.stderr)
+                try:
+                    retry_response = await invoke(
+                        prompt + "\n\n" + result + "\n\n" + RETRY_JSON,
+                        model=args.model, timeout=600,
+                    )
+                    beliefs = extract_json(retry_response)
+                except Exception:
+                    pass
+            if not isinstance(beliefs, list):
+                print("    WARN: could not parse beliefs JSON, skipping batch", file=sys.stderr)
+                return None
+
+            filtered = []
+            skipped = 0
+            for b in beliefs:
+                bid = b.get("id", "")
+                if bid in existing_ids:
+                    skipped += 1
+                    continue
+                filtered.append(b)
+            return filtered, batch_paths[i], skipped
+
+    parallel = max(1, getattr(args, "parallel", 1))
+    semaphore = asyncio.Semaphore(parallel)
+
+    async def run_batches():
+        tasks = [_process_batch(i, bt, semaphore) for i, bt in enumerate(batches)]
+        return await asyncio.gather(*tasks)
+
+    batch_results = asyncio.run(run_batches())
+
     total_skipped = 0
     successful_entries = []
-    for i, batch_text in enumerate(batches):
-        print(f"  Batch {i + 1}/{len(batches)}...")
-        existing_context = _build_dedup_context(
-            existing_beliefs, batch_paths[i], batch_text,
-            belief_vectors=belief_vectors,
-        )
-        prompt = PROPOSE_BELIEFS.format(entries=batch_text) + existing_context
-        try:
-            result = invoke_sync(prompt, model=args.model, timeout=600)
-        except Exception as e:
-            print(f"  ERROR: {e}")
+    for result in batch_results:
+        if result is None:
             continue
-
-        # Parse JSON response
-        beliefs = extract_json(result)
-        if not isinstance(beliefs, list):
-            print("    WARN: response not valid JSON, retrying...", file=sys.stderr)
-            try:
-                retry_response = invoke_sync(
-                    prompt + "\n\n" + result + "\n\n" + RETRY_JSON,
-                    model=args.model, timeout=600,
-                )
-                beliefs = extract_json(retry_response)
-            except Exception:
-                pass
-        if not isinstance(beliefs, list):
-            print("    WARN: could not parse beliefs JSON, skipping batch", file=sys.stderr)
-            continue
-
-        # Filter out proposals whose IDs already exist
-        skipped = 0
-        filtered = []
-        for b in beliefs:
-            bid = b.get("id", "")
-            if bid in existing_ids:
-                skipped += 1
-                continue
-            filtered.append(b)
+        filtered, paths, skipped = result
         total_skipped += skipped
 
-        # Write this batch's proposals as markdown for human review
         with output.open("a") as f:
             for b in filtered:
                 bid = b.get("id", "unknown")
@@ -430,8 +445,7 @@ def cmd_propose_beliefs(args):
                 f.write(f"- Source: {source}\n")
                 f.write(f"- Source URL: {source_url or 'none'}\n\n")
 
-        # Record this batch's entries as processed
-        successful_entries.extend(Path(p) for p in batch_paths[i])
+        successful_entries.extend(Path(p) for p in paths)
         _save_processed(processed_path, successful_entries, processed)
 
     if total_skipped:
diff --git a/tests/test_propose.py b/tests/test_propose.py
index ecadd13..e96eced 100644
--- a/tests/test_propose.py
+++ b/tests/test_propose.py
@@ -3,7 +3,7 @@
 import json
 import types
 from pathlib import Path
-from unittest.mock import patch, MagicMock
+from unittest.mock import patch, MagicMock, AsyncMock
 
 import pytest
 
@@ -26,13 +26,14 @@ def work_dir(tmp_path, monkeypatch):
     return wd
 
 
-def make_args(input_dir, output="proposed-beliefs.md", batch_size=2, model="test"):
+def make_args(input_dir, output="proposed-beliefs.md", batch_size=2, model="test", parallel=1):
     return types.SimpleNamespace(
         input_dir=str(input_dir),
         output=output,
         batch_size=batch_size,
         model=model,
         all=False,
+        parallel=parallel,
     )
 
 
@@ -61,7 +62,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         return _json_beliefs((f"belief-from-batch-{call_count}", "A belief."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -85,7 +86,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         return _json_beliefs((f"belief-{call_count}", "A belief."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -110,7 +111,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         )
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=existing), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -137,7 +138,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         return _json_beliefs((f"belief-{call_count}", "A belief."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -168,7 +169,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         return _json_beliefs(("retried-belief", "A belief from retry."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -189,7 +190,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         return '```json\n' + _json_beliefs(("fenced-belief", "A belief.")) + '\n```'
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -213,7 +214,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         return _json_beliefs(("test-belief", "A belief."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -236,7 +237,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         return _json_beliefs(("test-belief", "A belief."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -259,7 +260,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         return _json_beliefs(("test-belief", "A belief."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -279,7 +280,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         return _json_beliefs(("new-belief", "Fresh."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
-         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
          patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
          patch("expert_build.propose._has_embeddings", return_value=False):
         cmd_propose_beliefs(args)
@@ -287,3 +288,30 @@ def invoke_side_effect(prompt, model=None, timeout=None):
     content = output.read_text()
     assert "prior-belief" in content
     assert "new-belief" in content
+
+
+def test_parallel_processes_all_batches(entries_dir, work_dir):
+    """With parallel=2, all batches are processed and results written."""
+    for i in range(6):
+        (entries_dir / f"entry{i}.md").write_text(f"# Entry {i}\nContent {i}")
+
+    output = work_dir / "proposed-beliefs.md"
+    args = make_args(entries_dir, output=str(output), batch_size=2, parallel=2)
+
+    call_count = 0
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        nonlocal call_count
+        call_count += 1
+        return _json_beliefs((f"belief-{call_count}", f"A belief from batch {call_count}."))
+
+    with patch("expert_build.propose.check_model_available", return_value=True), \
+         patch("expert_build.propose.invoke", new_callable=AsyncMock, side_effect=invoke_side_effect), \
+         patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
+         patch("expert_build.propose._has_embeddings", return_value=False):
+        cmd_propose_beliefs(args)
+
+    content = output.read_text()
+    assert call_count == 3
+    assert "belief-1" in content
+    assert "belief-2" in content
+    assert "belief-3" in content

```

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