You are a senior code reviewer. Review the following code changes.

## Specification

No specification provided. Focus on correctness, tests, and integration.





## 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), \

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "extract_json_rename_complete": {
    "old_name": "_extract_json",
    "old_name_usages": [],
    "old_name_count": 0,
    "stale_references": [],
    "stale_count": 0,
    "migration_complete": true,
    "new_name": "extract_json",
    "new_name_usages": [
      {
        "file": "tests/test_exam.py",
        "line": 6,
        "text": "from expert_build.llm import extract_json"
      },
      {
        "file": "tests/test_exam.py",
        "line": 9,
        "text": "# --- extract_json ---"
      },
      {
        "file": "tests/test_exam.py",
        "line": 12,
        "text": "assert extract_json('{\"answer\": \"b\", \"explanation\": \"because\"}') == {"
      },
      {
        "file": "tests/test_exam.py",
        "line": 19,
        "text": "assert extract_json(response) == {\"answer\": \"c\", \"explanation\": \"reason\"}"
      },
      {
        "file": "tests/test_exam.py",
        "line": 24,
        "text": "result = extract_json(response)"
      },
      {
        "file": "tests/test_exam.py",
        "line": 30,
        "text": "result = extract_json(response)"
      },
      {
        "file": "tests/test_exam.py",
        "line": 36,
        "text": "assert extract_json(\"No JSON here at all\") is None"
      },
      {
        "file": "tests/test_exam.py",
        "line": 40,
        "text": "assert extract_json('{\"answer\": \"b\", \"explan') is None"
      },
      {
        "file": "tests/test_exam.py",
        "line": 45,
        "text": "result = extract_json(response)"
      },
      {
        "file": "tests/test_exam.py",
        "line": 53,
        "text": "result = extract_json(response)"
      },
      {
        "file": "tests/test_exam.py",
        "line": 60,
        "text": "result = extract_json(response)"
      },
      {
        "file": "expert_build/exam.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/exam.py",
        "line": 107,
        "text": "data = extract_json(response)"
      },
      {
        "file": "expert_build/exam.py",
        "line": 118,
        "text": "data = extract_json(retry_response)"
      },
      {
        "file": "expert_build/exam.py",
        "line": 136,
        "text": "data = extract_json(response)"
      },
      {
        "file": "expert_build/exam.py",
        "line": 147,
        "text": "data = extract_json(retry_response)"
      },
      {
        "file": "expert_build/propose.py",
        "line": 12,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/propose.py",
        "line": 391,
        "text": "beliefs = extract_json(result)"
      },
      {
        "file": "expert_build/propose.py",
        "line": 399,
        "text": "beliefs = extract_json(retry_response)"
      },
      {
        "file": "expert_build/llm.py",
        "line": 65,
        "text": "def extract_json(response: str) -> dict | list | None:"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 116,
        "text": "data = extract_json(result)"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 122,
        "text": "data = extract_json(retry_response)"
      }
    ],
    "new_name_count": 23
  },
  "retry_json_move_complete": {
    "symbol": "RETRY_JSON",
    "usages": [
      {
        "file": "expert_build/exam.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/exam.py",
        "line": 115,
        "text": "prompt + \"\\n\\n\" + response + \"\\n\\n\" + RETRY_JSON,"
      },
      {
        "file": "expert_build/exam.py",
        "line": 144,
        "text": "prompt + \"\\n\\n\" + response + \"\\n\\n\" + RETRY_JSON,"
      },
      {
        "file": "expert_build/propose.py",
        "line": 12,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/propose.py",
        "line": 396,
        "text": "prompt + \"\\n\\n\" + result + \"\\n\\n\" + RETRY_JSON,"
      },
      {
        "file": "expert_build/llm.py",
        "line": 62,
        "text": "RETRY_JSON = \"Your response was not valid JSON. Respond with ONLY the JSON object, no other text.\""
      },
      {
        "file": "expert_build/coverage.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 119,
        "text": "prompt + \"\\n\\n\" + result + \"\\n\\n\" + RETRY_JSON,"
      }
    ],
    "production_usages": [
      {
        "file": "expert_build/exam.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/exam.py",
        "line": 115,
        "text": "prompt + \"\\n\\n\" + response + \"\\n\\n\" + RETRY_JSON,"
      },
      {
        "file": "expert_build/exam.py",
        "line": 144,
        "text": "prompt + \"\\n\\n\" + response + \"\\n\\n\" + RETRY_JSON,"
      },
      {
        "file": "expert_build/propose.py",
        "line": 12,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/propose.py",
        "line": 396,
        "text": "prompt + \"\\n\\n\" + result + \"\\n\\n\" + RETRY_JSON,"
      },
      {
        "file": "expert_build/llm.py",
        "line": 62,
        "text": "RETRY_JSON = \"Your response was not valid JSON. Respond with ONLY the JSON object, no other text.\""
      },
      {
        "file": "expert_build/coverage.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 119,
        "text": "prompt + \"\\n\\n\" + result + \"\\n\\n\" + RETRY_JSON,"
      }
    ],
    "test_usages": [],
    "production_count": 8,
    "test_count": 0,
    "total_count": 8
  },
  "extract_json_callers": {
    "symbol": "extract_json",
    "production_callers": [
      {
        "file": "expert_build/exam.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/exam.py",
        "line": 107,
        "text": "data = extract_json(response)"
      },
      {
        "file": "expert_build/exam.py",
        "line": 118,
        "text": "data = extract_json(retry_response)"
      },
      {
        "file": "expert_build/exam.py",
        "line": 136,
        "text": "data = extract_json(response)"
      },
      {
        "file": "expert_build/exam.py",
        "line": 147,
        "text": "data = extract_json(retry_response)"
      },
      {
        "file": "expert_build/propose.py",
        "line": 12,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/propose.py",
        "line": 391,
        "text": "beliefs = extract_json(result)"
      },
      {
        "file": "expert_build/propose.py",
        "line": 399,
        "text": "beliefs = extract_json(retry_response)"
      },
      {
        "file": "expert_build/llm.py",
        "line": 65,
        "text": "def extract_json(response: str) -> dict | list | None:"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 9,
        "text": "from .llm import check_model_available, extract_json, invoke_sync, RETRY_JSON"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 116,
        "text": "data = extract_json(result)"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 122,
        "text": "data = extract_json(retry_response)"
      }
    ],
    "test_callers": [
      {
        "file": "tests/test_exam.py",
        "line": 6,
        "text": "from expert_build.llm import extract_json",
        "context_function": null,
        "context_snippet": "   3: from unittest.mock import patch\n   4: \n   5: from expert_build.exam import extract_answer, judge_answer\n>> 6: from expert_build.llm import extract_json\n   7: \n   8: \n   9: # --- extract_json ---"
      },
      {
        "file": "tests/test_exam.py",
        "line": 9,
        "text": "# --- extract_json ---",
        "context_function": null,
        "context_snippet": "   6: from expert_build.llm import extract_json\n   7: \n   8: \n>> 9: # --- extract_json ---\n   10: \n   11: def test_extract_json_plain():\n   12:     assert extract_json('{\"answer\": \"b\", \"explanation\": \"because\"}') == {"
      },
      {
        "file": "tests/test_exam.py",
        "line": 11,
        "text": "def test_extract_json_plain():",
        "context_function": null,
        "context_snippet": "   8: \n   9: # --- extract_json ---\n   10: \n>> 11: def test_extract_json_plain():\n   12:     assert extract_json('{\"answer\": \"b\", \"explanation\": \"because\"}') == {\n   13:         \"answer\": \"b\", \"explanation\": \"because\"\n   14:     }"
      },
      {
        "file": "tests/test_exam.py",
        "line": 12,
        "text": "assert extract_json('{\"answer\": \"b\", \"explanation\": \"because\"}') == {",
        "context_function": "test_extract_json_plain",
        "context_snippet": "   9: # --- extract_json ---\n   10: \n   11: def test_extract_json_plain():\n>> 12:     assert extract_json('{\"answer\": \"b\", \"explanation\": \"because\"}') == {\n   13:         \"answer\": \"b\", \"explanation\": \"because\"\n   14:     }\n   15: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 17,
        "text": "def test_extract_json_with_code_fence():",
        "context_function": "test_extract_json_plain",
        "context_snippet": "   14:     }\n   15: \n   16: \n>> 17: def test_extract_json_with_code_fence():\n   18:     response = '```json\\n{\"answer\": \"c\", \"explanation\": \"reason\"}\\n```'\n   19:     assert extract_json(response) == {\"answer\": \"c\", \"explanation\": \"reason\"}\n   20: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 19,
        "text": "assert extract_json(response) == {\"answer\": \"c\", \"explanation\": \"reason\"}",
        "context_function": "test_extract_json_with_code_fence",
        "context_snippet": "   16: \n   17: def test_extract_json_with_code_fence():\n   18:     response = '```json\\n{\"answer\": \"c\", \"explanation\": \"reason\"}\\n```'\n>> 19:     assert extract_json(response) == {\"answer\": \"c\", \"explanation\": \"reason\"}\n   20: \n   21: \n   22: def test_extract_json_embedded_in_text():"
      },
      {
        "file": "tests/test_exam.py",
        "line": 22,
        "text": "def test_extract_json_embedded_in_text():",
        "context_function": "test_extract_json_with_code_fence",
        "context_snippet": "   19:     assert extract_json(response) == {\"answer\": \"c\", \"explanation\": \"reason\"}\n   20: \n   21: \n>> 22: def test_extract_json_embedded_in_text():\n   23:     response = 'Here is my answer:\\n{\"answer\": \"a\", \"explanation\": \"yes\"}\\nDone.'\n   24:     result = extract_json(response)\n   25:     assert result[\"answer\"] == \"a\""
      },
      {
        "file": "tests/test_exam.py",
        "line": 24,
        "text": "result = extract_json(response)",
        "context_function": "test_extract_json_embedded_in_text",
        "context_snippet": "   21: \n   22: def test_extract_json_embedded_in_text():\n   23:     response = 'Here is my answer:\\n{\"answer\": \"a\", \"explanation\": \"yes\"}\\nDone.'\n>> 24:     result = extract_json(response)\n   25:     assert result[\"answer\"] == \"a\"\n   26: \n   27: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 28,
        "text": "def test_extract_json_braces_in_value():",
        "context_function": "test_extract_json_embedded_in_text",
        "context_snippet": "   25:     assert result[\"answer\"] == \"a\"\n   26: \n   27: \n>> 28: def test_extract_json_braces_in_value():\n   29:     response = 'Sure: {\"answer\": \"b\", \"explanation\": \"use {braces} here\"}'\n   30:     result = extract_json(response)\n   31:     assert result[\"answer\"] == \"b\""
      },
      {
        "file": "tests/test_exam.py",
        "line": 30,
        "text": "result = extract_json(response)",
        "context_function": "test_extract_json_braces_in_value",
        "context_snippet": "   27: \n   28: def test_extract_json_braces_in_value():\n   29:     response = 'Sure: {\"answer\": \"b\", \"explanation\": \"use {braces} here\"}'\n>> 30:     result = extract_json(response)\n   31:     assert result[\"answer\"] == \"b\"\n   32:     assert \"{braces}\" in result[\"explanation\"]\n   33: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 35,
        "text": "def test_extract_json_invalid():",
        "context_function": "test_extract_json_braces_in_value",
        "context_snippet": "   32:     assert \"{braces}\" in result[\"explanation\"]\n   33: \n   34: \n>> 35: def test_extract_json_invalid():\n   36:     assert extract_json(\"No JSON here at all\") is None\n   37: \n   38: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 36,
        "text": "assert extract_json(\"No JSON here at all\") is None",
        "context_function": "test_extract_json_invalid",
        "context_snippet": "   33: \n   34: \n   35: def test_extract_json_invalid():\n>> 36:     assert extract_json(\"No JSON here at all\") is None\n   37: \n   38: \n   39: def test_extract_json_truncated():"
      },
      {
        "file": "tests/test_exam.py",
        "line": 39,
        "text": "def test_extract_json_truncated():",
        "context_function": "test_extract_json_invalid",
        "context_snippet": "   36:     assert extract_json(\"No JSON here at all\") is None\n   37: \n   38: \n>> 39: def test_extract_json_truncated():\n   40:     assert extract_json('{\"answer\": \"b\", \"explan') is None\n   41: \n   42: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 40,
        "text": "assert extract_json('{\"answer\": \"b\", \"explan') is None",
        "context_function": "test_extract_json_truncated",
        "context_snippet": "   37: \n   38: \n   39: def test_extract_json_truncated():\n>> 40:     assert extract_json('{\"answer\": \"b\", \"explan') is None\n   41: \n   42: \n   43: def test_extract_json_array():"
      },
      {
        "file": "tests/test_exam.py",
        "line": 43,
        "text": "def test_extract_json_array():",
        "context_function": "test_extract_json_truncated",
        "context_snippet": "   40:     assert extract_json('{\"answer\": \"b\", \"explan') is None\n   41: \n   42: \n>> 43: def test_extract_json_array():\n   44:     response = '[{\"id\": \"a\"}, {\"id\": \"b\"}]'\n   45:     result = extract_json(response)\n   46:     assert isinstance(result, list)"
      },
      {
        "file": "tests/test_exam.py",
        "line": 45,
        "text": "result = extract_json(response)",
        "context_function": "test_extract_json_array",
        "context_snippet": "   42: \n   43: def test_extract_json_array():\n   44:     response = '[{\"id\": \"a\"}, {\"id\": \"b\"}]'\n>> 45:     result = extract_json(response)\n   46:     assert isinstance(result, list)\n   47:     assert len(result) == 2\n   48:     assert result[0][\"id\"] == \"a\""
      },
      {
        "file": "tests/test_exam.py",
        "line": 51,
        "text": "def test_extract_json_array_in_text():",
        "context_function": "test_extract_json_array",
        "context_snippet": "   48:     assert result[0][\"id\"] == \"a\"\n   49: \n   50: \n>> 51: def test_extract_json_array_in_text():\n   52:     response = 'Here are the beliefs:\\n[{\"id\": \"a\", \"claim\": \"test\"}]\\nDone.'\n   53:     result = extract_json(response)\n   54:     assert isinstance(result, list)"
      },
      {
        "file": "tests/test_exam.py",
        "line": 53,
        "text": "result = extract_json(response)",
        "context_function": "test_extract_json_array_in_text",
        "context_snippet": "   50: \n   51: def test_extract_json_array_in_text():\n   52:     response = 'Here are the beliefs:\\n[{\"id\": \"a\", \"claim\": \"test\"}]\\nDone.'\n>> 53:     result = extract_json(response)\n   54:     assert isinstance(result, list)\n   55:     assert result[0][\"id\"] == \"a\"\n   56: "
      },
      {
        "file": "tests/test_exam.py",
        "line": 58,
        "text": "def test_extract_json_array_with_code_fence():",
        "context_function": "test_extract_json_array_in_text",
        "context_snippet": "   55:     assert result[0][\"id\"] == \"a\"\n   56: \n   57: \n>> 58: def test_extract_json_array_with_code_fence():\n   59:     response = '```json\\n[{\"id\": \"a\"}]\\n```'\n   60:     result = extract_json(response)\n   61:     assert isinstance(result, list)"
      },
      {
        "file": "tests/test_exam.py",
        "line": 60,
        "text": "result = extract_json(response)",
        "context_function": "test_extract_json_array_with_code_fence",
        "context_snippet": "   57: \n   58: def test_extract_json_array_with_code_fence():\n   59:     response = '```json\\n[{\"id\": \"a\"}]\\n```'\n>> 60:     result = extract_json(response)\n   61:     assert isinstance(result, list)\n   62:     assert result[0][\"id\"] == \"a\"\n   63: "
      }
    ],
    "production_count": 12,
    "test_count": 20,
    "total_count": 32
  },
  "cmd_cert_coverage_body": {
    "function": "cmd_cert_coverage",
    "file": "expert_build/coverage.py",
    "start_line": 76,
    "end_line": 177,
    "source": "def cmd_cert_coverage(args):\n    \"\"\"Map certification objectives to beliefs, report coverage.\"\"\"\n    obj_path = Path(args.objectives_file)\n    if not obj_path.exists():\n        print(f\"Objectives file not found: {obj_path}\")\n        sys.exit(1)\n\n    db_path = str(args.beliefs_file)\n    if not Path(db_path).exists():\n        print(f\"Reasons database not found: {db_path}\")\n        sys.exit(1)\n\n    objectives = parse_objectives(obj_path)\n    beliefs = load_beliefs(db_path=db_path)\n\n    if not objectives:\n        print(f\"No objectives found in {obj_path}\")\n        print(\"Expected format: # Domain\\\\n- Objective text\")\n        return\n\n    print(f\"=== Certification Coverage Report ===\")\n    print(f\"Objectives: {len(objectives)}\")\n    print(f\"Beliefs: {len(beliefs)} IN\\n\")\n\n    use_llm = args.model and check_model_available(args.model)\n    if args.model and not use_llm:\n        print(f\"WARNING: Model {args.model} not available, falling back to keyword matching\\n\")\n\n    covered = []\n    gaps = []\n\n    for obj in objectives:\n        matches = []\n\n        if use_llm:\n            # Use LLM for semantic matching\n            beliefs_text = \"\\n\".join(f\"- {b['id']}: {b['text']}\" for b in beliefs)\n            prompt = CERT_MATCH.format(objective=obj[\"text\"], beliefs=beliefs_text)\n            try:\n                result = invoke_sync(prompt, model=args.model, timeout=120)\n                data = extract_json(result)\n                if not isinstance(data, dict) or \"matching_ids\" not in data:\n                    retry_response = invoke_sync(\n                        prompt + \"\\n\\n\" + result + \"\\n\\n\" + RETRY_JSON,\n                        model=args.model, timeout=120,\n                    )\n                    data = extract_json(retry_response)\n                if isinstance(data, dict) and \"matching_ids\" in data:\n                    for bid in data[\"matching_ids\"]:\n                        bid = str(bid).strip()\n                        if any(b[\"id\"] == bid for b in beliefs):\n                            matches.append((bid, 1.0))\n            except Exception as e:\n                print(f\"  WARN: LLM matching failed for objective: {e}\",\n                      file=sys.stderr)\n\n        if not matches:\n            # Fall back to keyword matching\n            for belief in beliefs:\n                score = keyword_match(obj[\"text\"], belief[\"text\"])\n                if score >= 0.3:\n                    matches.append((belief[\"id\"], score))\n\n        matches.sort(key=lambda x: x[1], reverse=True)\n\n        if matches:\n            covered.append((obj, matches))\n        else:\n            gaps.append(obj)\n\n    # Report\n    if covered:\n        print(f\"COVERED ({len(covered)}/{len(objectives)}, {100 * len(covered) // len(objectives)}%):\\n\")\n        for obj, matches in covered:\n            print(f\"  {obj['id']} [{obj['domain']}] {obj['text']}\")\n            for bid, score in matches[:3]:\n                print(f\"    -> {bid} ({score:.2f})\")\n        print()\n\n    if gaps:\n        print(f\"GAPS ({len(gaps)}/{len(objectives)}, {100 * len(gaps) // len(objectives)}%):\\n\")\n        for obj in gaps:\n            print(f\"  {obj['id']} [{obj['domain']}] {obj['text']}\")\n            print(f\"    No matching beliefs found.\")\n        print()\n\n    # Summary by domain\n    domains = {}\n    for obj in objectives:\n        d = obj[\"domain\"]\n        if d not in domains:\n            domains[d] = {\"total\": 0, \"covered\": 0}\n        domains[d][\"total\"] += 1\n\n    for obj, _ in covered:\n        domains[obj[\"domain\"]][\"covered\"] += 1\n\n    print(\"BY DOMAIN:\")\n    for domain, counts in sorted(domains.items()):\n        pct = 100 * counts[\"covered\"] // counts[\"total\"] if counts[\"total\"] else 0\n        bar = \"***\" if pct < 50 else \"\"\n        print(f\"  {domain}: {counts['covered']}/{counts['total']} ({pct}%) {bar}\")"
  },
  "cmd_propose_beliefs_body": {
    "function": "cmd_propose_beliefs",
    "file": "expert_build/propose.py",
    "start_line": 261,
    "end_line": 439,
    "source": "def cmd_propose_beliefs(args):\n    \"\"\"Extract candidate beliefs from entries for human review.\"\"\"\n    from .caffeinate import hold as _caffeinate\n    _caffeinate()\n    input_dir = Path(args.input_dir)\n    if not input_dir.exists():\n        print(f\"Entries directory not found: {input_dir}\")\n        sys.exit(1)\n\n    if not check_model_available(args.model):\n        print(f\"Model not available: {args.model}\")\n        sys.exit(1)\n\n    # Collect entries\n    if hasattr(args, 'entry') and args.entry:\n        entries = [Path(p) for p in args.entry]\n    else:\n        entries = sorted(input_dir.rglob(\"*.md\"))\n\n    if not entries:\n        print(f\"No .md files found.\")\n        return\n\n    # Filter out already-processed entries (unless --all or --entry)\n    processed_path = Path(PROJECT_DIR) / \"proposed-entries.json\"\n    processed = _load_processed(processed_path)\n    process_all = getattr(args, 'all', False)\n    has_entry_flag = hasattr(args, 'entry') and args.entry\n\n    if not process_all and not has_entry_flag:\n        total = len(entries)\n        entries = _filter_unprocessed(entries, processed)\n        skipped = total - len(entries)\n        if skipped:\n            print(f\"Skipping {skipped} already-processed entries (use --all to reprocess)\")\n        if not entries:\n            print(\"No new entries to process.\")\n            return\n\n    # Load existing beliefs for dedup context\n    existing_beliefs = _load_existing_beliefs()\n    existing_ids = {b[\"id\"] for b in existing_beliefs}\n\n    if existing_ids:\n        print(f\"Found {len(existing_ids)} existing beliefs (will skip duplicates)\")\n\n    # Compute belief embeddings once (if fastembed available)\n    belief_vectors = None\n    if existing_beliefs and _has_embeddings():\n        print(\"Computing belief embeddings for semantic dedup...\")\n        cache_path = Path(PROJECT_DIR) / \"belief-vectors.json\"\n        belief_vectors = _get_belief_embeddings(existing_beliefs, cache_path)\n        print(f\"  {len(belief_vectors)} belief vectors ready\")\n    elif existing_beliefs:\n        print(\"(install fastembed for semantic dedup: uv pip install 'expert-agent-builder[embeddings]')\")\n\n    print(f\"Reading {len(entries)} entries...\")\n\n    # Batch entries \u2014 track paths per batch for relevance scoring\n    batches = []\n    batch_paths = []\n    current_batch = []\n    current_paths = []\n    for entry_path in entries:\n        content = entry_path.read_text()\n        if len(content) > 10000:\n            content = content[:10000] + \"\\n[Truncated]\"\n        source_url = \"\"\n        if content.startswith(\"---\"):\n            end = content.find(\"---\", 3)\n            if end != -1:\n                for line in content[3:end].splitlines():\n                    if line.startswith(\"source_url:\"):\n                        source_url = line.split(\":\", 1)[1].strip()\n        header = f\"--- FILE: {entry_path}\"\n        if source_url:\n            header += f\" | SOURCE_URL: {source_url}\"\n        header += \" ---\"\n        current_batch.append(f\"{header}\\n{content}\")\n        current_paths.append(str(entry_path))\n        if len(current_batch) >= args.batch_size:\n            batches.append(\"\\n\\n\".join(current_batch))\n            batch_paths.append(current_paths)\n            current_batch = []\n            current_paths = []\n    if current_batch:\n        batches.append(\"\\n\\n\".join(current_batch))\n        batch_paths.append(current_paths)\n\n    print(f\"Processing {len(batches)} batches (batch size: {args.batch_size})...\")\n\n    source_desc = (\", \".join(str(e) for e in entries)\n                   if has_entry_flag\n                   else f\"{len(entries)} entries from {input_dir}/\")\n    output = Path(args.output)\n\n    # Write header before first batch if starting a new file\n    appended = output.exists() and output.stat().st_size > 0\n    if not appended:\n        with output.open(\"w\") as f:\n            f.write(\"# Proposed Beliefs\\n\\n\")\n            f.write(\"Edit each entry: change `[ACCEPT/REJECT]` to `[ACCEPT]` or `[REJECT]`.\\n\")\n            f.write(\"Then run: `expert-build accept-beliefs`\\n\\n\")\n            f.write(\"---\\n\\n\")\n            f.write(f\"**Generated:** {date.today().isoformat()}\\n\")\n            f.write(f\"**Source:** {source_desc}\\n\")\n            f.write(f\"**Model:** {args.model}\\n\\n\")\n    else:\n        with output.open(\"a\") as f:\n            f.write(f\"\\n---\\n\\n\")\n            f.write(f\"**Generated:** {date.today().isoformat()}\\n\")\n            f.write(f\"**Source:** {source_desc}\\n\")\n            f.write(f\"**Model:** {args.model}\\n\\n\")\n\n    total_skipped = 0\n    successful_entries = []\n    for i, batch_text in enumerate(batches):\n        print(f\"  Batch {i + 1}/{len(batches)}...\")\n        existing_context = _build_dedup_context(\n            existing_beliefs, batch_paths[i], batch_text,\n            belief_vectors=belief_vectors,\n        )\n        prompt = PROPOSE_BELIEFS.format(entries=batch_text) + existing_context\n        try:\n            result = invoke_sync(prompt, model=args.model, timeout=600)\n        except Exception as e:\n            print(f\"  ERROR: {e}\")\n            continue\n\n        # Parse JSON response\n        beliefs = extract_json(result)\n        if not isinstance(beliefs, list):\n            print(\"    WARN: response not valid JSON, retrying...\", file=sys.stderr)\n            try:\n                retry_response = invoke_sync(\n                    prompt + \"\\n\\n\" + result + \"\\n\\n\" + RETRY_JSON,\n                    model=args.model, timeout=600,\n                )\n                beliefs = extract_json(retry_response)\n            except Exception:\n                pass\n        if not isinstance(beliefs, list):\n            print(\"    WARN: could not parse beliefs JSON, skipping batch\", file=sys.stderr)\n            continue\n\n        # Filter out proposals whose IDs already exist\n        skipped = 0\n        filtered = []\n        for b in beliefs:\n            bid = b.get(\"id\", \"\")\n            if bid in existing_ids:\n                skipped += 1\n                continue\n            filtered.append(b)\n        total_skipped += skipped\n\n        # Write this batch's proposals as markdown for human review\n        with output.open(\"a\") as f:\n            for b in filtered:\n                bid = b.get(\"id\", \"unknown\")\n                claim = b.get(\"claim\", \"\")\n                source = b.get(\"source\", \"\")\n                source_url = b.get(\"source_url\", \"\")\n                f.write(f\"### [ACCEPT/REJECT] {bid}\\n\")\n                f.write(f\"{claim}\\n\")\n                f.write(f\"- Source: {source}\\n\")\n                f.write(f\"- Source URL: {source_url or 'none'}\\n\\n\")\n\n        # Record this batch's entries as processed\n        successful_entries.extend(Path(p) for p in batch_paths[i])\n        _save_processed(processed_path, successful_entries, processed)\n\n    if total_skipped:\n        print(f\"  Filtered {total_skipped} already-accepted beliefs\")\n\n    print(f\"\\n{'Appended to' if appended else 'Wrote'} {output}\")\n\n    print(\"Review the file, mark entries as [ACCEPT] or [REJECT], then run:\")\n    print(\"  expert-build accept-beliefs\")"
  },
  "load_beliefs_body": {
    "function": "load_beliefs",
    "file": "expert_build/coverage.py",
    "start_line": 46,
    "end_line": 49,
    "source": "def load_beliefs(db_path: str = REASONS_DB) -> list[dict]:\n    \"\"\"Load IN beliefs from the reasons database.\"\"\"\n    result = list_nodes(status=\"IN\", db_path=db_path)\n    return [{\"id\": n[\"id\"], \"text\": n[\"text\"]} for n in result[\"nodes\"]]"
  },
  "invoke_sync_raises": {
    "function": "invoke_sync",
    "file": "expert_build/llm.py",
    "explicit_raises": [],
    "calls": [
      "invoke",
      "run"
    ]
  },
  "coverage_test_files": {
    "source_file": "expert_build/coverage.py",
    "test_files": [
      {
        "path": "tests/test_coverage.py",
        "exists": true,
        "line_count": 131
      },
      {
        "path": "tests/test_coveragepy",
        "exists": false
      }
    ],
    "test_count": 2
  },
  "propose_test_files": {
    "source_file": "expert_build/propose.py",
    "test_files": [
      {
        "path": "tests/test_pipelinepy",
        "exists": false
      },
      {
        "path": "tests/test_propose.py",
        "exists": true,
        "line_count": 220
      },
      {
        "path": "tests/test_proposepy",
        "exists": false
      }
    ],
    "test_count": 3
  },
  "llm_imports": {
    "file": "expert_build/llm.py",
    "imports": [
      "asyncio",
      "json",
      "os",
      "shutil"
    ],
    "from_imports": [],
    "import_section": "\"\"\"Model invocation for expert agent builder.\"\"\"\n\nimport asyncio\nimport json\nimport os\nimport shutil\n\nMODEL_COMMANDS: dict[str, list[str]] = {\n    \"claude\": [\"claude\", \"-p\"],\n    \"gemini\": [\"gemini\", \"-p\", \"\"],\n}\n\nDEFAULT_TIMEOUT = 300\n\n\ndef check_model_available(model: str) -> bool:\n    \"\"\"Check if a model's CLI is available.\"\"\""
  }
}
```

Use these results to inform your review. Do not request the same observations again.


## Instructions

For each significant change (new file, modified function, etc.), provide a structured verdict.

Use this exact format for each change:

### <file_path or file_path:function_name>
VERDICT: PASS | CONCERN | BLOCK
CORRECTNESS: VALID | QUESTIONABLE | BROKEN
SPEC_COMPLIANCE: MEETS | PARTIAL | VIOLATES | N/A
ISSUE_COMPLIANCE: ADDRESSES | PARTIAL | UNRELATED | N/A
BELIEF_COMPLIANCE: CONSISTENT | VIOLATES | N/A
TEST_COVERAGE: COVERED | PARTIAL | UNTESTED
INTEGRATION: WIRED | PARTIAL | MISSING
REASONING: <brief explanation of your assessment>
---

## Review Criteria

1. **CORRECTNESS**: Does the code do what it claims? Is the logic sound?
   - VALID: Logic is correct, no bugs apparent
   - QUESTIONABLE: Logic may have edge cases or unclear behavior
   - BROKEN: Clear bugs or incorrect behavior

2. **SPEC_COMPLIANCE**: Does it meet MUST requirements from the spec?
   - MEETS: All relevant spec requirements satisfied
   - PARTIAL: Some requirements met, others missing or incomplete
   - VIOLATES: Contradicts spec requirements
   - N/A: No spec provided or not applicable

3. **ISSUE_COMPLIANCE** (only when an issue is provided): Do the changes address the problem or feature described in the issue?
   - ADDRESSES: Changes directly solve the issue's stated problem or implement the requested feature
   - PARTIAL: Changes partially address the issue but leave some aspects unresolved
   - UNRELATED: Changes do not appear related to the issue
   - N/A: No issue provided

4. **TEST_COVERAGE**: Are there tests for the new/changed code?
   - COVERED: Tests exist and cover the changes
   - PARTIAL: Some tests exist but coverage is incomplete
   - UNTESTED: No tests for the changes

5. **INTEGRATION**: Are callers updated? Is the feature usable end-to-end?
   - WIRED: Feature is fully integrated and usable
   - PARTIAL: Interface exists but callers not updated, or integration incomplete
   - MISSING: No integration with existing code

6. **BELIEF_COMPLIANCE** (only when beliefs are provided): Do the changes respect known architectural invariants, contracts, and rules?
   - CONSISTENT: Changes align with or reinforce known beliefs
   - VIOLATES: Changes contradict a specific belief — cite the belief ID
   - N/A: No beliefs provided or no relevant beliefs apply

## Verdict Guidelines

- **BLOCK**: Security issues, broken functionality, spec violations, or missing critical integration
- **CONCERN**: Missing tests, partial integration, questionable patterns, or unclear logic
- **PASS**: Correct, tested, well-integrated code

## Important

- Full function bodies for modified functions may be available in the observations section — use them to verify the complete logic, not just the diff hunks
- Related test files (prefixed with ``related_test:``) may be included in observations — check whether existing test assertions still match modified return types, signatures, or behavior. Flag any test that would break due to the changes
- If duplicate test coverage is detected (multiple test files covering the same source), note it in your review
- Focus on actual issues, not style preferences
- If a method signature is added but callers aren't updated, that's PARTIAL integration
- Be specific in reasoning - reference line numbers or function names
- When in doubt, use CONCERN rather than PASS

## Self-Review

After completing your review, add a brief self-assessment:

### SELF_REVIEW
LIMITATIONS: <what context were you missing that affected review quality?>
---

Examples of limitations:
- "Could not see full class to verify no other methods access the modified field"
- "Test file not included in diff - cannot verify coverage claims"
- "Spec file referenced but not provided"


## Feature Requests

If this review tool could be improved to help you do a better job, suggest features:

### FEATURE_REQUESTS
- <suggestion 1>
- <suggestion 2>
---

Examples:
- "Include full file context for modified functions, not just diff hunks"
- "Show callers of modified methods to verify integration"
- "Include test file alongside implementation changes"

Only include this section if you have specific suggestions. Skip if none.
