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 66e6ad5..6d25458 100644
--- a/expert_build/propose.py
+++ b/expert_build/propose.py
@@ -349,7 +349,29 @@ def cmd_propose_beliefs(args):
 
     print(f"Processing {len(batches)} batches (batch size: {args.batch_size})...")
 
-    all_proposals = []
+    source_desc = (", ".join(str(e) for e in entries)
+                   if has_entry_flag
+                   else f"{len(entries)} entries from {input_dir}/")
+    output = Path(args.output)
+
+    # Write header before first batch if starting a new file
+    if not (output.exists() and output.stat().st_size > 0):
+        with output.open("w") as f:
+            f.write("# Proposed Beliefs\n\n")
+            f.write("Edit each entry: change `[ACCEPT/REJECT]` to `[ACCEPT]` or `[REJECT]`.\n")
+            f.write("Then run: `expert-build accept-beliefs`\n\n")
+            f.write("---\n\n")
+            f.write(f"**Generated:** {date.today().isoformat()}\n")
+            f.write(f"**Source:** {source_desc}\n")
+            f.write(f"**Model:** {args.model}\n\n")
+    else:
+        with output.open("a") as f:
+            f.write(f"\n---\n\n")
+            f.write(f"**Generated:** {date.today().isoformat()}\n")
+            f.write(f"**Source:** {source_desc}\n")
+            f.write(f"**Model:** {args.model}\n\n")
+
+    total_skipped = 0
     for i, batch_text in enumerate(batches):
         print(f"  Batch {i + 1}/{len(batches)}...")
         existing_context = _build_dedup_context(
@@ -359,18 +381,15 @@ def cmd_propose_beliefs(args):
         prompt = PROPOSE_BELIEFS.format(entries=batch_text) + existing_context
         try:
             result = invoke_sync(prompt, model=args.model, timeout=600)
-            all_proposals.append(result)
         except Exception as e:
             print(f"  ERROR: {e}")
             continue
 
-    # Filter out proposals whose IDs already exist
-    filtered_proposals = []
-    skipped = 0
-    for proposal in all_proposals:
-        lines = proposal.split("\n")
+        # Filter out proposals whose IDs already exist
+        lines = result.split("\n")
         filtered_lines = []
         skip_until_next = False
+        skipped = 0
         for line in lines:
             m = re.match(r"^### \[?(?:ACCEPT|REJECT)\]? (\S+)", line)
             if m:
@@ -387,42 +406,20 @@ def cmd_propose_beliefs(args):
                     filtered_lines.append(line)
                 continue
             filtered_lines.append(line)
-        filtered_proposals.append("\n".join(filtered_lines))
+        total_skipped += skipped
 
-    if skipped:
-        print(f"  Filtered {skipped} already-accepted beliefs")
+        # Write this batch's proposals immediately
+        with output.open("a") as f:
+            f.write("\n".join(filtered_lines))
+            f.write("\n\n")
+
+    if total_skipped:
+        print(f"  Filtered {total_skipped} already-accepted beliefs")
 
     # Record processed entries
     _save_processed(processed_path, entries, processed)
 
-    # Write proposals file (append if it already exists)
-    source_desc = (", ".join(str(e) for e in entries)
-                   if has_entry_flag
-                   else f"{len(entries)} entries from {input_dir}/")
-    output = Path(args.output)
-    if output.exists() and output.stat().st_size > 0:
-        with output.open("a") as f:
-            f.write(f"\n---\n\n")
-            f.write(f"**Generated:** {date.today().isoformat()}\n")
-            f.write(f"**Source:** {source_desc}\n")
-            f.write(f"**Model:** {args.model}\n\n")
-            for proposal in filtered_proposals:
-                f.write(proposal)
-                f.write("\n\n")
-        print(f"\nAppended to {output}")
-    else:
-        with output.open("w") as f:
-            f.write("# Proposed Beliefs\n\n")
-            f.write("Edit each entry: change `[ACCEPT/REJECT]` to `[ACCEPT]` or `[REJECT]`.\n")
-            f.write("Then run: `expert-build accept-beliefs`\n\n")
-            f.write("---\n\n")
-            f.write(f"**Generated:** {date.today().isoformat()}\n")
-            f.write(f"**Source:** {source_desc}\n")
-            f.write(f"**Model:** {args.model}\n\n")
-            for proposal in filtered_proposals:
-                f.write(proposal)
-                f.write("\n\n")
-        print(f"\nWrote {output}")
+    print(f"\nWrote {output}")
 
     print("Review the file, mark entries as [ACCEPT] or [REJECT], then run:")
     print("  expert-build accept-beliefs")
diff --git a/tests/test_propose.py b/tests/test_propose.py
new file mode 100644
index 0000000..c21cb1d
--- /dev/null
+++ b/tests/test_propose.py
@@ -0,0 +1,111 @@
+"""Tests for expert_build.propose — incremental batch writing."""
+
+import types
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from expert_build.propose import cmd_propose_beliefs
+
+
+@pytest.fixture
+def entries_dir(tmp_path):
+    d = tmp_path / "entries"
+    d.mkdir()
+    return d
+
+
+@pytest.fixture
+def work_dir(tmp_path, monkeypatch):
+    wd = tmp_path / "work"
+    wd.mkdir()
+    (wd / ".expert-build").mkdir()
+    monkeypatch.chdir(wd)
+    return wd
+
+
+def make_args(input_dir, output="proposed-beliefs.md", batch_size=2, model="test"):
+    return types.SimpleNamespace(
+        input_dir=str(input_dir),
+        output=output,
+        batch_size=batch_size,
+        model=model,
+        all=False,
+    )
+
+
+def test_proposals_written_after_each_batch(entries_dir, work_dir):
+    """Proposals from completed batches survive a crash in a later batch."""
+    for i in range(4):
+        (entries_dir / f"entry{i}.md").write_text(f"# Entry {i}\nContent {i}")
+
+    output = work_dir / "proposed-beliefs.md"
+    args = make_args(entries_dir, output=str(output), batch_size=2)
+
+    call_count = 0
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        nonlocal call_count
+        call_count += 1
+        if call_count == 2:
+            raise RuntimeError("simulated crash")
+        return f"### [ACCEPT/REJECT] belief-from-batch-{call_count}\nA belief.\n"
+
+    with patch("expert_build.propose.check_model_available", return_value=True), \
+         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
+         patch("expert_build.propose._has_embeddings", return_value=False):
+        cmd_propose_beliefs(args)
+
+    content = output.read_text()
+    assert "belief-from-batch-1" in content
+    assert "belief-from-batch-2" not in content
+
+
+def test_all_batches_written_on_success(entries_dir, work_dir):
+    for i in range(4):
+        (entries_dir / f"entry{i}.md").write_text(f"# Entry {i}\nContent {i}")
+
+    output = work_dir / "proposed-beliefs.md"
+    args = make_args(entries_dir, output=str(output), batch_size=2)
+
+    call_count = 0
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        nonlocal call_count
+        call_count += 1
+        return f"### [ACCEPT/REJECT] belief-{call_count}\nA belief.\n"
+
+    with patch("expert_build.propose.check_model_available", return_value=True), \
+         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose._load_existing_beliefs", return_value=[]), \
+         patch("expert_build.propose._has_embeddings", return_value=False):
+        cmd_propose_beliefs(args)
+
+    content = output.read_text()
+    assert "belief-1" in content
+    assert "belief-2" in content
+
+
+def test_existing_beliefs_filtered_per_batch(entries_dir, work_dir):
+    (entries_dir / "entry0.md").write_text("# Entry\nContent")
+
+    output = work_dir / "proposed-beliefs.md"
+    args = make_args(entries_dir, output=str(output), batch_size=5)
+
+    existing = [{"id": "already-exists", "text": "old belief", "source": ""}]
+
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        return (
+            "### [ACCEPT] already-exists\nDuplicate.\n\n"
+            "### [ACCEPT] new-belief\nFresh.\n"
+        )
+
+    with patch("expert_build.propose.check_model_available", return_value=True), \
+         patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.propose._load_existing_beliefs", return_value=existing), \
+         patch("expert_build.propose._has_embeddings", return_value=False):
+        cmd_propose_beliefs(args)
+
+    content = output.read_text()
+    assert "new-belief" in content
+    assert "already-exists" not in content

```

## Your Task

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

## Available Observation Tools

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

## What to Look For

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

## Output Format

Output a JSON array of observation requests:

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

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

```json
[]
```

## Examples

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

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

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

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

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

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

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

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

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

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