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

## Code Changes

```diff
diff --git a/expert_build/cli.py b/expert_build/cli.py
index 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()

```

## Your Task

Analyze the diff and identify what additional information you need to render confident verdicts.
Do NOT render verdicts yet. Only request observations.

## Available Observation Tools

| Tool | Purpose | When to use |
|------|---------|-------------|
| `exception_hierarchy` | Show exception MRO and subclasses | Retry logic, exception handling |
| `raises_analysis` | What exceptions a function raises | New function calls, error paths |
| `call_graph` | What a function calls | Impact analysis |
| `find_usages` | Where a symbol is used (with prod/test split) | Quick integration lookup |
| `find_callers` | Caller analysis with prod/test split and calling context | Method signature changes, return type changes, constructor modifications, integration verification |
| `test_coverage` | Find tests for a file (uses coverage-map if available) | Test coverage claims |
| `coverage_map_tests` | Find tests covering a file (from coverage-map.json) | Precise test coverage from actual execution |
| `coverage_map_files` | Find files covered by tests matching a pattern | Impact analysis for test changes |
| `function_body` | Full source of a function/method | Need complete function context beyond diff hunks |
| `file_imports` | Extract imports from a file | Verify import changes, check dependencies |
| `project_dependencies` | Get pyproject.toml/requirements.txt | Verify new imports have dependencies |
| `related_test_files` | Find test files for a source file | Discover tests by naming, imports, and coverage map |
| `class_hierarchy` | Show base classes and their `__init__` signatures | Class changes its parent, modifies `__init__`, or uses `super()` |
| `symbol_migration` | Check if a rename is complete across the repo | Symbol renamed in diff — verify old name is fully removed |
| `generator_info` | Report whether a function uses `yield` | Function might be a generator — affects return value semantics |

## What to Look For

1. **Exception handling**: Any `retry_if_exception_type`, `except`, or exception class references
2. **New dependencies**: Calls to external libraries where you don't know the error behavior
3. **Behavioral changes**: Modified logic where you need to verify callers/callees
4. **Test claims**: References to tests you can't see in the diff
5. **Inheritance changes**: Class definition changes, new base classes, `super()` calls
6. **Renames**: Symbols that appear to have been renamed in the diff
7. **Factory methods**: Calls to `@classmethod` / `@staticmethod` constructors (e.g. `Result.error(...)`) — request `function_body` to see their implementation

## Output Format

Output a JSON array of observation requests:

```json
[
  {"name": "descriptive_name", "tool": "tool_name", "params": {"param": "value"}},
  ...
]
```

If you don't need any observations (simple changes, all context is in the diff), output:

```json
[]
```

## Examples

For a diff containing `retry_if_exception_type((OSError, httpx.TransportError))`:
```json
[
  {"name": "oserror_subclasses", "tool": "exception_hierarchy", "params": {"class_name": "builtins.OSError"}},
  {"name": "transport_errors", "tool": "exception_hierarchy", "params": {"class_name": "httpx.TransportError"}}
]
```

For a diff adding a new function that calls `oauth_client.get_access_token()`:
```json
[
  {"name": "oauth_exceptions", "tool": "raises_analysis", "params": {"file_path": "src/auth/oauth.py", "function_name": "get_access_token"}}
]
```

For a diff modifying a method but you need the full function to verify:
```json
[
  {"name": "full_getattr", "tool": "function_body", "params": {"file_path": "src/proxy.py", "function_name": "__getattr__"}}
]
```

For a diff changing a method signature or return type (verify all callers):
```json
[
  {"name": "handle_request_callers", "tool": "find_callers", "params": {"symbol": "handle_request"}}
]
```

For a diff adding new imports (e.g., `import httpx`):
```json
[
  {"name": "file_imports", "tool": "file_imports", "params": {"file_path": "src/client.py"}},
  {"name": "project_deps", "tool": "project_dependencies", "params": {}}
]
```

For a diff calling a factory method like `ModuleResult.error_result(msg)`:
```json
[
  {"name": "error_result_body", "tool": "function_body", "params": {"file_path": "src/models.py", "function_name": "error_result"}}
]
```

For a diff where a class changes its parent class:
```json
[
  {"name": "client_hierarchy", "tool": "class_hierarchy", "params": {"class_name": "MyClient", "file_path": "src/client.py"}}
]
```

For a diff that renames a symbol (e.g., `OldClient` to `NewClient`):
```json
[
  {"name": "client_rename", "tool": "symbol_migration", "params": {"old_name": "OldClient", "new_name": "NewClient"}}
]
```

For a diff modifying a function that might be a generator:
```json
[
  {"name": "process_gen", "tool": "generator_info", "params": {"file_path": "src/pipeline.py", "function_name": "process_items"}}
]
```

Now analyze the diff above and output your observation requests as JSON:
