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

## Specification

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



## Issue Description

Review the code changes against this issue. Determine whether the changes address the problem or feature request described.

```markdown
## derive fails with JSON parse error on large networks (10k+ beliefs)

## Problem

`project-expert derive` (called during `update --exhaust`) fails with:

```
--- Derive round 1/5 ---
Error in round 1: Expecting value: line 1 column 1 (char 0)
```

This is a `json.JSONDecodeError` indicating `json.loads()` received an empty string.

## Root cause

In `cli.py:_derive_once()` (line 1748), the function calls `_load_network()` which reads `network.json`. On a 10,822-node network, the network file is ~3.9 MB. The derive prompt built by `_build_beliefs_section()` is capped at 300 beliefs, but the JSON parsing error suggests the Claude CLI invocation itself is failing — the model returns empty output or non-zero exit, and the error propagates as a JSON parse error rather than a clear model failure message.

The call chain is:
1. `_derive_once()` calls `invoke(prompt, model, timeout)` (line 1777)
2. `invoke()` in `llm.py:23` runs `claude -p` via subprocess
3. If Claude exits non-zero, `invoke()` raises `RuntimeError` (line 49)
4. The `except Exception as e` at line 1866 catches it and prints the error

However, the "Expecting value" error doesn't match `RuntimeError` — it's a `json.JSONDecodeError`, suggesting the issue is in `_load_network()` (line 1752) trying to parse `network.json` when the file is empty or being written concurrently, OR the model returns a response that gets JSON-parsed somewhere unexpected.

## Observed on

- Network size: 10,822 nodes (8,405 IN / 2,416 OUT)
- `network.json`: ~3.9 MB
- Model: `claude` (default)
- Called via: `project-expert update --since-last --all-pages`

## Suggested fix

1. Add explicit error handling in `_load_network()` for corrupt/empty `network.json`
2. Wrap the `json.loads()` call with a clear error message that names the file
3. Consider whether `_derive_once` should re-export `network.json` before loading (it may be stale from a prior step)
4. The `except Exception as e` block at line 1866 should distinguish model failures from data loading failures
```


## Code Changes

```diff
diff --git a/ftl_project_expert/cli.py b/ftl_project_expert/cli.py
index 1410f17..5df3154 100644
--- a/ftl_project_expert/cli.py
+++ b/ftl_project_expert/cli.py
@@ -146,13 +146,17 @@ def _report_beliefs(response: str) -> None:
 def _reasons_export():
     beliefs_path = Path("beliefs.md")
     network_path = Path("network.json")
-    result = subprocess.run(["reasons", "export-markdown"], capture_output=True, text=True)
+    result = subprocess.run(
+        ["reasons", "export-markdown", "-o", str(beliefs_path)],
+        capture_output=True, text=True,
+    )
     if result.returncode == 0:
-        beliefs_path.write_text(result.stdout)
         click.echo(f"Updated {beliefs_path}")
-    result = subprocess.run(["reasons", "export"], capture_output=True, text=True)
+    result = subprocess.run(
+        ["reasons", "export", "-o", str(network_path)],
+        capture_output=True, text=True,
+    )
     if result.returncode == 0:
-        network_path.write_text(result.stdout)
         click.echo(f"Updated {network_path}")
 
 
@@ -1622,12 +1626,28 @@ def _load_network() -> dict:
     if not network_path.exists():
         if _has_reasons():
             result = subprocess.run(
-                ["reasons", "export"], capture_output=True, text=True,
+                ["reasons", "export", "-o", str(network_path)],
+                capture_output=True, text=True,
+            )
+            if result.returncode != 0:
+                return {"nodes": {}}
+        else:
+            return {"nodes": {}}
+    try:
+        return json.loads(network_path.read_text())
+    except (json.JSONDecodeError, ValueError, FileNotFoundError):
+        click.echo(f"WARN: {network_path} is corrupt, empty, or missing, re-exporting", err=True)
+        if _has_reasons():
+            result = subprocess.run(
+                ["reasons", "export", "-o", str(network_path)],
+                capture_output=True, text=True,
             )
             if result.returncode == 0:
-                return json.loads(result.stdout)
+                try:
+                    return json.loads(network_path.read_text())
+                except (json.JSONDecodeError, ValueError, FileNotFoundError):
+                    pass
         return {"nodes": {}}
-    return json.loads(network_path.read_text())
 
 
 def _get_depth(node_id: str, nodes: dict, derived: dict, memo: dict | None = None) -> int:

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "reasons_export_body": {
    "function": "_reasons_export",
    "file": "ftl_project_expert/cli.py",
    "start_line": 146,
    "end_line": 160,
    "source": "def _reasons_export():\n    beliefs_path = Path(\"beliefs.md\")\n    network_path = Path(\"network.json\")\n    result = subprocess.run(\n        [\"reasons\", \"export-markdown\", \"-o\", str(beliefs_path)],\n        capture_output=True, text=True,\n    )\n    if result.returncode == 0:\n        click.echo(f\"Updated {beliefs_path}\")\n    result = subprocess.run(\n        [\"reasons\", \"export\", \"-o\", str(network_path)],\n        capture_output=True, text=True,\n    )\n    if result.returncode == 0:\n        click.echo(f\"Updated {network_path}\")"
  },
  "load_network_body": {
    "function": "_load_network",
    "file": "ftl_project_expert/cli.py",
    "start_line": 1623,
    "end_line": 1650,
    "source": "def _load_network() -> dict:\n    \"\"\"Load network.json (exported from reasons).\"\"\"\n    network_path = Path(\"network.json\")\n    if not network_path.exists():\n        if _has_reasons():\n            result = subprocess.run(\n                [\"reasons\", \"export\", \"-o\", str(network_path)],\n                capture_output=True, text=True,\n            )\n            if result.returncode != 0:\n                return {\"nodes\": {}}\n        else:\n            return {\"nodes\": {}}\n    try:\n        return json.loads(network_path.read_text())\n    except (json.JSONDecodeError, ValueError, FileNotFoundError):\n        click.echo(f\"WARN: {network_path} is corrupt, empty, or missing, re-exporting\", err=True)\n        if _has_reasons():\n            result = subprocess.run(\n                [\"reasons\", \"export\", \"-o\", str(network_path)],\n                capture_output=True, text=True,\n            )\n            if result.returncode == 0:\n                try:\n                    return json.loads(network_path.read_text())\n                except (json.JSONDecodeError, ValueError, FileNotFoundError):\n                    pass\n        return {\"nodes\": {}}"
  },
  "has_reasons_body": {
    "function": "_has_reasons",
    "file": "ftl_project_expert/cli.py",
    "start_line": 120,
    "end_line": 121,
    "source": "def _has_reasons() -> bool:\n    return shutil.which(\"reasons\") is not None"
  },
  "load_network_callers": {
    "symbol": "_load_network",
    "production_callers": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1188,
        "text": "network = _load_network()",
        "context_function": "review_proposals",
        "context_snippet": "   1185:     # Load context\n   1186:     cached_issues = _load_cached_issues(project_dir)\n   1187:     try:\n>> 1188:         network = _load_network()\n   1189:         existing_nodes = network.get(\"nodes\", {})\n   1190:     except Exception:\n   1191:         existing_nodes = {}"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1502,
        "text": "network = _load_network()",
        "context_function": "research",
        "context_snippet": "   1499:         sys.exit(1)\n   1500: \n   1501:     # Determine which beliefs to research\n>> 1502:     network = _load_network()\n   1503:     if belief_id:\n   1504:         belief_ids = [belief_id]\n   1505:     else:"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1623,
        "text": "def _load_network() -> dict:",
        "context_function": "_research_one",
        "context_snippet": "   1620: # --- derive ---\n   1621: \n   1622: \n>> 1623: def _load_network() -> dict:\n   1624:     \"\"\"Load network.json (exported from reasons).\"\"\"\n   1625:     network_path = Path(\"network.json\")\n   1626:     if not network_path.exists():"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1744,
        "text": "network = _load_network()",
        "context_function": "_derive_once",
        "context_snippet": "   1741:     \"\"\"Run a single derivation round. Returns number of beliefs added.\"\"\"\n   1742:     from .prompts.derive import DERIVE_BELIEFS_PROMPT\n   1743: \n>> 1744:     network = _load_network()\n   1745:     nodes = network.get(\"nodes\", {})\n   1746:     if not nodes:\n   1747:         return 0"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1869,
        "text": "network = _load_network()",
        "context_function": "derive",
        "context_snippet": "   1866:         return\n   1867: \n   1868:     # Single-round mode\n>> 1869:     network = _load_network()\n   1870:     nodes = network.get(\"nodes\", {})\n   1871:     if not nodes:\n   1872:         click.echo(\"No beliefs found. Run explorations first.\", err=True)"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2193,
        "text": "network = _load_network()",
        "context_function": "update",
        "context_snippet": "   2190: \n   2191:     # Snapshot pre-run belief IDs\n   2192:     try:\n>> 2193:         network = _load_network()\n   2194:         pre_run_ids = set(network.get(\"nodes\", {}).keys())\n   2195:     except Exception:\n   2196:         pre_run_ids = set()"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2345,
        "text": "network = _load_network()",
        "context_function": "update",
        "context_snippet": "   2342:     # --- Report ---\n   2343:     # Count new beliefs\n   2344:     try:\n>> 2345:         network = _load_network()\n   2346:         post_run_ids = set(network.get(\"nodes\", {}).keys())\n   2347:         new_beliefs = post_run_ids - pre_run_ids\n   2348:         click.echo(f\"New beliefs: {len(new_beliefs)}\", err=True)"
      }
    ],
    "test_callers": [],
    "production_count": 7,
    "test_count": 0,
    "total_count": 7
  },
  "reasons_export_callers": {
    "symbol": "_reasons_export",
    "production_callers": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 146,
        "text": "def _reasons_export():",
        "context_function": "_report_beliefs",
        "context_snippet": "   143:             click.echo(f\"  {b['id']}: {b['text'][:80]}\", err=True)\n   144: \n   145: \n>> 146: def _reasons_export():\n   147:     beliefs_path = Path(\"beliefs.md\")\n   148:     network_path = Path(\"network.json\")\n   149:     result = subprocess.run("
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 250,
        "text": "_reasons_export()",
        "context_function": "init",
        "context_snippet": "   247:             subprocess.run([\"reasons\", \"init\"], capture_output=True)\n   248:             click.echo(\"Initialized reasons.db\")\n   249:         if not Path(\"beliefs.md\").exists():\n>> 250:             _reasons_export()\n   251:     elif not Path(\"beliefs.md\").exists():\n   252:         subprocess.run([\"beliefs\", \"init\"], capture_output=True)\n   253:         click.echo(\"Initialized beliefs.md\")"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 833,
        "text": "_reasons_export()",
        "context_function": "_auto_accept_proposals",
        "context_snippet": "   830:                 else:\n   831:                     click.echo(f\"  FAIL: {belief_id}: {stderr or stdout}\", err=True)\n   832:         if added > 0:\n>> 833:             _reasons_export()\n   834:         click.echo(f\"Accepted {added} belief(s).\", err=True)\n   835:         return\n   836: "
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1016,
        "text": "_reasons_export()",
        "context_function": "accept_beliefs",
        "context_snippet": "   1013:                     click.echo(f\"  FAIL: {belief_id}: {stderr or stdout}\")\n   1014: \n   1015:         if added > 0:\n>> 1016:             _reasons_export()\n   1017:         return\n   1018: \n   1019:     # Fall back to beliefs CLI"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1809,
        "text": "_reasons_export()",
        "context_function": "_derive_once",
        "context_snippet": "   1806:             click.echo(f\"  FAIL {p['id']}: {r.stderr.strip() or r.stdout.strip()}\", err=True)\n   1807: \n   1808:     if added:\n>> 1809:         _reasons_export()\n   1810: \n   1811:     return added\n   1812: "
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1954,
        "text": "_reasons_export()",
        "context_function": "derive",
        "context_snippet": "   1951: \n   1952:         if added:\n   1953:             click.echo(f\"\\nAdded {added} derived beliefs.\", err=True)\n>> 1954:             _reasons_export()\n   1955:         return\n   1956: \n   1957:     # Write proposals file for review"
      }
    ],
    "test_callers": [],
    "production_count": 6,
    "test_count": 0,
    "total_count": 6
  },
  "load_network_tests": {
    "source_file": "ftl_project_expert/cli.py",
    "test_files": [],
    "test_count": 0
  }
}
```

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.
