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/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..60f567e 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,59 @@ def cmd_propose_beliefs(args):
             f.write(f"**Source:** {source_desc}\n")
             f.write(f"**Model:** {args.model}\n\n")
 
-    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}")
-            continue
-
-        # Parse JSON response
-        beliefs = extract_json(result)
-        if not isinstance(beliefs, list):
-            print("    WARN: response not valid JSON, retrying...", file=sys.stderr)
+    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:
-                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)
+                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 = []
+            for b in beliefs:
+                bid = b.get("id", "")
+                if bid in existing_ids:
+                    continue
+                filtered.append(b)
+            return filtered, batch_paths[i]
+
+    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())
+
+    successful_entries = []
+    for result in batch_results:
+        if result is None:
             continue
+        filtered, paths = result
 
-        # 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)
-        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,13 +441,9 @@ 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:
-        print(f"  Filtered {total_skipped} already-accepted beliefs")
-
     print(f"\n{'Appended to' if appended else 'Wrote'} {output}")
 
     print("Review the file, mark entries as [ACCEPT] or [REJECT], then run:")
diff --git a/tests/test_propose.py b/tests/test_propose.py
index ecadd13..40ed0f7 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)

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "invoke_migration": {
    "old_name": "invoke_sync",
    "old_name_usages": [
      {
        "file": "tests/test_exam.py",
        "line": 80,
        "text": "with patch(\"expert_build.exam.invoke_sync\","
      },
      {
        "file": "tests/test_exam.py",
        "line": 91,
        "text": "with patch(\"expert_build.exam.invoke_sync\", return_value=\"Still no format\"):"
      },
      {
        "file": "tests/test_exam.py",
        "line": 110,
        "text": "with patch(\"expert_build.exam.invoke_sync\","
      },
      {
        "file": "tests/test_exam.py",
        "line": 119,
        "text": "with patch(\"expert_build.exam.invoke_sync\","
      },
      {
        "file": "tests/test_exam.py",
        "line": 136,
        "text": "with patch(\"expert_build.exam.invoke_sync\", side_effect=side_effect):"
      },
      {
        "file": "tests/test_exam.py",
        "line": 144,
        "text": "with patch(\"expert_build.exam.invoke_sync\", return_value=\"No JSON at all\"):"
      },
      {
        "file": "tests/test_exam.py",
        "line": 152,
        "text": "with patch(\"expert_build.exam.invoke_sync\","
      },
      {
        "file": "tests/test_exam.py",
        "line": 169,
        "text": "with patch(\"expert_build.exam.invoke_sync\", side_effect=side_effect):"
      },
      {
        "file": "tests/test_exam.py",
        "line": 178,
        "text": "with patch(\"expert_build.exam.invoke_sync\","
      },
      {
        "file": "tests/test_coverage.py",
        "line": 55,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_coverage.py",
        "line": 72,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_coverage.py",
        "line": 93,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_coverage.py",
        "line": 109,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_coverage.py",
        "line": 125,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_pipeline.py",
        "line": 186,
        "text": "patch(\"expert_build.llm.invoke_sync\", return_value=\"No proposals\"), \\"
      },
      {
        "file": "tests/test_pipeline.py",
        "line": 202,
        "text": "patch(\"expert_build.llm.invoke_sync\", return_value=\"proposal text\"), \\"
      },
      {
        "file": "expert_build/exam.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/exam.py",
        "line": 114,
        "text": "retry_response = invoke_sync("
      },
      {
        "file": "expert_build/exam.py",
        "line": 132,
        "text": "response = invoke_sync(prompt, model=model, timeout=60)"
      },
      {
        "file": "expert_build/exam.py",
        "line": 143,
        "text": "retry_response = invoke_sync("
      },
      {
        "file": "expert_build/exam.py",
        "line": 202,
        "text": "response = invoke_sync(prompt, model=args.model, timeout=120)"
      },
      {
        "file": "expert_build/llm.py",
        "line": 154,
        "text": "def invoke_sync(prompt: str, model: str = \"claude\", timeout: int = DEFAULT_TIMEOUT) -> str:"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 115,
        "text": "result = invoke_sync(prompt, model=args.model, timeout=120)"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 118,
        "text": "retry_response = invoke_sync("
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 10,
        "text": "from .llm import check_model_available, invoke_sync"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 193,
        "text": "response = invoke_sync(prompt, model=args.model, timeout=args.timeout)"
      }
    ],
    "old_name_count": 27,
    "stale_references": [
      {
        "file": "expert_build/exam.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/exam.py",
        "line": 114,
        "text": "retry_response = invoke_sync("
      },
      {
        "file": "expert_build/exam.py",
        "line": 132,
        "text": "response = invoke_sync(prompt, model=model, timeout=60)"
      },
      {
        "file": "expert_build/exam.py",
        "line": 143,
        "text": "retry_response = invoke_sync("
      },
      {
        "file": "expert_build/exam.py",
        "line": 202,
        "text": "response = invoke_sync(prompt, model=args.model, timeout=120)"
      },
      {
        "file": "expert_build/llm.py",
        "line": 154,
        "text": "def invoke_sync(prompt: str, model: str = \"claude\", timeout: int = DEFAULT_TIMEOUT) -> str:"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 115,
        "text": "result = invoke_sync(prompt, model=args.model, timeout=120)"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 118,
        "text": "retry_response = invoke_sync("
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 10,
        "text": "from .llm import check_model_available, invoke_sync"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 193,
        "text": "response = invoke_sync(prompt, model=args.model, timeout=args.timeout)"
      }
    ],
    "stale_count": 11,
    "migration_complete": false,
    "new_name": "invoke",
    "new_name_usages": [
      {
        "file": "tests/test_propose.py",
        "line": 65,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 89,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 114,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 141,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 172,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 193,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 217,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 240,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 263,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 283,
        "text": "patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 51,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Topic Title\\nSummary"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 63,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Module\\nSummary\"):"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 76,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\") as "
      },
      {
        "file": "tests/test_summarize.py",
        "line": 92,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\") as "
      },
      {
        "file": "tests/test_summarize.py",
        "line": 108,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\") as "
      },
      {
        "file": "tests/test_summarize.py",
        "line": 120,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock) as mock_llm:"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 133,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Module\\nSummary\") as"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 145,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Doc Title\\nSummary\")"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 159,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Big Doc\\nSummary\"):"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 172,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Big\\nSummary\") as mo"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 185,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Small\\nSummary\"):"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 201,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock) as mock_llm:"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 212,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\"):"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 228,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\") as "
      },
      {
        "file": "tests/test_summarize.py",
        "line": 242,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\") as "
      },
      {
        "file": "tests/test_summarize.py",
        "line": 255,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock) as mock_llm:"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 271,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\"):"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 287,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Page\\nSummary\"):"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 302,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\"):"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 317,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## My Title\\nDetailed s"
      }
    ],
    "new_name_count": 42
  },
  "invoke_body": {
    "function": "invoke",
    "file": "expert_build/llm.py",
    "start_line": 117,
    "end_line": 151,
    "source": "async def invoke(prompt: str, model: str = \"claude\", timeout: int = DEFAULT_TIMEOUT) -> str:\n    \"\"\"Invoke model via CLI, piping prompt through stdin.\n\n    Uses --output-format json to capture token/cost data.\n    Accumulated stats available via get_cost_summary().\n    \"\"\"\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\n    # Remove CLAUDECODE env var to allow nested claude invocation\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 _parse_cli_json(stdout.decode(), model)"
  },
  "invoke_sync_body": {
    "function": "invoke_sync",
    "file": "expert_build/llm.py",
    "start_line": 154,
    "end_line": 156,
    "source": "def invoke_sync(prompt: str, model: str = \"claude\", timeout: int = DEFAULT_TIMEOUT) -> str:\n    \"\"\"Synchronous wrapper for invoke.\"\"\"\n    return asyncio.run(invoke(prompt, model, timeout))"
  },
  "invoke_raises": {
    "function": "invoke",
    "file": "expert_build/llm.py",
    "explicit_raises": [
      "TimeoutError",
      "RuntimeError",
      "ValueError"
    ],
    "calls": [
      "_parse_cli_json",
      "items",
      "wait_for",
      "kill",
      "TimeoutError",
      "decode",
      "communicate",
      "RuntimeError",
      "keys",
      "create_subprocess_exec",
      "ValueError",
      "encode",
      "list"
    ]
  },
  "cmd_propose_beliefs_body": {
    "function": "cmd_propose_beliefs",
    "file": "expert_build/propose.py",
    "start_line": 262,
    "end_line": 450,
    "source": "def cmd_propose_beliefs(args):\n    \"\"\"Extract candidate beliefs from entries for human review.\"\"\"\n    from .caffeinate import hold as _caffeinate\n    _caffeinate()\n    input_dir = Path(args.input_dir)\n    if not input_dir.exists():\n        print(f\"Entries directory not found: {input_dir}\")\n        sys.exit(1)\n\n    if not check_model_available(args.model):\n        print(f\"Model not available: {args.model}\")\n        sys.exit(1)\n\n    # Collect entries\n    if hasattr(args, 'entry') and args.entry:\n        entries = [Path(p) for p in args.entry]\n    else:\n        entries = sorted(input_dir.rglob(\"*.md\"))\n\n    if not entries:\n        print(f\"No .md files found.\")\n        return\n\n    # Filter out already-processed entries (unless --all or --entry)\n    processed_path = Path(PROJECT_DIR) / \"proposed-entries.json\"\n    processed = _load_processed(processed_path)\n    process_all = getattr(args, 'all', False)\n    has_entry_flag = hasattr(args, 'entry') and args.entry\n\n    if not process_all and not has_entry_flag:\n        total = len(entries)\n        entries = _filter_unprocessed(entries, processed)\n        skipped = total - len(entries)\n        if skipped:\n            print(f\"Skipping {skipped} already-processed entries (use --all to reprocess)\")\n        if not entries:\n            print(\"No new entries to process.\")\n            return\n\n    # Load existing beliefs for dedup context\n    existing_beliefs = _load_existing_beliefs()\n    existing_ids = {b[\"id\"] for b in existing_beliefs}\n\n    if existing_ids:\n        print(f\"Found {len(existing_ids)} existing beliefs (will skip duplicates)\")\n\n    # Compute belief embeddings once (if fastembed available)\n    belief_vectors = None\n    if existing_beliefs and _has_embeddings():\n        print(\"Computing belief embeddings for semantic dedup...\")\n        cache_path = Path(PROJECT_DIR) / \"belief-vectors.json\"\n        belief_vectors = _get_belief_embeddings(existing_beliefs, cache_path)\n        print(f\"  {len(belief_vectors)} belief vectors ready\")\n    elif existing_beliefs:\n        print(\"(install fastembed for semantic dedup: uv pip install 'expert-agent-builder[embeddings]')\")\n\n    print(f\"Reading {len(entries)} entries...\")\n\n    # Batch entries \u2014 track paths per batch for relevance scoring\n    batches = []\n    batch_paths = []\n    current_batch = []\n    current_paths = []\n    for entry_path in entries:\n        content = entry_path.read_text()\n        if len(content) > 10000:\n            content = content[:10000] + \"\\n[Truncated]\"\n        source_url = \"\"\n        if content.startswith(\"---\"):\n            end = content.find(\"---\", 3)\n            if end != -1:\n                for line in content[3:end].splitlines():\n                    if line.startswith(\"source_url:\"):\n                        source_url = line.split(\":\", 1)[1].strip()\n                    elif line.startswith(\"source:\"):\n                        val = line.split(\":\", 1)[1].strip()\n                        if not source_url and val.startswith((\"http://\", \"https://\")):\n                            source_url = val\n        header = f\"--- FILE: {entry_path}\"\n        if source_url:\n            header += f\" | SOURCE_URL: {source_url}\"\n        header += \" ---\"\n        current_batch.append(f\"{header}\\n{content}\")\n        current_paths.append(str(entry_path))\n        if len(current_batch) >= args.batch_size:\n            batches.append(\"\\n\\n\".join(current_batch))\n            batch_paths.append(current_paths)\n            current_batch = []\n            current_paths = []\n    if current_batch:\n        batches.append(\"\\n\\n\".join(current_batch))\n        batch_paths.append(current_paths)\n\n    print(f\"Processing {len(batches)} batches (batch size: {args.batch_size})...\")\n\n    source_desc = (\", \".join(str(e) for e in entries)\n                   if has_entry_flag\n                   else f\"{len(entries)} entries from {input_dir}/\")\n    output = Path(args.output)\n\n    # Write header before first batch if starting a new file\n    appended = output.exists() and output.stat().st_size > 0\n    if not appended:\n        with output.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: `expert-build accept-beliefs`\\n\\n\")\n            f.write(\"---\\n\\n\")\n            f.write(f\"**Generated:** {date.today().isoformat()}\\n\")\n            f.write(f\"**Source:** {source_desc}\\n\")\n            f.write(f\"**Model:** {args.model}\\n\\n\")\n    else:\n        with output.open(\"a\") as f:\n            f.write(f\"\\n---\\n\\n\")\n            f.write(f\"**Generated:** {date.today().isoformat()}\\n\")\n            f.write(f\"**Source:** {source_desc}\\n\")\n            f.write(f\"**Model:** {args.model}\\n\\n\")\n\n    async def _process_batch(i, batch_text, semaphore):\n        \"\"\"Process one batch under concurrency limit. Returns (filtered, paths) or None.\"\"\"\n        async with semaphore:\n            print(f\"  Batch {i + 1}/{len(batches)}...\")\n            existing_context = _build_dedup_context(\n                existing_beliefs, batch_paths[i], batch_text,\n                belief_vectors=belief_vectors,\n            )\n            prompt = PROPOSE_BELIEFS.format(entries=batch_text) + existing_context\n            try:\n                result = await invoke(prompt, model=args.model, timeout=600)\n            except Exception as e:\n                print(f\"  ERROR: {e}\")\n                return None\n\n            beliefs = extract_json(result)\n            if not isinstance(beliefs, list):\n                print(\"    WARN: response not valid JSON, retrying...\", file=sys.stderr)\n                try:\n                    retry_response = await invoke(\n                        prompt + \"\\n\\n\" + result + \"\\n\\n\" + RETRY_JSON,\n                        model=args.model, timeout=600,\n                    )\n                    beliefs = extract_json(retry_response)\n                except Exception:\n                    pass\n            if not isinstance(beliefs, list):\n                print(\"    WARN: could not parse beliefs JSON, skipping batch\", file=sys.stderr)\n                return None\n\n            filtered = []\n            for b in beliefs:\n                bid = b.get(\"id\", \"\")\n                if bid in existing_ids:\n                    continue\n                filtered.append(b)\n            return filtered, batch_paths[i]\n\n    parallel = max(1, getattr(args, \"parallel\", 1))\n    semaphore = asyncio.Semaphore(parallel)\n\n    async def run_batches():\n        tasks = [_process_batch(i, bt, semaphore) for i, bt in enumerate(batches)]\n        return await asyncio.gather(*tasks)\n\n    batch_results = asyncio.run(run_batches())\n\n    successful_entries = []\n    for result in batch_results:\n        if result is None:\n            continue\n        filtered, paths = result\n\n        with output.open(\"a\") as f:\n            for b in filtered:\n                bid = b.get(\"id\", \"unknown\")\n                claim = b.get(\"claim\", \"\")\n                source = b.get(\"source\", \"\")\n                source_url = b.get(\"source_url\", \"\")\n                f.write(f\"### [ACCEPT/REJECT] {bid}\\n\")\n                f.write(f\"{claim}\\n\")\n                f.write(f\"- Source: {source}\\n\")\n                f.write(f\"- Source URL: {source_url or 'none'}\\n\\n\")\n\n        successful_entries.extend(Path(p) for p in paths)\n        _save_processed(processed_path, successful_entries, processed)\n\n    print(f\"\\n{'Appended to' if appended else 'Wrote'} {output}\")\n\n    print(\"Review the file, mark entries as [ACCEPT] or [REJECT], then run:\")\n    print(\"  expert-build accept-beliefs\")"
  },
  "build_dedup_context_body": {
    "function": "_build_dedup_context",
    "file": "expert_build/propose.py",
    "start_line": 209,
    "end_line": 248,
    "source": "def _build_dedup_context(\n    existing_beliefs: list[dict],\n    batch_entry_paths: list[str],\n    batch_text: str,\n    max_detailed: int = 50,\n    max_compact: int = 200,\n    belief_vectors: dict[str, list[float]] | None = None,\n) -> str:\n    \"\"\"Build per-batch dedup context: relevant beliefs with text, rest as compact IDs.\"\"\"\n    if not existing_beliefs:\n        return \"\"\n\n    if belief_vectors:\n        scored = _score_by_embedding(\n            existing_beliefs, belief_vectors, batch_text, batch_entry_paths,\n        )\n    else:\n        scored = _score_by_keywords(\n            existing_beliefs, batch_text, batch_entry_paths,\n        )\n\n    detailed = scored[:max_detailed]\n    compact = scored[max_detailed:max_detailed + max_compact]\n\n    parts = [\n        \"\\n\\n## Already Accepted Beliefs\\n\\n\"\n        \"The following beliefs already exist. Do NOT propose beliefs with these IDs \"\n        \"or that duplicate their meaning under different names.\\n\"\n    ]\n\n    if detailed:\n        parts.append(\"\\nRelevant existing beliefs:\")\n        for _, belief in detailed:\n            parts.append(f\"- `{belief['id']}`: {belief['text']}\")\n\n    if compact:\n        compact_ids = \", \".join(b[\"id\"] for _, b in compact)\n        parts.append(f\"\\nOther existing IDs: {compact_ids}\")\n\n    return \"\\n\".join(parts) + \"\\n\""
  },
  "save_processed_body": {
    "function": "_save_processed",
    "file": "expert_build/propose.py",
    "start_line": 71,
    "end_line": 79,
    "source": "def _save_processed(path: Path, entries: list[Path], existing: dict[str, str]):\n    \"\"\"Record entries as processed by content hash.\"\"\"\n    updated = dict(existing)\n    for entry_path in entries:\n        content = entry_path.read_text()\n        content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]\n        updated[str(entry_path)] = content_hash\n    path.parent.mkdir(parents=True, exist_ok=True)\n    path.write_text(json.dumps(updated, indent=2) + \"\\n\")"
  },
  "llm_imports": {
    "file": "expert_build/llm.py",
    "imports": [
      "asyncio",
      "json",
      "os",
      "shutil"
    ],
    "from_imports": [],
    "import_section": "\"\"\"Model invocation for expert agent builder.\n\nCost tracking: CLI models use --output-format json to capture token\ncounts and costs. Use get_cost_summary() to retrieve accumulated stats.\n\"\"\"\n\nimport asyncio\nimport json\nimport os\nimport shutil\n"
  },
  "propose_imports": {
    "file": "expert_build/propose.py",
    "imports": [
      "asyncio",
      "hashlib",
      "json",
      "re",
      "sys"
    ],
    "from_imports": [
      {
        "module": "datetime",
        "names": [
          "date"
        ]
      },
      {
        "module": "pathlib",
        "names": [
          "Path"
        ]
      },
      {
        "module": "reasons_lib.api",
        "names": [
          "add_node",
          "list_nodes"
        ]
      },
      {
        "module": "llm",
        "names": [
          "check_model_available",
          "extract_json",
          "invoke",
          "RETRY_JSON"
        ]
      },
      {
        "module": "prompts",
        "names": [
          "PROPOSE_BELIEFS"
        ]
      }
    ],
    "import_section": "\"\"\"Propose and accept beliefs from entries.\"\"\"\n\nimport asyncio\nimport hashlib\nimport json\nimport re\nimport sys\nfrom datetime import date\nfrom pathlib import Path\n\nfrom reasons_lib.api import add_node, list_nodes\n\nfrom .llm import check_model_available, extract_json, invoke, RETRY_JSON\nfrom .prompts import PROPOSE_BELIEFS\n\nPROJECT_DIR = \".expert-build\"\nREASONS_DB = \"reasons.db\"\n\n\ndef _has_embeddings() -> bool:\n    \"\"\"Check if fastembed is available.\"\"\""
  },
  "invoke_sync_callers": {
    "symbol": "invoke_sync",
    "production_callers": [
      {
        "file": "expert_build/exam.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON",
        "context_function": null,
        "context_snippet": "   6: \n   7: from reasons_lib.api import add_node, add_nogood, list_nodes\n   8: \n>> 9: from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON\n   10: from .prompts import EXAM_ANSWER, EXAM_JUDGE\n   11: \n   12: REASONS_DB = \"reasons.db\""
      },
      {
        "file": "expert_build/exam.py",
        "line": 114,
        "text": "retry_response = invoke_sync(",
        "context_function": "extract_answer",
        "context_snippet": "   111:     if model and prompt:\n   112:         print(\"    WARN: response not valid JSON, retrying...\", file=sys.stderr)\n   113:         try:\n>> 114:             retry_response = invoke_sync(\n   115:                 prompt + \"\\n\\n\" + response + \"\\n\\n\" + RETRY_JSON,\n   116:                 model=model, timeout=60,\n   117:             )"
      },
      {
        "file": "expert_build/exam.py",
        "line": 132,
        "text": "response = invoke_sync(prompt, model=model, timeout=60)",
        "context_function": "judge_answer",
        "context_snippet": "   129:     \"\"\"Use LLM to judge if an open-ended answer is semantically correct.\"\"\"\n   130:     prompt = EXAM_JUDGE.format(question=question, expected=expected, got=got)\n   131:     try:\n>> 132:         response = invoke_sync(prompt, model=model, timeout=60)\n   133:     except Exception:\n   134:         return False, \"judge error\"\n   135: "
      },
      {
        "file": "expert_build/exam.py",
        "line": 143,
        "text": "retry_response = invoke_sync(",
        "context_function": "judge_answer",
        "context_snippet": "   140: \n   141:     print(\"    WARN: verdict not valid JSON, retrying...\", file=sys.stderr)\n   142:     try:\n>> 143:         retry_response = invoke_sync(\n   144:             prompt + \"\\n\\n\" + response + \"\\n\\n\" + RETRY_JSON,\n   145:             model=model, timeout=60,\n   146:         )"
      },
      {
        "file": "expert_build/exam.py",
        "line": 202,
        "text": "response = invoke_sync(prompt, model=args.model, timeout=120)"
      },
      {
        "file": "expert_build/llm.py",
        "line": 154,
        "text": "def invoke_sync(prompt: str, model: str = \"claude\", timeout: int = DEFAULT_TIMEOUT) -> str:"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 115,
        "text": "result = invoke_sync(prompt, model=args.model, timeout=120)"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 118,
        "text": "retry_response = invoke_sync("
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 10,
        "text": "from .llm import check_model_available, invoke_sync"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 193,
        "text": "response = invoke_sync(prompt, model=args.model, timeout=args.timeout)"
      }
    ],
    "test_callers": [
      {
        "file": "tests/test_exam.py",
        "line": 80,
        "text": "with patch(\"expert_build.exam.invoke_sync\",",
        "context_function": "test_extract_answer_retries_on_bad_json",
        "context_snippet": "   77: def test_extract_answer_retries_on_bad_json():\n   78:     bad_response = \"I think the answer is B because of reasons\"\n   79: \n>> 80:     with patch(\"expert_build.exam.invoke_sync\",\n   81:                return_value='{\"answer\": \"b\", \"explanation\": \"reasons\"}') as mock_llm:\n   82:         result = extract_answer(bad_response, model=\"test\", prompt=\"original prompt\")\n   83: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 91,
        "text": "with patch(\"expert_build.exam.invoke_sync\", return_value=\"Still no format\"):",
        "context_function": "test_extract_answer_fallback_after_failed_retry",
        "context_snippet": "   88: def test_extract_answer_fallback_after_failed_retry():\n   89:     bad_response = \"No format at all\"\n   90: \n>> 91:     with patch(\"expert_build.exam.invoke_sync\", return_value=\"Still no format\"):\n   92:         result = extract_answer(bad_response, model=\"test\", prompt=\"original prompt\")\n   93: \n   94:     assert result == \"No format at all\""
      },
      {
        "file": "tests/test_exam.py",
        "line": 110,
        "text": "with patch(\"expert_build.exam.invoke_sync\",",
        "context_function": "test_judge_correct",
        "context_snippet": "   107: # --- judge_answer ---\n   108: \n   109: def test_judge_correct():\n>> 110:     with patch(\"expert_build.exam.invoke_sync\",\n   111:                return_value='{\"verdict\": \"CORRECT\", \"explanation\": \"matches\"}'):\n   112:         is_correct, explanation = judge_answer(\"q\", \"expected\", \"got\", \"test\")\n   113: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 119,
        "text": "with patch(\"expert_build.exam.invoke_sync\",",
        "context_function": "test_judge_wrong",
        "context_snippet": "   116: \n   117: \n   118: def test_judge_wrong():\n>> 119:     with patch(\"expert_build.exam.invoke_sync\",\n   120:                return_value='{\"verdict\": \"WRONG\", \"explanation\": \"missed key point\"}'):\n   121:         is_correct, explanation = judge_answer(\"q\", \"expected\", \"got\", \"test\")\n   122: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 136,
        "text": "with patch(\"expert_build.exam.invoke_sync\", side_effect=side_effect):",
        "context_function": "side_effect",
        "context_snippet": "   133:             return \"I think this is correct because it matches\"\n   134:         return '{\"verdict\": \"CORRECT\", \"explanation\": \"matches expected\"}'\n   135: \n>> 136:     with patch(\"expert_build.exam.invoke_sync\", side_effect=side_effect):\n   137:         is_correct, explanation = judge_answer(\"q\", \"expected\", \"got\", \"test\")\n   138: \n   139:     assert is_correct is True"
      },
      {
        "file": "tests/test_exam.py",
        "line": 144,
        "text": "with patch(\"expert_build.exam.invoke_sync\", return_value=\"No JSON at all\"):",
        "context_function": "test_judge_fallback_after_failed_retry",
        "context_snippet": "   141: \n   142: \n   143: def test_judge_fallback_after_failed_retry():\n>> 144:     with patch(\"expert_build.exam.invoke_sync\", return_value=\"No JSON at all\"):\n   145:         is_correct, explanation = judge_answer(\"q\", \"expected\", \"got\", \"test\")\n   146: \n   147:     assert is_correct is False"
      },
      {
        "file": "tests/test_exam.py",
        "line": 152,
        "text": "with patch(\"expert_build.exam.invoke_sync\",",
        "context_function": "test_judge_handles_llm_error",
        "context_snippet": "   149: \n   150: \n   151: def test_judge_handles_llm_error():\n>> 152:     with patch(\"expert_build.exam.invoke_sync\",\n   153:                side_effect=RuntimeError(\"timeout\")):\n   154:         is_correct, explanation = judge_answer(\"q\", \"expected\", \"got\", \"test\")\n   155: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 169,
        "text": "with patch(\"expert_build.exam.invoke_sync\", side_effect=side_effect):",
        "context_function": "side_effect",
        "context_snippet": "   166:             return \"Not JSON\"\n   167:         raise RuntimeError(\"retry timeout\")\n   168: \n>> 169:     with patch(\"expert_build.exam.invoke_sync\", side_effect=side_effect):\n   170:         is_correct, explanation = judge_answer(\"q\", \"expected\", \"got\", \"test\")\n   171: \n   172:     assert is_correct is False"
      },
      {
        "file": "tests/test_exam.py",
        "line": 178,
        "text": "with patch(\"expert_build.exam.invoke_sync\",",
        "context_function": "test_judge_case_insensitive_verdict",
        "context_snippet": "   175: \n   176: \n   177: def test_judge_case_insensitive_verdict():\n>> 178:     with patch(\"expert_build.exam.invoke_sync\",\n   179:                return_value='{\"verdict\": \"correct\", \"explanation\": \"ok\"}'):\n   180:         is_correct, _ = judge_answer(\"q\", \"expected\", \"got\", \"test\")\n   181: "
      },
      {
        "file": "tests/test_coverage.py",
        "line": 55,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   52:         return json.dumps({\"matching_ids\": [\"lvm-basics\"]})\n   53: \n   54:     with patch(\"expert_build.coverage.check_model_available\", return_value=True), \\\n>> 55:          patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\\n   56:          patch(\"expert_build.coverage.load_beliefs\", return_value=FAKE_BELIEFS):\n   57:         cmd_cert_coverage(args)\n   58: "
      },
      {
        "file": "tests/test_coverage.py",
        "line": 72,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   69:         return json.dumps({\"matching_ids\": []})\n   70: \n   71:     with patch(\"expert_build.coverage.check_model_available\", return_value=True), \\\n>> 72:          patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\\n   73:          patch(\"expert_build.coverage.load_beliefs\", return_value=FAKE_BELIEFS):\n   74:         cmd_cert_coverage(args)\n   75: "
      },
      {
        "file": "tests/test_coverage.py",
        "line": 93,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   90:         return json.dumps({\"matching_ids\": [\"local-storage-config\"]})\n   91: \n   92:     with patch(\"expert_build.coverage.check_model_available\", return_value=True), \\\n>> 93:          patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\\n   94:          patch(\"expert_build.coverage.load_beliefs\", return_value=FAKE_BELIEFS):\n   95:         cmd_cert_coverage(args)\n   96: "
      },
      {
        "file": "tests/test_coverage.py",
        "line": 109,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   106:         return '```json\\n{\"matching_ids\": [\"local-storage-config\"]}\\n```'\n   107: \n   108:     with patch(\"expert_build.coverage.check_model_available\", return_value=True), \\\n>> 109:          patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\\n   110:          patch(\"expert_build.coverage.load_beliefs\", return_value=FAKE_BELIEFS):\n   111:         cmd_cert_coverage(args)\n   112: "
      },
      {
        "file": "tests/test_coverage.py",
        "line": 125,
        "text": "patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   122:         return json.dumps({\"matching_ids\": [\"nonexistent-belief\", \"local-storage-config\"]})\n   123: \n   124:     with patch(\"expert_build.coverage.check_model_available\", return_value=True), \\\n>> 125:          patch(\"expert_build.coverage.invoke_sync\", side_effect=invoke_side_effect), \\\n   126:          patch(\"expert_build.coverage.load_beliefs\", return_value=FAKE_BELIEFS):\n   127:         cmd_cert_coverage(args)\n   128: "
      },
      {
        "file": "tests/test_pipeline.py",
        "line": 186,
        "text": "patch(\"expert_build.llm.invoke_sync\", return_value=\"No proposals\"), \\",
        "context_function": "test_saturates_on_no_proposals",
        "context_snippet": "   183:         stats = {\"total_in\": 1, \"total_derived\": 0, \"max_depth\": 0, \"agents\": 0}\n   184:         with patch(\"reasons_lib.api.export_network\", return_value={\"nodes\": nodes}), \\\n   185:              patch(\"reasons_lib.derive.build_prompt\", return_value=(\"prompt\", stats)), \\\n>> 186:              patch(\"expert_build.llm.invoke_sync\", return_value=\"No proposals\"), \\\n   187:              patch(\"reasons_lib.derive.parse_proposals\", return_value=[]):\n   188:             added = _stage_derive(args)\n   189:         assert added == 0"
      },
      {
        "file": "tests/test_pipeline.py",
        "line": 202,
        "text": "patch(\"expert_build.llm.invoke_sync\", return_value=\"proposal text\"), \\",
        "context_function": "test_applies_valid_proposals",
        "context_snippet": "   199:         }\n   200:         with patch(\"reasons_lib.api.export_network\", return_value={\"nodes\": nodes}), \\\n   201:              patch(\"reasons_lib.derive.build_prompt\", return_value=(\"prompt\", stats)), \\\n>> 202:              patch(\"expert_build.llm.invoke_sync\", return_value=\"proposal text\"), \\\n   203:              patch(\"reasons_lib.derive.parse_proposals\", return_value=[proposal]), \\\n   204:              patch(\"reasons_lib.derive.validate_proposals\", return_value=([proposal], [])), \\\n   205:              patch(\"reasons_lib.derive.apply_proposals\", return_value=[(proposal, {\"truth_value\": \"IN\"})]):"
      }
    ],
    "production_count": 11,
    "test_count": 16,
    "total_count": 27
  },
  "propose_tests": {
    "source_file": "expert_build/propose.py",
    "test_files": [
      {
        "path": "tests/test_pipelinepy",
        "exists": false
      },
      {
        "path": "tests/test_propose.py",
        "exists": true,
        "line_count": 290
      },
      {
        "path": "tests/test_proposepy",
        "exists": false
      }
    ],
    "test_count": 3
  },
  "cmd_propose_callers": {
    "symbol": "cmd_propose_beliefs",
    "production_callers": [
      {
        "file": "expert_build/propose.py",
        "line": 262,
        "text": "def cmd_propose_beliefs(args):",
        "context_function": "auto_accept_proposals",
        "context_snippet": "   259:     path.write_text(text)\n   260: \n   261: \n>> 262: def cmd_propose_beliefs(args):\n   263:     \"\"\"Extract candidate beliefs from entries for human review.\"\"\"\n   264:     from .caffeinate import hold as _caffeinate\n   265:     _caffeinate()"
      },
      {
        "file": "expert_build/cli.py",
        "line": 161,
        "text": "\"propose-beliefs\": lambda a: _lazy(\"propose\", \"cmd_propose_beliefs\")(a),",
        "context_function": "main",
        "context_snippet": "   158:         \"chunk-docs\": lambda a: _lazy(\"chunk_docs\", \"cmd_chunk_docs\")(a),\n   159:         \"fetch-docs\": lambda a: _lazy(\"fetch\", \"cmd_fetch_docs\")(a),\n   160:         \"summarize\": lambda a: _lazy(\"summarize\", \"cmd_summarize\")(a),\n>> 161:         \"propose-beliefs\": lambda a: _lazy(\"propose\", \"cmd_propose_beliefs\")(a),\n   162:         \"accept-beliefs\": lambda a: _lazy(\"propose\", \"cmd_accept_beliefs\")(a),\n   163:         \"cert-coverage\": lambda a: _lazy(\"coverage\", \"cmd_cert_coverage\")(a),\n   164:         \"exam\": lambda a: _lazy(\"exam\", \"cmd_exam\")(a),"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 135,
        "text": "from .propose import cmd_propose_beliefs, cmd_accept_beliefs",
        "context_function": "_stage_extract",
        "context_snippet": "   132: \n   133: def _stage_extract(args):\n   134:     \"\"\"Stage 3: Extract beliefs from entries and optionally auto-accept.\"\"\"\n>> 135:     from .propose import cmd_propose_beliefs, cmd_accept_beliefs\n   136: \n   137:     prop_args = SimpleNamespace(\n   138:         input_dir=\"entries\","
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 146,
        "text": "cmd_propose_beliefs(prop_args)",
        "context_function": "_stage_extract",
        "context_snippet": "   143:     )\n   144:     setattr(prop_args, \"all\", False)\n   145: \n>> 146:     cmd_propose_beliefs(prop_args)\n   147: \n   148:     if args.no_auto_accept:\n   149:         print(\"\\nStopping after propose-beliefs (--no-auto-accept)\", file=sys.stderr)"
      }
    ],
    "test_callers": [
      {
        "file": "tests/test_propose.py",
        "line": 10,
        "text": "from expert_build.propose import cmd_propose_beliefs",
        "context_function": null,
        "context_snippet": "   7: \n   8: import pytest\n   9: \n>> 10: from expert_build.propose import cmd_propose_beliefs\n   11: \n   12: \n   13: @pytest.fixture"
      },
      {
        "file": "tests/test_propose.py",
        "line": 68,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   65:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   66:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   67:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 68:         cmd_propose_beliefs(args)\n   69: \n   70:     content = output.read_text()\n   71:     assert \"belief-from-batch-1\" in content"
      },
      {
        "file": "tests/test_propose.py",
        "line": 92,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   89:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   90:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   91:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 92:         cmd_propose_beliefs(args)\n   93: \n   94:     content = output.read_text()\n   95:     assert \"belief-1\" in content"
      },
      {
        "file": "tests/test_propose.py",
        "line": 117,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   114:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   115:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=existing), \\\n   116:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 117:         cmd_propose_beliefs(args)\n   118: \n   119:     content = output.read_text()\n   120:     assert \"new-belief\" in content"
      },
      {
        "file": "tests/test_propose.py",
        "line": 144,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   141:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   142:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   143:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 144:         cmd_propose_beliefs(args)\n   145: \n   146:     processed_path = work_dir / \".expert-build\" / \"proposed-entries.json\"\n   147:     processed = json.loads(processed_path.read_text())"
      },
      {
        "file": "tests/test_propose.py",
        "line": 175,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   172:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   173:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   174:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 175:         cmd_propose_beliefs(args)\n   176: \n   177:     content = output.read_text()\n   178:     assert \"retried-belief\" in content"
      },
      {
        "file": "tests/test_propose.py",
        "line": 196,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   193:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   194:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   195:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 196:         cmd_propose_beliefs(args)\n   197: \n   198:     content = output.read_text()\n   199:     assert \"fenced-belief\" in content"
      },
      {
        "file": "tests/test_propose.py",
        "line": 220,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   217:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   218:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   219:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 220:         cmd_propose_beliefs(args)\n   221: \n   222:     assert \"SOURCE_URL: https://example.com/doc\" in captured_prompt\n   223: "
      },
      {
        "file": "tests/test_propose.py",
        "line": 243,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   240:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   241:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   242:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 243:         cmd_propose_beliefs(args)\n   244: \n   245:     assert \"SOURCE_URL: https://example.com/page\" in captured_prompt\n   246: "
      },
      {
        "file": "tests/test_propose.py",
        "line": 266,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   263:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   264:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   265:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 266:         cmd_propose_beliefs(args)\n   267: \n   268:     assert \"| SOURCE_URL:\" not in captured_prompt\n   269: "
      },
      {
        "file": "tests/test_propose.py",
        "line": 286,
        "text": "cmd_propose_beliefs(args)",
        "context_function": "invoke_side_effect",
        "context_snippet": "   283:          patch(\"expert_build.propose.invoke\", new_callable=AsyncMock, side_effect=invoke_side_effect), \\\n   284:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   285:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n>> 286:         cmd_propose_beliefs(args)\n   287: \n   288:     content = output.read_text()\n   289:     assert \"prior-belief\" in content"
      },
      {
        "file": "tests/test_pipeline.py",
        "line": 149,
        "text": "with patch(\"expert_build.propose.cmd_propose_beliefs\"):",
        "context_function": "test_stops_on_no_auto_accept",
        "context_snippet": "   146: class TestStageExtract:\n   147:     def test_stops_on_no_auto_accept(self, work_dir, capsys):\n   148:         args = make_pipeline_args(no_auto_accept=True)\n>> 149:         with patch(\"expert_build.propose.cmd_propose_beliefs\"):\n   150:             result = _stage_extract(args)\n   151:         assert result is False\n   152:         captured = capsys.readouterr()"
      },
      {
        "file": "tests/test_pipeline.py",
        "line": 160,
        "text": "with patch(\"expert_build.propose.cmd_propose_beliefs\"), \\",
        "context_function": "test_auto_accepts_and_imports",
        "context_snippet": "   157:         proposals.write_text(\"### [ACCEPT/REJECT] test-belief\\nText\\n- Source: test\\n\")\n   158:         args = make_pipeline_args()\n   159: \n>> 160:         with patch(\"expert_build.propose.cmd_propose_beliefs\"), \\\n   161:              patch(\"expert_build.propose.cmd_accept_beliefs\") as mock_accept:\n   162:             result = _stage_extract(args)\n   163: "
      }
    ],
    "production_count": 4,
    "test_count": 13,
    "total_count": 17
  }
}
```

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.
