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

## Code Changes

```diff
diff --git a/expert_build/coverage.py b/expert_build/coverage.py
index e9b2186..bb62bc3 100644
--- a/expert_build/coverage.py
+++ b/expert_build/coverage.py
@@ -6,7 +6,7 @@
 
 from reasons_lib.api import list_nodes
 
-from .llm import check_model_available, invoke_sync
+from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON
 from .prompts import CERT_MATCH
 
 REASONS_DB = "reasons.db"
@@ -113,9 +113,16 @@ def cmd_cert_coverage(args):
             prompt = CERT_MATCH.format(objective=obj["text"], beliefs=beliefs_text)
             try:
                 result = invoke_sync(prompt, model=args.model, timeout=120)
-                if result.strip().upper() != "NONE":
-                    for line in result.strip().split("\n"):
-                        bid = line.strip().strip("-").strip()
+                data = extract_json(result)
+                if not isinstance(data, dict) or "matching_ids" not in data:
+                    retry_response = invoke_sync(
+                        prompt + "\n\n" + result + "\n\n" + RETRY_JSON,
+                        model=args.model, timeout=120,
+                    )
+                    data = extract_json(retry_response)
+                if isinstance(data, dict) and "matching_ids" in data:
+                    for bid in data["matching_ids"]:
+                        bid = str(bid).strip()
                         if any(b["id"] == bid for b in beliefs):
                             matches.append((bid, 1.0))
             except Exception as e:
diff --git a/expert_build/exam.py b/expert_build/exam.py
index 0ef7558..d6daab6 100644
--- a/expert_build/exam.py
+++ b/expert_build/exam.py
@@ -1,13 +1,12 @@
 """Practice exam runner for nogood discovery."""
 
-import json
 import re
 import sys
 from pathlib import Path
 
 from reasons_lib.api import add_node, add_nogood, list_nodes
 
-from .llm import check_model_available, invoke_sync
+from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON
 from .prompts import EXAM_ANSWER, EXAM_JUDGE
 
 REASONS_DB = "reasons.db"
@@ -103,33 +102,9 @@ def load_beliefs_for_context(db_path: str = REASONS_DB) -> str:
     return "\n".join(beliefs)
 
 
-RETRY_JSON = "Your response was not valid JSON. Respond with ONLY the JSON object, no other text."
-
-
-def _extract_json(response: str) -> dict | None:
-    """Extract a JSON object from an LLM response."""
-    text = response.strip()
-    if text.startswith("```"):
-        lines = text.split("\n")
-        lines = [l for l in lines if not l.strip().startswith("```")]
-        text = "\n".join(lines).strip()
-    try:
-        return json.loads(text)
-    except (json.JSONDecodeError, ValueError):
-        pass
-    start = text.find("{")
-    end = text.rfind("}")
-    if start != -1 and end > start:
-        try:
-            return json.loads(text[start:end + 1])
-        except (json.JSONDecodeError, ValueError):
-            pass
-    return None
-
-
 def extract_answer(response: str, model: str = None, prompt: str = None) -> str:
     """Extract answer from JSON LLM response, retrying on parse failure."""
-    data = _extract_json(response)
+    data = extract_json(response)
     if data and "answer" in data:
         return str(data["answer"]).strip()
 
@@ -140,7 +115,7 @@ def extract_answer(response: str, model: str = None, prompt: str = None) -> str:
                 prompt + "\n\n" + response + "\n\n" + RETRY_JSON,
                 model=model, timeout=60,
             )
-            data = _extract_json(retry_response)
+            data = extract_json(retry_response)
             if data and "answer" in data:
                 return str(data["answer"]).strip()
         except Exception:
@@ -158,7 +133,7 @@ def judge_answer(question: str, expected: str, got: str, model: str) -> tuple[bo
     except Exception:
         return False, "judge error"
 
-    data = _extract_json(response)
+    data = extract_json(response)
     if data and "verdict" in data:
         is_correct = str(data["verdict"]).strip().upper() == "CORRECT"
         return is_correct, str(data.get("explanation", "")).strip()
@@ -169,7 +144,7 @@ def judge_answer(question: str, expected: str, got: str, model: str) -> tuple[bo
             prompt + "\n\n" + response + "\n\n" + RETRY_JSON,
             model=model, timeout=60,
         )
-        data = _extract_json(retry_response)
+        data = extract_json(retry_response)
         if data and "verdict" in data:
             is_correct = data["verdict"].strip().upper() == "CORRECT"
             return is_correct, data.get("explanation", "")
diff --git a/expert_build/llm.py b/expert_build/llm.py
index 8a54d07..1ca48b2 100644
--- a/expert_build/llm.py
+++ b/expert_build/llm.py
@@ -1,6 +1,7 @@
 """Model invocation for expert agent builder."""
 
 import asyncio
+import json
 import os
 import shutil
 
@@ -56,3 +57,36 @@ async def invoke(prompt: str, model: str = "claude", timeout: int = DEFAULT_TIME
 def invoke_sync(prompt: str, model: str = "claude", timeout: int = DEFAULT_TIMEOUT) -> str:
     """Synchronous wrapper for invoke."""
     return asyncio.run(invoke(prompt, model, timeout))
+
+
+RETRY_JSON = "Your response was not valid JSON. Respond with ONLY the JSON object, no other text."
+
+
+def extract_json(response: str) -> dict | list | None:
+    """Extract a JSON object or array from an LLM response."""
+    text = response.strip()
+    if text.startswith("```"):
+        lines = text.split("\n")
+        lines = [l for l in lines if not l.strip().startswith("```")]
+        text = "\n".join(lines).strip()
+    try:
+        return json.loads(text)
+    except (json.JSONDecodeError, ValueError):
+        pass
+    start = text.find("{")
+    start_arr = text.find("[")
+    if start_arr != -1 and (start == -1 or start_arr < start):
+        end = text.rfind("]")
+        if end > start_arr:
+            try:
+                return json.loads(text[start_arr:end + 1])
+            except (json.JSONDecodeError, ValueError):
+                pass
+    if start != -1:
+        end = text.rfind("}")
+        if end > start:
+            try:
+                return json.loads(text[start:end + 1])
+            except (json.JSONDecodeError, ValueError):
+                pass
+    return None
diff --git a/expert_build/prompts.py b/expert_build/prompts.py
index d3c6d30..8144a2d 100644
--- a/expert_build/prompts.py
+++ b/expert_build/prompts.py
@@ -66,14 +66,6 @@
 PROPOSE_BELIEFS = """\
 You are extracting factual claims from study notes to build a belief registry.
 
-For each significant factual claim in the entries below, output a proposed belief \
-in this exact format:
-
-### [ACCEPT/REJECT] <belief-id-in-kebab-case>
-<one-line factual claim>
-- Source: <path to the entry file>
-- Source URL: <url from SOURCE_URL in file header, or "none" if not present>
-
 Rules:
 - Each belief should be a single, testable factual claim
 - Use kebab-case IDs that are descriptive (e.g., rhel9-default-filesystem-xfs)
@@ -87,6 +79,11 @@
 ENTRIES:
 
 {entries}
+
+---
+
+Respond with ONLY this JSON array (no other text):
+[{{"id": "<kebab-case-id>", "claim": "<one-line factual claim>", "source": "<path to entry file>", "source_url": "<url from SOURCE_URL in header, or empty string>"}}]
 """
 
 EXAM_ANSWER = """\
@@ -125,13 +122,14 @@
 
 CERT_MATCH = """\
 Given a certification objective and a list of beliefs, determine which beliefs \
-(if any) cover this objective. Return the belief IDs that match, one per line. \
-If none match, return "NONE".
+(if any) cover this objective.
 
 Objective: {objective}
 
 Beliefs:
 {beliefs}
 
-Matching belief IDs (one per line, or NONE):
+Respond with ONLY this JSON (no other text):
+{{"matching_ids": ["belief-id-1", "belief-id-2"]}}
+Use an empty array if none match: {{"matching_ids": []}}
 """
diff --git a/expert_build/propose.py b/expert_build/propose.py
index 990a03f..d38db8a 100644
--- a/expert_build/propose.py
+++ b/expert_build/propose.py
@@ -9,7 +9,7 @@
 
 from reasons_lib.api import add_node, list_nodes
 
-from .llm import check_model_available, invoke_sync
+from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON
 from .prompts import PROPOSE_BELIEFS
 
 PROJECT_DIR = ".expert-build"
@@ -387,33 +387,44 @@ def cmd_propose_beliefs(args):
             print(f"  ERROR: {e}")
             continue
 
+        # Parse JSON response
+        beliefs = extract_json(result)
+        if not isinstance(beliefs, list):
+            print("    WARN: response not valid JSON, retrying...", file=sys.stderr)
+            try:
+                retry_response = invoke_sync(
+                    prompt + "\n\n" + result + "\n\n" + RETRY_JSON,
+                    model=args.model, timeout=600,
+                )
+                beliefs = extract_json(retry_response)
+            except Exception:
+                pass
+        if not isinstance(beliefs, list):
+            print("    WARN: could not parse beliefs JSON, skipping batch", file=sys.stderr)
+            continue
+
         # 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\]|\[?(?:ACCEPT|REJECT)\]?) (\S+)", line)
-            if m:
-                belief_id = m.group(1)
-                if belief_id in existing_ids:
-                    skip_until_next = True
-                    skipped += 1
-                    continue
-                else:
-                    skip_until_next = False
-            if skip_until_next:
-                if line.startswith("### "):
-                    skip_until_next = False
-                    filtered_lines.append(line)
+        filtered = []
+        for b in beliefs:
+            bid = b.get("id", "")
+            if bid in existing_ids:
+                skipped += 1
                 continue
-            filtered_lines.append(line)
+            filtered.append(b)
         total_skipped += skipped
 
-        # Write this batch's proposals immediately
+        # Write this batch's proposals as markdown for human review
         with output.open("a") as f:
-            f.write("\n".join(filtered_lines))
-            f.write("\n\n")
+            for b in filtered:
+                bid = b.get("id", "unknown")
+                claim = b.get("claim", "")
+                source = b.get("source", "")
+                source_url = b.get("source_url", "")
+                f.write(f"### [ACCEPT/REJECT] {bid}\n")
+                f.write(f"{claim}\n")
+                f.write(f"- Source: {source}\n")
+                f.write(f"- Source URL: {source_url or 'none'}\n\n")
 
         # Record this batch's entries as processed
         successful_entries.extend(Path(p) for p in batch_paths[i])
diff --git a/tests/test_coverage.py b/tests/test_coverage.py
new file mode 100644
index 0000000..561f499
--- /dev/null
+++ b/tests/test_coverage.py
@@ -0,0 +1,131 @@
+"""Tests for expert_build.coverage — JSON cert-match parsing."""
+
+import json
+import types
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from expert_build.coverage import cmd_cert_coverage
+
+
+@pytest.fixture
+def objectives_file(tmp_path):
+    p = tmp_path / "objectives.md"
+    p.write_text("# Storage\n- Configure local storage\n- Manage LVM volumes\n")
+    return p
+
+
+@pytest.fixture
+def beliefs_db(tmp_path):
+    db = tmp_path / "reasons.db"
+    db.write_text("")
+    return db
+
+
+def make_args(objectives_file, beliefs_file, model="test"):
+    return types.SimpleNamespace(
+        objectives_file=str(objectives_file),
+        beliefs_file=beliefs_file,
+        model=model,
+    )
+
+
+FAKE_BELIEFS = [
+    {"id": "local-storage-config", "text": "Local storage is configured via /etc/fstab"},
+    {"id": "lvm-basics", "text": "LVM uses PVs, VGs, and LVs for volume management"},
+    {"id": "unrelated-belief", "text": "SELinux enforces mandatory access control"},
+]
+
+
+def test_json_matching(objectives_file, beliefs_db, capsys):
+    """LLM returns valid JSON with matching belief IDs."""
+    args = make_args(objectives_file, beliefs_db)
+
+    call_count = 0
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        nonlocal call_count
+        call_count += 1
+        if "Configure local storage" in prompt:
+            return json.dumps({"matching_ids": ["local-storage-config"]})
+        return json.dumps({"matching_ids": ["lvm-basics"]})
+
+    with patch("expert_build.coverage.check_model_available", return_value=True), \
+         patch("expert_build.coverage.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.coverage.load_beliefs", return_value=FAKE_BELIEFS):
+        cmd_cert_coverage(args)
+
+    output = capsys.readouterr().out
+    assert "local-storage-config" in output
+    assert "lvm-basics" in output
+
+
+def test_json_empty_matches(objectives_file, beliefs_db, capsys):
+    """LLM returns empty matching_ids array — falls back to keyword."""
+    args = make_args(objectives_file, beliefs_db)
+
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        return json.dumps({"matching_ids": []})
+
+    with patch("expert_build.coverage.check_model_available", return_value=True), \
+         patch("expert_build.coverage.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.coverage.load_beliefs", return_value=FAKE_BELIEFS):
+        cmd_cert_coverage(args)
+
+    output = capsys.readouterr().out
+    assert "GAPS" in output or "COVERED" in output
+
+
+def test_json_retry_on_bad_response(objectives_file, beliefs_db, capsys):
+    """When LLM returns non-JSON, retry and parse the retry response."""
+    args = make_args(objectives_file, beliefs_db)
+
+    call_count = 0
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        nonlocal call_count
+        call_count += 1
+        if call_count <= 2:
+            return "I think the matching beliefs are local-storage-config and lvm-basics"
+        return json.dumps({"matching_ids": ["local-storage-config"]})
+
+    with patch("expert_build.coverage.check_model_available", return_value=True), \
+         patch("expert_build.coverage.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.coverage.load_beliefs", return_value=FAKE_BELIEFS):
+        cmd_cert_coverage(args)
+
+    output = capsys.readouterr().out
+    assert call_count >= 3
+
+
+def test_json_with_code_fence(objectives_file, beliefs_db, capsys):
+    """LLM response wrapped in code fences is parsed correctly."""
+    args = make_args(objectives_file, beliefs_db)
+
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        return '```json\n{"matching_ids": ["local-storage-config"]}\n```'
+
+    with patch("expert_build.coverage.check_model_available", return_value=True), \
+         patch("expert_build.coverage.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.coverage.load_beliefs", return_value=FAKE_BELIEFS):
+        cmd_cert_coverage(args)
+
+    output = capsys.readouterr().out
+    assert "local-storage-config" in output
+
+
+def test_invalid_belief_ids_ignored(objectives_file, beliefs_db, capsys):
+    """Belief IDs not in the known beliefs list are silently ignored."""
+    args = make_args(objectives_file, beliefs_db)
+
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        return json.dumps({"matching_ids": ["nonexistent-belief", "local-storage-config"]})
+
+    with patch("expert_build.coverage.check_model_available", return_value=True), \
+         patch("expert_build.coverage.invoke_sync", side_effect=invoke_side_effect), \
+         patch("expert_build.coverage.load_beliefs", return_value=FAKE_BELIEFS):
+        cmd_cert_coverage(args)
+
+    output = capsys.readouterr().out
+    assert "local-storage-config" in output
+    assert "nonexistent-belief" not in output
diff --git a/tests/test_exam.py b/tests/test_exam.py
index a8d9240..90ac551 100644
--- a/tests/test_exam.py
+++ b/tests/test_exam.py
@@ -2,41 +2,64 @@
 
 from unittest.mock import patch
 
-from expert_build.exam import extract_answer, judge_answer, _extract_json
+from expert_build.exam import extract_answer, judge_answer
+from expert_build.llm import extract_json
 
 
-# --- _extract_json ---
+# --- extract_json ---
 
 def test_extract_json_plain():
-    assert _extract_json('{"answer": "b", "explanation": "because"}') == {
+    assert extract_json('{"answer": "b", "explanation": "because"}') == {
         "answer": "b", "explanation": "because"
     }
 
 
 def test_extract_json_with_code_fence():
     response = '```json\n{"answer": "c", "explanation": "reason"}\n```'
-    assert _extract_json(response) == {"answer": "c", "explanation": "reason"}
+    assert extract_json(response) == {"answer": "c", "explanation": "reason"}
 
 
 def test_extract_json_embedded_in_text():
     response = 'Here is my answer:\n{"answer": "a", "explanation": "yes"}\nDone.'
-    result = _extract_json(response)
+    result = extract_json(response)
     assert result["answer"] == "a"
 
 
 def test_extract_json_braces_in_value():
     response = 'Sure: {"answer": "b", "explanation": "use {braces} here"}'
-    result = _extract_json(response)
+    result = extract_json(response)
     assert result["answer"] == "b"
     assert "{braces}" in result["explanation"]
 
 
 def test_extract_json_invalid():
-    assert _extract_json("No JSON here at all") is None
+    assert extract_json("No JSON here at all") is None
 
 
 def test_extract_json_truncated():
-    assert _extract_json('{"answer": "b", "explan') is None
+    assert extract_json('{"answer": "b", "explan') is None
+
+
+def test_extract_json_array():
+    response = '[{"id": "a"}, {"id": "b"}]'
+    result = extract_json(response)
+    assert isinstance(result, list)
+    assert len(result) == 2
+    assert result[0]["id"] == "a"
+
+
+def test_extract_json_array_in_text():
+    response = 'Here are the beliefs:\n[{"id": "a", "claim": "test"}]\nDone.'
+    result = extract_json(response)
+    assert isinstance(result, list)
+    assert result[0]["id"] == "a"
+
+
+def test_extract_json_array_with_code_fence():
+    response = '```json\n[{"id": "a"}]\n```'
+    result = extract_json(response)
+    assert isinstance(result, list)
+    assert result[0]["id"] == "a"
 
 
 # --- extract_answer ---
diff --git a/tests/test_propose.py b/tests/test_propose.py
index a2c2ff9..77cc30a 100644
--- a/tests/test_propose.py
+++ b/tests/test_propose.py
@@ -1,4 +1,4 @@
-"""Tests for expert_build.propose — incremental batch writing."""
+"""Tests for expert_build.propose — JSON belief parsing and incremental batch writing."""
 
 import json
 import types
@@ -36,6 +36,14 @@ def make_args(input_dir, output="proposed-beliefs.md", batch_size=2, model="test
     )
 
 
+def _json_beliefs(*beliefs):
+    """Helper to build a JSON response from (id, claim) tuples."""
+    return json.dumps([
+        {"id": b[0], "claim": b[1], "source": "entry.md", "source_url": ""}
+        for b in beliefs
+    ])
+
+
 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):
@@ -50,7 +58,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         call_count += 1
         if call_count == 2:
             raise RuntimeError("simulated crash")
-        return f"### [ACCEPT/REJECT] belief-from-batch-{call_count}\nA belief.\n"
+        return _json_beliefs((f"belief-from-batch-{call_count}", "A belief."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
          patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
@@ -74,7 +82,7 @@ def test_all_batches_written_on_success(entries_dir, work_dir):
     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"
+        return _json_beliefs((f"belief-{call_count}", "A belief."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
          patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
@@ -96,9 +104,9 @@ def test_existing_beliefs_filtered_per_batch(entries_dir, work_dir):
     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"
+        return _json_beliefs(
+            ("already-exists", "Duplicate."),
+            ("new-belief", "Fresh."),
         )
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
@@ -126,7 +134,7 @@ def invoke_side_effect(prompt, model=None, timeout=None):
         call_count += 1
         if call_count == 2:
             raise RuntimeError("simulated crash")
-        return f"### [ACCEPT/REJECT] belief-{call_count}\nA belief.\n"
+        return _json_beliefs((f"belief-{call_count}", "A belief."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
          patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \
@@ -144,30 +152,50 @@ def invoke_side_effect(prompt, model=None, timeout=None):
     assert not any("entry3" in k for k in processed)
 
 
-def test_filter_regex_matches_accept_reject_placeholder(entries_dir, work_dir):
-    """The dedup filter handles [ACCEPT/REJECT] placeholder from LLM output."""
+def test_json_retry_on_bad_response(entries_dir, work_dir):
+    """When LLM returns non-JSON, retry and parse the retry response."""
     (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": "old-belief", "text": "existing", "source": ""}]
+    call_count = 0
+    def invoke_side_effect(prompt, model=None, timeout=None):
+        nonlocal call_count
+        call_count += 1
+        if call_count == 1:
+            return "Here are some beliefs about the code..."
+        return _json_beliefs(("retried-belief", "A belief from retry."))
+
+    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 "retried-belief" in content
+    assert call_count == 2
+
+
+def test_json_with_code_fence(entries_dir, work_dir):
+    """LLM response wrapped in code fences is parsed correctly."""
+    (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)
 
     def invoke_side_effect(prompt, model=None, timeout=None):
-        return (
-            "### [ACCEPT/REJECT] old-belief\nDuplicate.\n\n"
-            "### [ACCEPT/REJECT] new-belief\nFresh.\n"
-        )
+        return '```json\n' + _json_beliefs(("fenced-belief", "A 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=existing), \
+         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 "new-belief" in content
-    assert "old-belief" not in content
+    assert "fenced-belief" in content
 
 
 def test_appends_to_existing_output_file(entries_dir, work_dir):
@@ -179,7 +207,7 @@ def test_appends_to_existing_output_file(entries_dir, work_dir):
     args = make_args(entries_dir, output=str(output), batch_size=5)
 
     def invoke_side_effect(prompt, model=None, timeout=None):
-        return "### [ACCEPT/REJECT] new-belief\nFresh.\n"
+        return _json_beliefs(("new-belief", "Fresh."))
 
     with patch("expert_build.propose.check_model_available", return_value=True), \
          patch("expert_build.propose.invoke_sync", side_effect=invoke_side_effect), \

```

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