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 b15f174..0f99b53 100644
--- a/expert_build/cli.py
+++ b/expert_build/cli.py
@@ -111,6 +111,18 @@ def main():
     pipe_p.add_argument("--resume", action="store_true",
                         help="Resume from last saved pipeline state")
 
+    # -- derive-review-repair --
+    drr_p = sub.add_parser("derive-review-repair",
+                           help="Run derive/review/repair loop on existing belief network")
+    drr_p.add_argument("--model", default="claude", help="Model to use (default: claude)")
+    drr_p.add_argument("--rounds", type=int, default=3,
+                       help="Max derive/review/repair cycles (default: 3)")
+    drr_p.add_argument("--max-derive-rounds", type=int, default=10,
+                       help="Max derive exhaust rounds per cycle (default: 10)")
+    drr_p.add_argument("--timeout", type=int, default=600,
+                       help="LLM timeout in seconds (default: 600)")
+    drr_p.add_argument("--domain", help="Domain description for derive context")
+
     # -- status --
     sub.add_parser("status", help="Show pipeline progress")
 
@@ -135,6 +147,7 @@ def main():
         "cert-coverage": lambda a: _lazy("coverage", "cmd_cert_coverage")(a),
         "exam": lambda a: _lazy("exam", "cmd_exam")(a),
         "pipeline": lambda a: _lazy("pipeline", "cmd_pipeline")(a),
+        "derive-review-repair": lambda a: _lazy("pipeline", "cmd_derive_review_repair")(a),
         "status": lambda a: _lazy("init_cmd", "cmd_status")(a),
         "install-skill": lambda a: _lazy("init_cmd", "cmd_install_skill")(a),
     }
diff --git a/expert_build/pipeline.py b/expert_build/pipeline.py
index 6dcd4d5..3f33f08 100644
--- a/expert_build/pipeline.py
+++ b/expert_build/pipeline.py
@@ -314,6 +314,93 @@ def _stage_export(args):
     print(f"\nFinal: {in_count} IN / {total} total beliefs", file=sys.stderr)
 
 
+def _run_convergence_loop(args, rounds, on_cycle=None):
+    """Run derive -> review -> repair -> dedup until convergence.
+
+    Returns summary dict with totals across all cycles.
+    """
+    summary = {
+        "cycles": 0,
+        "total_derived": 0,
+        "total_reviewed": 0,
+        "total_invalid": 0,
+        "total_linked": 0,
+        "total_softened": 0,
+        "total_abandoned": 0,
+        "converged": False,
+    }
+
+    for cycle in range(1, rounds + 1):
+        label = f"cycle {cycle}/{rounds}"
+        summary["cycles"] = cycle
+
+        if on_cycle:
+            on_cycle(cycle, "derive_start", None, None)
+
+        added = _stage_derive(args, round_label=label)
+        summary["total_derived"] += added
+
+        review_result = _stage_review(args, round_label=label)
+        invalid_count = review_result.get("invalid", 0)
+        summary["total_reviewed"] += review_result.get("reviewed", 0)
+        summary["total_invalid"] += invalid_count
+
+        repair_result = None
+        if invalid_count > 0:
+            repair_result = _stage_repair(args, review_result, round_label=label)
+            summary["total_linked"] += repair_result.get("linked", 0)
+            summary["total_softened"] += repair_result.get("softened", 0)
+            summary["total_abandoned"] += repair_result.get("abandoned", 0)
+
+        _stage_deduplicate(args, round_label=label)
+
+        if on_cycle:
+            on_cycle(cycle, "cycle_end", added, review_result)
+
+        if invalid_count == 0 and added == 0:
+            print(f"\nConverged after {cycle} cycles "
+                  f"(0 invalids, 0 new derivations)", file=sys.stderr)
+            summary["converged"] = True
+            break
+
+    return summary
+
+
+def cmd_derive_review_repair(args):
+    """Run derive/review/repair loop on existing belief network."""
+    from .caffeinate import hold as _caffeinate
+    _caffeinate()
+
+    if not check_model_available(args.model):
+        print(f"Model not available: {args.model}", file=sys.stderr)
+        sys.exit(1)
+
+    if not Path(REASONS_DB).exists():
+        print(f"Reasons database not found: {REASONS_DB}", file=sys.stderr)
+        print("Run: expert-build pipeline or expert-build accept-beliefs first",
+              file=sys.stderr)
+        sys.exit(1)
+
+    rounds = getattr(args, "rounds", 3)
+    print(f"=== Derive-Review-Repair ===", file=sys.stderr)
+    print(f"Model: {args.model}", file=sys.stderr)
+    print(f"Max rounds: {rounds}", file=sys.stderr)
+    print(f"Max derive rounds per cycle: {args.max_derive_rounds}\n",
+          file=sys.stderr)
+
+    summary = _run_convergence_loop(args, rounds)
+
+    print(f"\n=== Summary ===", file=sys.stderr)
+    print(f"Cycles: {summary['cycles']}", file=sys.stderr)
+    print(f"Derived: {summary['total_derived']}", file=sys.stderr)
+    print(f"Reviewed: {summary['total_reviewed']}", file=sys.stderr)
+    print(f"Invalid: {summary['total_invalid']}", file=sys.stderr)
+    print(f"  Linked: {summary['total_linked']}", file=sys.stderr)
+    print(f"  Softened: {summary['total_softened']}", file=sys.stderr)
+    print(f"  Abandoned: {summary['total_abandoned']}", file=sys.stderr)
+    print(f"Converged: {'yes' if summary['converged'] else 'no'}", file=sys.stderr)
+
+
 def cmd_pipeline(args):
     """Run end-to-end EEM construction pipeline."""
     from .caffeinate import hold as _caffeinate
@@ -382,41 +469,27 @@ def cmd_pipeline(args):
         # Stages 4-7: Derive → Review → Repair → Deduplicate (convergence loop)
         if not state.get("loop_completed"):
             start_cycle = state.get("current_cycle") or 1
-            for cycle in range(start_cycle, args.rounds + 1):
-                label = f"cycle {cycle}/{args.rounds}"
-                state["current_cycle"] = cycle
-                _save_state(state)
-
-                _banner(4, total_stages, f"DERIVE ({label})")
-                _mark_stage(state, 4, "running", cycle=cycle)
-                added = _stage_derive(args, round_label=label)
-                _mark_stage(state, 4, "completed", cycle=cycle, added=added)
-
-                _banner(5, total_stages, f"REVIEW ({label})")
-                _mark_stage(state, 5, "running", cycle=cycle)
-                review_result = _stage_review(args, round_label=label)
-                invalid_count = review_result.get("invalid", 0)
-                _mark_stage(state, 5, "completed", cycle=cycle,
-                            reviewed=review_result.get("reviewed", 0),
-                            invalid=invalid_count)
-
-                if invalid_count > 0:
-                    _banner(6, total_stages, f"REPAIR ({label})")
-                    _mark_stage(state, 6, "running", cycle=cycle)
-                    _stage_repair(args, review_result, round_label=label)
-                    _mark_stage(state, 6, "completed", cycle=cycle)
-                else:
-                    _mark_stage(state, 6, "completed", cycle=cycle, skipped=True)
-
-                _banner(7, total_stages, f"DEDUPLICATE ({label})")
-                _mark_stage(state, 7, "running", cycle=cycle)
-                _stage_deduplicate(args, round_label=label)
-                _mark_stage(state, 7, "completed", cycle=cycle)
-
-                if invalid_count == 0 and added == 0:
-                    print(f"\nConverged after {cycle} cycles "
-                          f"(0 invalids, 0 new derivations)", file=sys.stderr)
-                    break
+            remaining_rounds = args.rounds - start_cycle + 1
+
+            def _pipeline_on_cycle(cycle_num, event, added, review_result):
+                actual_cycle = start_cycle + cycle_num - 1
+                if event == "derive_start":
+                    state["current_cycle"] = actual_cycle
+                    _save_state(state)
+                elif event == "cycle_end":
+                    _mark_stage(state, 4, "completed", cycle=actual_cycle,
+                                added=added or 0)
+                    invalid_count = (review_result.get("invalid", 0)
+                                     if review_result else 0)
+                    _mark_stage(state, 5, "completed", cycle=actual_cycle,
+                                reviewed=(review_result.get("reviewed", 0)
+                                          if review_result else 0),
+                                invalid=invalid_count)
+                    _mark_stage(state, 6, "completed", cycle=actual_cycle)
+                    _mark_stage(state, 7, "completed", cycle=actual_cycle)
+
+            _run_convergence_loop(args, remaining_rounds,
+                                  on_cycle=_pipeline_on_cycle)
 
             state["loop_completed"] = True
             _save_state(state)
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index 458fda6..2026d29 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -8,6 +8,8 @@
 
 from expert_build.pipeline import (
     cmd_pipeline,
+    cmd_derive_review_repair,
+    _run_convergence_loop,
     _stage_ingest,
     _stage_extract,
     _stage_derive,
@@ -49,6 +51,18 @@ def make_pipeline_args(**overrides):
     return types.SimpleNamespace(**defaults)
 
 
+def make_drr_args(**overrides):
+    defaults = dict(
+        model="claude",
+        rounds=3,
+        max_derive_rounds=10,
+        timeout=600,
+        domain="Test domain",
+    )
+    defaults.update(overrides)
+    return types.SimpleNamespace(**defaults)
+
+
 # --- auto_accept_proposals ---
 
 class TestAutoAcceptProposals:
@@ -443,3 +457,116 @@ def test_corrupt_state_file_handled(self, work_dir):
              patch("expert_build.caffeinate.hold"), \
              pytest.raises(SystemExit):
             cmd_pipeline(args)
+
+
+# --- Derive-Review-Repair ---
+
+class TestConvergenceLoop:
+    def test_converges_on_zero_invalids_and_zero_derived(self, work_dir):
+        args = make_drr_args(rounds=3)
+        review_result = {"reviewed": 5, "invalid": 0, "results": []}
+
+        with patch("expert_build.pipeline._stage_derive", return_value=0), \
+             patch("expert_build.pipeline._stage_review", return_value=review_result), \
+             patch("expert_build.pipeline._stage_deduplicate"):
+            summary = _run_convergence_loop(args, rounds=3)
+
+        assert summary["converged"] is True
+        assert summary["cycles"] == 1
+
+    def test_runs_max_rounds_without_convergence(self, work_dir):
+        args = make_drr_args(rounds=2)
+        review_result = {"reviewed": 5, "invalid": 1, "results": [
+            {"belief_id": "b1", "valid": False},
+        ]}
+        repair_result = {"linked": 1, "softened": 0, "abandoned": 0}
+
+        with patch("expert_build.pipeline._stage_derive", return_value=1), \
+             patch("expert_build.pipeline._stage_review", return_value=review_result), \
+             patch("expert_build.pipeline._stage_repair", return_value=repair_result), \
+             patch("expert_build.pipeline._stage_deduplicate"):
+            summary = _run_convergence_loop(args, rounds=2)
+
+        assert summary["converged"] is False
+        assert summary["cycles"] == 2
+        assert summary["total_derived"] == 2
+        assert summary["total_linked"] == 2
+
+    def test_skips_repair_when_no_invalids(self, work_dir):
+        args = make_drr_args(rounds=1)
+        review_result = {"reviewed": 5, "invalid": 0, "results": []}
+
+        with patch("expert_build.pipeline._stage_derive", return_value=1), \
+             patch("expert_build.pipeline._stage_review", return_value=review_result), \
+             patch("expert_build.pipeline._stage_repair") as mock_repair, \
+             patch("expert_build.pipeline._stage_deduplicate"):
+            summary = _run_convergence_loop(args, rounds=1)
+
+        assert not mock_repair.called
+        assert summary["total_invalid"] == 0
+
+    def test_summary_accumulates_across_cycles(self, work_dir):
+        args = make_drr_args(rounds=2)
+        review_result = {"reviewed": 3, "invalid": 1, "results": [
+            {"belief_id": "b1", "valid": False},
+        ]}
+        repair_result = {"linked": 0, "softened": 1, "abandoned": 0}
+
+        with patch("expert_build.pipeline._stage_derive", return_value=2), \
+             patch("expert_build.pipeline._stage_review", return_value=review_result), \
+             patch("expert_build.pipeline._stage_repair", return_value=repair_result), \
+             patch("expert_build.pipeline._stage_deduplicate"):
+            summary = _run_convergence_loop(args, rounds=2)
+
+        assert summary["total_derived"] == 4
+        assert summary["total_reviewed"] == 6
+        assert summary["total_invalid"] == 2
+        assert summary["total_softened"] == 2
+
+    def test_on_cycle_callback_called(self, work_dir):
+        args = make_drr_args(rounds=1)
+        review_result = {"reviewed": 1, "invalid": 0, "results": []}
+        events = []
+
+        def on_cycle(cycle, event, added, review):
+            events.append((cycle, event))
+
+        with patch("expert_build.pipeline._stage_derive", return_value=0), \
+             patch("expert_build.pipeline._stage_review", return_value=review_result), \
+             patch("expert_build.pipeline._stage_deduplicate"):
+            _run_convergence_loop(args, rounds=1, on_cycle=on_cycle)
+
+        assert (1, "derive_start") in events
+        assert (1, "cycle_end") in events
+
+
+class TestCmdDeriveReviewRepair:
+    def test_model_not_available_exits(self, work_dir):
+        args = make_drr_args(model="nonexistent")
+        with patch("expert_build.llm.check_model_available", return_value=False), \
+             pytest.raises(SystemExit):
+            cmd_derive_review_repair(args)
+
+    def test_missing_db_exits(self, work_dir):
+        (work_dir / "reasons.db").unlink()
+        args = make_drr_args()
+        with patch("expert_build.llm.check_model_available", return_value=True), \
+             patch("expert_build.caffeinate.hold"), \
+             pytest.raises(SystemExit):
+            cmd_derive_review_repair(args)
+
+    def test_prints_summary(self, work_dir, capsys):
+        args = make_drr_args(rounds=1)
+        review_result = {"reviewed": 5, "invalid": 0, "results": []}
+
+        with patch("expert_build.llm.check_model_available", return_value=True), \
+             patch("expert_build.pipeline._stage_derive", return_value=0), \
+             patch("expert_build.pipeline._stage_review", return_value=review_result), \
+             patch("expert_build.pipeline._stage_deduplicate"), \
+             patch("expert_build.caffeinate.hold"):
+            cmd_derive_review_repair(args)
+
+        captured = capsys.readouterr()
+        assert "Summary" in captured.err
+        assert "Derived: 0" in captured.err
+        assert "Converged: yes" in captured.err

```

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