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

## Code Changes

```diff
diff --git a/expert_build/propose.py b/expert_build/propose.py
index 49a37b7..3b86042 100644
--- a/expert_build/propose.py
+++ b/expert_build/propose.py
@@ -68,15 +68,14 @@ def _load_processed(path: Path) -> dict[str, str]:
     return {}
 
 
-def _save_processed(path: Path, entries: list[Path], existing: dict[str, str]):
-    """Record entries as processed by content hash."""
-    updated = dict(existing)
-    for entry_path in entries:
+def _save_processed(path: Path, new_entries: list[Path], existing: dict[str, str]):
+    """Record new entries as processed by content hash and write to disk."""
+    for entry_path in new_entries:
         content = entry_path.read_text()
         content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
-        updated[str(entry_path)] = content_hash
+        existing[str(entry_path)] = content_hash
     path.parent.mkdir(parents=True, exist_ok=True)
-    path.write_text(json.dumps(updated, indent=2) + "\n")
+    path.write_text(json.dumps(existing, indent=2) + "\n")
 
 
 def _filter_unprocessed(entries: list[Path], processed: dict[str, str]) -> list[Path]:
@@ -378,8 +377,13 @@ 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 = []
+    write_lock = asyncio.Lock()
+
     async def _process_batch(i, batch_text, semaphore):
-        """Process one batch under concurrency limit. Returns (filtered, paths) or None."""
+        """Process one batch and write results immediately."""
+        nonlocal total_skipped
         async with semaphore:
             print(f"  Batch {i + 1}/{len(batches)}...")
             existing_context = _build_dedup_context(
@@ -391,7 +395,7 @@ async def _process_batch(i, batch_text, semaphore):
                 result = await invoke(prompt, model=args.model, timeout=600)
             except Exception as e:
                 print(f"  ERROR: {e}")
-                return None
+                return
 
             beliefs = extract_json(result)
             if not isinstance(beliefs, list):
@@ -406,7 +410,7 @@ async def _process_batch(i, batch_text, semaphore):
                     pass
             if not isinstance(beliefs, list):
                 print("    WARN: could not parse beliefs JSON, skipping batch", file=sys.stderr)
-                return None
+                return
 
             filtered = []
             skipped = 0
@@ -416,39 +420,33 @@ async def _process_batch(i, batch_text, semaphore):
                     skipped += 1
                     continue
                 filtered.append(b)
-            return filtered, batch_paths[i], skipped
+
+        async with write_lock:
+            total_skipped += skipped
+            with output.open("a") as f:
+                for b in filtered:
+                    bid = b.get("id", "unknown")
+                    claim = b.get("claim", "")
+                    source = b.get("source", "")
+                    source_url = b.get("source_url", "")
+                    verdict = "[ACCEPT]" if b.get("accept", True) else "[REJECT]"
+                    f.write(f"### {verdict} {bid}\n")
+                    f.write(f"{claim}\n")
+                    f.write(f"- Source: {source}\n")
+                    f.write(f"- Source URL: {source_url or 'none'}\n\n")
+
+            batch_entries = [Path(p) for p in batch_paths[i]]
+            successful_entries.extend(batch_entries)
+            _save_processed(processed_path, batch_entries, processed)
 
     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())
+        await asyncio.gather(*tasks)
 
-    total_skipped = 0
-    successful_entries = []
-    for result in batch_results:
-        if result is None:
-            continue
-        filtered, paths, skipped = result
-        total_skipped += skipped
-
-        with output.open("a") as f:
-            for b in filtered:
-                bid = b.get("id", "unknown")
-                claim = b.get("claim", "")
-                source = b.get("source", "")
-                source_url = b.get("source_url", "")
-                verdict = "[ACCEPT]" if b.get("accept", True) else "[REJECT]"
-                f.write(f"### {verdict} {bid}\n")
-                f.write(f"{claim}\n")
-                f.write(f"- Source: {source}\n")
-                f.write(f"- Source URL: {source_url or 'none'}\n\n")
-
-        successful_entries.extend(Path(p) for p in paths)
-        _save_processed(processed_path, successful_entries, processed)
+    asyncio.run(run_batches())
 
     if total_skipped:
         print(f"  Filtered {total_skipped} already-accepted beliefs")

```

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