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 ebc3244..42beb32 100644
--- a/expert_build/cli.py
+++ b/expert_build/cli.py
@@ -62,6 +62,8 @@ def main():
     sum_p.add_argument("--input-dir", default="sources", help="Source directory (default: sources)")
     sum_p.add_argument("--recursive", "-r", action="store_true",
                        help="Recursively search subdirectories")
+    sum_p.add_argument("--parallel", type=int, default=1,
+                       help="Number of parallel LLM calls (default: 1)")
     sum_p.add_argument("--limit", type=int, help="Max files to process")
     sum_p.add_argument("--model", default="claude", help="Model to use (default: claude)")
 
diff --git a/expert_build/summarize.py b/expert_build/summarize.py
index 78440eb..7bb25ed 100644
--- a/expert_build/summarize.py
+++ b/expert_build/summarize.py
@@ -1,13 +1,102 @@
 """Summarize source documents into entries using an LLM."""
 
+import asyncio
 import sys
 from datetime import date
 from pathlib import Path
 
-from .llm import check_model_available, invoke_sync
+from .llm import check_model_available, invoke
 from .prompts import SUMMARIZE, SUMMARIZE_CODE
 
 
+def _prepare_source(source_path):
+    """Read source file, strip frontmatter, truncate if needed.
+
+    Returns (content, source_url, source_id, prompt) or None if skipped.
+    """
+    content = source_path.read_text()
+
+    source_url = None
+    source_id = None
+    if content.startswith("---"):
+        end = content.find("---", 3)
+        if end != -1:
+            frontmatter = content[3:end]
+            for line in frontmatter.splitlines():
+                if line.startswith("source_url:"):
+                    source_url = line.split(":", 1)[1].strip()
+                elif line.startswith("source:"):
+                    val = line.split(":", 1)[1].strip()
+                    if not source_url and val.startswith(("http://", "https://")):
+                        source_url = val
+                elif line.startswith("source_id:"):
+                    source_id = line.split(":", 1)[1].strip()
+            content = content[end + 3:].strip()
+
+    if not content.strip():
+        return None
+
+    if len(content) > 30000:
+        original_len = len(content)
+        content = content[:30000] + "\n\n[Truncated — original was longer]"
+        if source_path.suffix == ".pdf":
+            print(f"  WARN: truncated from {original_len} to 30000 chars. "
+                  f"Consider: expert-build chunk-pdf {source_path}")
+        else:
+            print(f"  WARN: truncated from {original_len} to 30000 chars. "
+                  f"Consider: expert-build chunk-docs")
+
+    template = SUMMARIZE_CODE if source_path.suffix == ".py" else SUMMARIZE
+    prompt = template.format(content=content)
+
+    return source_url, source_id, prompt
+
+
+def _write_entry(source_path, summary, source_url, source_id):
+    """Write entry file with provenance frontmatter."""
+    topic = source_path.stem
+    today = date.today()
+    entry_dir = Path("entries") / str(today.year) / f"{today.month:02d}" / f"{today.day:02d}"
+    entry_dir.mkdir(parents=True, exist_ok=True)
+    entry_path = entry_dir / f"{topic}.md"
+
+    fm_lines = [f"source: {source_path}"]
+    if source_url:
+        fm_lines.append(f"source_url: {source_url}")
+    if source_id:
+        fm_lines.append(f"source_id: {source_id}")
+    frontmatter = "---\n" + "\n".join(fm_lines) + "\n---\n\n"
+
+    entry_path.write_text(frontmatter + summary + "\n")
+    return entry_path
+
+
+async def _summarize_one(source_path, model, semaphore, manifest, done):
+    """Summarize a single source file under concurrency limit."""
+    prepared = _prepare_source(source_path)
+    if prepared is None:
+        print(f"  SKIP (empty): {source_path.name}")
+        return False
+
+    source_url, source_id, prompt = prepared
+    print(f"Summarizing: {source_path.name}")
+
+    async with semaphore:
+        try:
+            summary = await invoke(prompt, model=model)
+        except Exception as e:
+            print(f"  ERROR ({source_path.name}): {e}")
+            return False
+
+    entry_path = _write_entry(source_path, summary, source_url, source_id)
+    print(f"  -> Created {entry_path}")
+
+    with manifest.open("a") as f:
+        f.write(f"{source_path}\n")
+    done.add(str(source_path))
+    return True
+
+
 def cmd_summarize(args):
     """Generate entries from source documents."""
     from .caffeinate import hold as _caffeinate
@@ -35,89 +124,29 @@ def cmd_summarize(args):
     if args.limit:
         sources = sources[:args.limit]
 
-    # Track what's been summarized
     manifest = Path(".summarized")
     done = set()
     if manifest.exists():
         done = set(manifest.read_text().strip().split("\n"))
 
-    processed = 0
-    skipped = 0
-
-    for source_path in sources:
-        if str(source_path) in done:
-            skipped += 1
-            continue
-
-        print(f"Summarizing: {source_path.name}")
-
-        content = source_path.read_text()
-
-        # Extract and strip frontmatter
-        source_url = None
-        source_id = None
-        if content.startswith("---"):
-            end = content.find("---", 3)
-            if end != -1:
-                frontmatter = content[3:end]
-                for line in frontmatter.splitlines():
-                    if line.startswith("source_url:"):
-                        source_url = line.split(":", 1)[1].strip()
-                    elif line.startswith("source:"):
-                        val = line.split(":", 1)[1].strip()
-                        if not source_url and val.startswith(("http://", "https://")):
-                            source_url = val
-                    elif line.startswith("source_id:"):
-                        source_id = line.split(":", 1)[1].strip()
-                content = content[end + 3:].strip()
-
-        if not content.strip():
-            print(f"  SKIP (empty)")
-            continue
-
-        # Truncate very long documents
-        if len(content) > 30000:
-            original_len = len(content)
-            content = content[:30000] + "\n\n[Truncated — original was longer]"
-            if source_path.suffix == ".pdf":
-                print(f"  WARN: truncated from {original_len} to 30000 chars. "
-                      f"Consider: expert-build chunk-pdf {source_path}")
-            else:
-                print(f"  WARN: truncated from {original_len} to 30000 chars. "
-                      f"Consider: expert-build chunk-docs")
-
-        template = SUMMARIZE_CODE if source_path.suffix == ".py" else SUMMARIZE
-        prompt = template.format(content=content)
+    to_process = [s for s in sources if str(s) not in done]
+    skipped = len(sources) - len(to_process)
 
-        try:
-            summary = invoke_sync(prompt, model=args.model)
-        except Exception as e:
-            print(f"  ERROR: {e}")
-            continue
-
-        topic = source_path.stem
-
-        # Write entry directly with provenance frontmatter
-        today = date.today()
-        entry_dir = Path("entries") / str(today.year) / f"{today.month:02d}" / f"{today.day:02d}"
-        entry_dir.mkdir(parents=True, exist_ok=True)
-        entry_path = entry_dir / f"{topic}.md"
-
-        fm_lines = [f"source: {source_path}"]
-        if source_url:
-            fm_lines.append(f"source_url: {source_url}")
-        if source_id:
-            fm_lines.append(f"source_id: {source_id}")
-        frontmatter = "---\n" + "\n".join(fm_lines) + "\n---\n\n"
+    if not to_process:
+        print(f"\nSummarized 0 sources ({skipped} already done)")
+        return
 
-        entry_path.write_text(frontmatter + summary + "\n")
-        print(f"  -> Created {entry_path}")
+    parallel = getattr(args, "parallel", 1)
+    semaphore = asyncio.Semaphore(parallel)
 
-        # Record as done
-        with manifest.open("a") as f:
-            f.write(f"{source_path}\n")
-        done.add(str(source_path))
+    async def run_all():
+        tasks = [
+            _summarize_one(s, args.model, semaphore, manifest, done)
+            for s in to_process
+        ]
+        return await asyncio.gather(*tasks)
 
-        processed += 1
+    results = asyncio.run(run_all())
+    processed = sum(1 for r in results if r)
 
     print(f"\nSummarized {processed} sources ({skipped} already done)")
diff --git a/tests/test_summarize.py b/tests/test_summarize.py
index 5b95108..1389343 100644
--- a/tests/test_summarize.py
+++ b/tests/test_summarize.py
@@ -1,8 +1,9 @@
 """Tests for expert_build.summarize."""
 
+import asyncio
 import types
 from pathlib import Path
-from unittest.mock import patch
+from unittest.mock import patch, AsyncMock
 
 import pytest
 
@@ -29,8 +30,8 @@ def work_dir(tmp_path, monkeypatch):
     return wd
 
 
-def make_args(input_dir, model="test-model", limit=None, recursive=False):
-    return types.SimpleNamespace(input_dir=str(input_dir), model=model, limit=limit, recursive=recursive)
+def make_args(input_dir, model="test-model", limit=None, recursive=False, parallel=1):
+    return types.SimpleNamespace(input_dir=str(input_dir), model=model, limit=limit, recursive=recursive, parallel=parallel)
 
 
 def _find_entry(work_dir):
@@ -47,7 +48,7 @@ def test_discovers_md_files(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Topic Title\nSummary"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Topic Title\nSummary"):
         cmd_summarize(args)
 
     entry = _find_entry(work_dir)
@@ -59,7 +60,7 @@ def test_discovers_py_files(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Module\nSummary"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Module\nSummary"):
         cmd_summarize(args)
 
     entry = _find_entry(work_dir)
@@ -72,7 +73,7 @@ def test_discovers_both_md_and_py(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Title\nSummary") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary") as mock_llm:
         cmd_summarize(args)
 
     assert mock_llm.call_count == 2
@@ -88,7 +89,7 @@ def test_recursive_discovers_nested_files(source_dir, work_dir):
     args = make_args(source_dir, recursive=True)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Title\nSummary") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary") as mock_llm:
         cmd_summarize(args)
 
     assert mock_llm.call_count == 2
@@ -104,7 +105,7 @@ def test_non_recursive_skips_nested_files(source_dir, work_dir):
     args = make_args(source_dir, recursive=False)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Title\nSummary") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary") as mock_llm:
         cmd_summarize(args)
 
     assert mock_llm.call_count == 1
@@ -116,7 +117,7 @@ def test_ignores_other_extensions(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock) as mock_llm:
         cmd_summarize(args)
 
     assert not mock_llm.called
@@ -129,7 +130,7 @@ def test_uses_summarize_code_for_py(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Module\nSummary") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Module\nSummary") as mock_llm:
         cmd_summarize(args)
 
     prompt = mock_llm.call_args[0][0]
@@ -141,7 +142,7 @@ def test_uses_summarize_for_md(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Doc Title\nSummary") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Doc Title\nSummary") as mock_llm:
         cmd_summarize(args)
 
     prompt = mock_llm.call_args[0][0]
@@ -155,7 +156,7 @@ def test_truncation_warning_for_large_file(source_dir, work_dir, capsys):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Big Doc\nSummary"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Big Doc\nSummary"):
         cmd_summarize(args)
 
     captured = capsys.readouterr()
@@ -168,7 +169,7 @@ def test_truncation_content_is_capped(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Big\nSummary") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Big\nSummary") as mock_llm:
         cmd_summarize(args)
 
     prompt = mock_llm.call_args[0][0]
@@ -181,7 +182,7 @@ def test_no_truncation_warning_for_small_file(source_dir, work_dir, capsys):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Small\nSummary"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Small\nSummary"):
         cmd_summarize(args)
 
     captured = capsys.readouterr()
@@ -197,7 +198,7 @@ def test_skips_already_summarized(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock) as mock_llm:
         cmd_summarize(args)
 
     assert not mock_llm.called
@@ -208,7 +209,7 @@ def test_manifest_records_processed_file(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Title\nSummary"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary"):
         cmd_summarize(args)
 
     manifest = work_dir / ".summarized"
@@ -224,7 +225,7 @@ def test_strips_frontmatter_before_summarizing(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Title\nSummary") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary") as mock_llm:
         cmd_summarize(args)
 
     prompt = mock_llm.call_args[0][0]
@@ -238,7 +239,7 @@ def test_strips_source_url_frontmatter(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Title\nSummary") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary") as mock_llm:
         cmd_summarize(args)
 
     prompt = mock_llm.call_args[0][0]
@@ -251,7 +252,7 @@ def test_skips_empty_content_after_frontmatter(source_dir, work_dir, capsys):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync") as mock_llm:
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock) as mock_llm:
         cmd_summarize(args)
 
     assert not mock_llm.called
@@ -267,7 +268,7 @@ def test_entry_has_source_frontmatter(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Title\nSummary"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary"):
         cmd_summarize(args)
 
     entry = _find_entry(work_dir)
@@ -283,7 +284,7 @@ def test_entry_has_source_url_from_fetch_frontmatter(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Page\nSummary"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Page\nSummary"):
         cmd_summarize(args)
 
     entry = _find_entry(work_dir)
@@ -298,7 +299,7 @@ def test_entry_has_source_id_when_present(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Title\nSummary"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary"):
         cmd_summarize(args)
 
     entry = _find_entry(work_dir)
@@ -313,7 +314,7 @@ def test_entry_contains_llm_summary(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## My Title\nDetailed summary here"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## My Title\nDetailed summary here"):
         cmd_summarize(args)
 
     entry = _find_entry(work_dir)
@@ -327,7 +328,7 @@ def test_entry_directory_structure(source_dir, work_dir):
     args = make_args(source_dir)
 
     with patch("expert_build.summarize.check_model_available", return_value=True), \
-         patch("expert_build.summarize.invoke_sync", return_value="## Title\nSummary"):
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary"):
         cmd_summarize(args)
 
     entry = _find_entry(work_dir)
@@ -352,3 +353,31 @@ def test_summarize_template_has_content_placeholder():
 
 def test_summarize_code_template_has_content_placeholder():
     assert "{content}" in SUMMARIZE_CODE
+
+
+# --- Parallel tests ---
+
+def test_parallel_summarizes_multiple_files(source_dir, work_dir):
+    for i in range(4):
+        (source_dir / f"doc{i}.md").write_text(f"# Doc {i}\nContent {i}")
+    args = make_args(source_dir, parallel=2)
+
+    with patch("expert_build.summarize.check_model_available", return_value=True), \
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary"):
+        cmd_summarize(args)
+
+    entries = list((work_dir / "entries").rglob("*.md"))
+    assert len(entries) == 4
+
+
+def test_parallel_default_is_sequential(source_dir, work_dir):
+    (source_dir / "doc.md").write_text("# Hello\nContent")
+    args = make_args(source_dir)
+    assert args.parallel == 1
+
+    with patch("expert_build.summarize.check_model_available", return_value=True), \
+         patch("expert_build.summarize.invoke", new_callable=AsyncMock, return_value="## Title\nSummary"):
+        cmd_summarize(args)
+
+    entry = _find_entry(work_dir)
+    assert "Summary" in entry.read_text()

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "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_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_propose.py",
        "line": 64,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 88,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 113,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 140,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 171,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 192,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 216,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 239,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 262,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 282,
        "text": "patch(\"expert_build.propose.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("
      }
    ],
    "old_name_count": 40,
    "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/propose.py",
        "line": 12,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/propose.py",
        "line": 389,
        "text": "result = invoke_sync(prompt, model=args.model, timeout=600)"
      },
      {
        "file": "expert_build/propose.py",
        "line": 399,
        "text": "retry_response = invoke_sync("
      },
      {
        "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": 14,
    "migration_complete": false,
    "new_name": "invoke",
    "new_name_usages": [
      {
        "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"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 331,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\"):"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 366,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\"):"
      },
      {
        "file": "tests/test_summarize.py",
        "line": 379,
        "text": "patch(\"expert_build.summarize.invoke\", new_callable=AsyncMock, return_value=\"## Title\\nSummary\"):"
      },
      {
        "file": ".venv/lib/python3.14/site-packages/bs4/filter.py",
        "line": 295,
        "text": "# No need to invoke the test function."
      },
      {
        "file": "expert_build/llm.py",
        "line": 117,
        "text": "async def invoke(prompt: str, model: str = \"claude\", timeout: int = DEFAULT_TIMEOUT) -> str:"
      },
      {
        "file": "expert_build/llm.py",
        "line": 155,
        "text": "\"\"\"Synchronous wrapper for invoke.\"\"\""
      },
      {
        "file": "expert_build/llm.py",
        "line": 156,
        "text": "return asyncio.run(invoke(prompt, model, timeout))"
      },
      {
        "file": "expert_build/summarize.py",
        "line": 8,
        "text": "from .llm import check_model_available, invoke"
      },
      {
        "file": "expert_build/summarize.py",
        "line": 86,
        "text": "summary = await invoke(prompt, model=model)"
      }
    ],
    "new_name_count": 29
  },
  "invoke_raises": {
    "function": "invoke",
    "file": "expert_build/llm.py",
    "explicit_raises": [
      "TimeoutError",
      "RuntimeError",
      "ValueError"
    ],
    "calls": [
      "items",
      "wait_for",
      "encode",
      "keys",
      "create_subprocess_exec",
      "communicate",
      "decode",
      "kill",
      "_parse_cli_json",
      "TimeoutError",
      "RuntimeError",
      "ValueError",
      "list"
    ]
  },
  "cmd_summarize_full": {
    "function": "cmd_summarize",
    "file": "expert_build/summarize.py",
    "start_line": 100,
    "end_line": 152,
    "source": "def cmd_summarize(args):\n    \"\"\"Generate entries from source documents.\"\"\"\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\"Source directory not found: {input_dir}\")\n        print(\"Run: expert-build fetch-docs <url>\")\n        sys.exit(1)\n\n    if not check_model_available(args.model):\n        print(f\"Model not available: {args.model}\")\n        print(\"Install claude CLI or specify --model\")\n        sys.exit(1)\n\n    glob = input_dir.rglob if getattr(args, \"recursive\", False) else input_dir.glob\n    sources = sorted(\n        [*glob(\"*.md\"), *glob(\"*.py\")],\n        key=lambda p: p.name,\n    )\n    if not sources:\n        print(f\"No .md or .py files in {input_dir}\")\n        return\n\n    if args.limit:\n        sources = sources[:args.limit]\n\n    manifest = Path(\".summarized\")\n    done = set()\n    if manifest.exists():\n        done = set(manifest.read_text().strip().split(\"\\n\"))\n\n    to_process = [s for s in sources if str(s) not in done]\n    skipped = len(sources) - len(to_process)\n\n    if not to_process:\n        print(f\"\\nSummarized 0 sources ({skipped} already done)\")\n        return\n\n    parallel = getattr(args, \"parallel\", 1)\n    semaphore = asyncio.Semaphore(parallel)\n\n    async def run_all():\n        tasks = [\n            _summarize_one(s, args.model, semaphore, manifest, done)\n            for s in to_process\n        ]\n        return await asyncio.gather(*tasks)\n\n    results = asyncio.run(run_all())\n    processed = sum(1 for r in results if r)\n\n    print(f\"\\nSummarized {processed} sources ({skipped} already done)\")"
  },
  "caffeinate_hold_body": {
    "function": "hold",
    "file": "expert_build/caffeinate.py",
    "start_line": 10,
    "end_line": 25,
    "source": "def hold():\n    \"\"\"Start caffeinate to prevent idle sleep. No-op on non-macOS.\"\"\"\n    global _process\n    if _process is not None:\n        return\n    if platform.system() != \"Darwin\":\n        return\n    try:\n        _process = subprocess.Popen(\n            [\"caffeinate\", \"-i\"],\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n        )\n        atexit.register(release)\n    except FileNotFoundError:\n        pass"
  },
  "summarize_imports": {
    "file": "expert_build/summarize.py",
    "imports": [
      "asyncio",
      "sys"
    ],
    "from_imports": [
      {
        "module": "datetime",
        "names": [
          "date"
        ]
      },
      {
        "module": "pathlib",
        "names": [
          "Path"
        ]
      },
      {
        "module": "llm",
        "names": [
          "check_model_available",
          "invoke"
        ]
      },
      {
        "module": "prompts",
        "names": [
          "SUMMARIZE",
          "SUMMARIZE_CODE"
        ]
      }
    ],
    "import_section": "\"\"\"Summarize source documents into entries using an LLM.\"\"\"\n\nimport asyncio\nimport sys\nfrom datetime import date\nfrom pathlib import Path\n\nfrom .llm import check_model_available, invoke\nfrom .prompts import SUMMARIZE, SUMMARIZE_CODE\n\n\ndef _prepare_source(source_path):\n    \"\"\"Read source file, strip frontmatter, truncate if needed.\n"
  },
  "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"
      },
      {
        "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/propose.py",
        "line": 12,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/propose.py",
        "line": 389,
        "text": "result = invoke_sync(prompt, model=args.model, timeout=600)"
      },
      {
        "file": "expert_build/propose.py",
        "line": 399,
        "text": "retry_response = invoke_sync("
      },
      {
        "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_propose.py",
        "line": 64,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   61:         return _json_beliefs((f\"belief-from-batch-{call_count}\", \"A belief.\"))\n   62: \n   63:     with patch(\"expert_build.propose.check_model_available\", return_value=True), \\\n>> 64:          patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\\n   65:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   66:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n   67:         cmd_propose_beliefs(args)"
      },
      {
        "file": "tests/test_propose.py",
        "line": 88,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   85:         return _json_beliefs((f\"belief-{call_count}\", \"A belief.\"))\n   86: \n   87:     with patch(\"expert_build.propose.check_model_available\", return_value=True), \\\n>> 88:          patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\\n   89:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   90:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n   91:         cmd_propose_beliefs(args)"
      },
      {
        "file": "tests/test_propose.py",
        "line": 113,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   110:         )\n   111: \n   112:     with patch(\"expert_build.propose.check_model_available\", return_value=True), \\\n>> 113:          patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\\n   114:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=existing), \\\n   115:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n   116:         cmd_propose_beliefs(args)"
      },
      {
        "file": "tests/test_propose.py",
        "line": 140,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   137:         return _json_beliefs((f\"belief-{call_count}\", \"A belief.\"))\n   138: \n   139:     with patch(\"expert_build.propose.check_model_available\", return_value=True), \\\n>> 140:          patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\\n   141:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   142:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n   143:         cmd_propose_beliefs(args)"
      },
      {
        "file": "tests/test_propose.py",
        "line": 171,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   168:         return _json_beliefs((\"retried-belief\", \"A belief from retry.\"))\n   169: \n   170:     with patch(\"expert_build.propose.check_model_available\", return_value=True), \\\n>> 171:          patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\\n   172:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   173:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n   174:         cmd_propose_beliefs(args)"
      },
      {
        "file": "tests/test_propose.py",
        "line": 192,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\",
        "context_function": "invoke_side_effect",
        "context_snippet": "   189:         return '```json\\n' + _json_beliefs((\"fenced-belief\", \"A belief.\")) + '\\n```'\n   190: \n   191:     with patch(\"expert_build.propose.check_model_available\", return_value=True), \\\n>> 192:          patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\\n   193:          patch(\"expert_build.propose._load_existing_beliefs\", return_value=[]), \\\n   194:          patch(\"expert_build.propose._has_embeddings\", return_value=False):\n   195:         cmd_propose_beliefs(args)"
      },
      {
        "file": "tests/test_propose.py",
        "line": 216,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 239,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 262,
        "text": "patch(\"expert_build.propose.invoke_sync\", side_effect=invoke_side_effect), \\"
      },
      {
        "file": "tests/test_propose.py",
        "line": 282,
        "text": "patch(\"expert_build.propose.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\"), \\"
      }
    ],
    "production_count": 14,
    "test_count": 26,
    "total_count": 40
  },
  "manifest_write_safety": {
    "function": "_summarize_one",
    "file": "expert_build/summarize.py",
    "calls": [
      {
        "line": 76,
        "name": "_prepare_source"
      },
      {
        "line": 82,
        "name": "print"
      },
      {
        "line": 91,
        "name": "_write_entry"
      },
      {
        "line": 96,
        "name": "done.add"
      },
      {
        "line": 94,
        "name": "manifest.open"
      },
      {
        "line": 95,
        "name": "f.write"
      },
      {
        "line": 96,
        "name": "str"
      },
      {
        "line": 86,
        "name": "invoke"
      }
    ]
  }
}
```

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.
