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

## Code Changes

```diff
diff --git a/expert_build/cli.py b/expert_build/cli.py
index dfb2a16..8dcd7a6 100644
--- a/expert_build/cli.py
+++ b/expert_build/cli.py
@@ -82,20 +82,40 @@ def main():
     exam_p = sub.add_parser("exam", help="Run practice questions, discover gaps")
     exam_p.add_argument("questions_file", help="Path to practice questions")
     exam_p.add_argument("--model", default="claude", help="Model to use (default: claude)")
     exam_p.add_argument("--beliefs-file", type=Path, default=Path("reasons.db"))
     exam_p.add_argument("--limit", type=int, help="Max questions to process")
     exam_p.add_argument("--output", "-o", type=Path, default=None,
                         help="Save results to file (markdown)")
     exam_p.add_argument("--no-judge", action="store_true",
                         help="Disable LLM judge for open-ended questions (use string matching)")
 
+    # -- pipeline --
+    pipe_p = sub.add_parser("pipeline", help="Run end-to-end EEM construction pipeline")
+    pipe_p.add_argument("--url", help="Starting URL for doc fetching")
+    pipe_p.add_argument("--pdf", action="append", help="PDF files to chunk (repeatable)")
+    pipe_p.add_argument("--sources-dir", default="sources", help="Source directory (default: sources)")
+    pipe_p.add_argument("--model", default="claude", help="Model for LLM calls (default: claude)")
+    pipe_p.add_argument("--rounds", type=int, default=3,
+                        help="Max derive/review/repair cycles (default: 3)")
+    pipe_p.add_argument("--max-derive-rounds", type=int, default=10,
+                        help="Max derive exhaust rounds per cycle (default: 10)")
+    pipe_p.add_argument("--no-auto-accept", action="store_true",
+                        help="Stop after propose-beliefs for human review")
+    pipe_p.add_argument("--no-fetch", action="store_true",
+                        help="Skip fetch-docs (use existing sources/)")
+    pipe_p.add_argument("--depth", type=int, default=2,
+                        help="Crawl depth for fetch-docs (default: 2)")
+    pipe_p.add_argument("--timeout", type=int, default=600,
+                        help="LLM timeout in seconds (default: 600)")
+    pipe_p.add_argument("--domain", help="Domain description for derive context")
+
     # -- status --
     sub.add_parser("status", help="Show pipeline progress")
 
     # -- install-skill --
     skill_p = sub.add_parser("install-skill", help="Install Claude Code skill file")
     skill_p.add_argument("--skill-dir", type=Path, default=Path(".claude/skills"),
                          help="Target skills directory")
 
     args = parser.parse_args()
 
@@ -105,18 +125,19 @@ def main():
 
     commands = {
         "init": lambda a: _lazy("init_cmd", "cmd_init")(a),
         "chunk-pdf": lambda a: _lazy("chunk_pdf", "cmd_chunk_pdf")(a),
         "fetch-docs": lambda a: _lazy("fetch", "cmd_fetch_docs")(a),
         "summarize": lambda a: _lazy("summarize", "cmd_summarize")(a),
         "propose-beliefs": lambda a: _lazy("propose", "cmd_propose_beliefs")(a),
         "accept-beliefs": lambda a: _lazy("propose", "cmd_accept_beliefs")(a),
         "cert-coverage": lambda a: _lazy("coverage", "cmd_cert_coverage")(a),
         "exam": lambda a: _lazy("exam", "cmd_exam")(a),
+        "pipeline": lambda a: _lazy("pipeline", "cmd_pipeline")(a),
         "status": lambda a: _lazy("init_cmd", "cmd_status")(a),
         "install-skill": lambda a: _lazy("init_cmd", "cmd_install_skill")(a),
     }
     commands[args.command](args)
 
 
 if __name__ == "__main__":
     main()
diff --git a/expert_build/pipeline.py b/expert_build/pipeline.py
new file mode 100644
index 0000000..8dd9fee
--- /dev/null
+++ b/expert_build/pipeline.py
@@ -0,0 +1,308 @@
+"""End-to-end EEM construction pipeline."""
+
+import re
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+from .llm import check_model_available, invoke_sync
+from .propose import REASONS_DB
+
+
+def _banner(stage_num, total, name):
+    print(f"\n{'=' * 50}", file=sys.stderr)
+    print(f"  Stage {stage_num}/{total}: {name}", file=sys.stderr)
+    print(f"{'=' * 50}\n", file=sys.stderr)
+
+
+def _stage_ingest(args):
+    """Stage 1: Fetch docs or chunk PDFs into sources/."""
+    if args.no_fetch:
+        print("Skipping fetch (--no-fetch)", file=sys.stderr)
+        return
+
+    if args.url:
+        from .fetch import cmd_fetch_docs
+        fetch_args = SimpleNamespace(
+            url=args.url,
+            depth=args.depth,
+            output_dir=args.sources_dir,
+            selector="main,article,.content,body",
+            sitemap=False,
+            include=None,
+            exclude=None,
+            delay=1.0,
+        )
+        cmd_fetch_docs(fetch_args)
+
+    if args.pdf:
+        from .chunk_pdf import cmd_chunk_pdf
+        for pdf_path in args.pdf:
+            chunk_args = SimpleNamespace(
+                pdf=pdf_path,
+                prefix=None,
+                source_label=None,
+                dry_run=False,
+            )
+            cmd_chunk_pdf(chunk_args)
+
+
+def _stage_summarize(args):
+    """Stage 2: Generate entries from source documents."""
+    from .summarize import cmd_summarize
+    sum_args = SimpleNamespace(
+        input_dir=args.sources_dir,
+        limit=None,
+        model=args.model,
+    )
+    cmd_summarize(sum_args)
+
+
+def _stage_extract(args):
+    """Stage 3: Extract beliefs from entries and optionally auto-accept."""
+    from .propose import cmd_propose_beliefs, cmd_accept_beliefs
+
+    prop_args = SimpleNamespace(
+        input_dir="entries",
+        output="proposed-beliefs.md",
+        model=args.model,
+        batch_size=5,
+        entry=None,
+    )
+    setattr(prop_args, "all", False)
+
+    cmd_propose_beliefs(prop_args)
+
+    if args.no_auto_accept:
+        print("\nStopping after propose-beliefs (--no-auto-accept)", file=sys.stderr)
+        print("Review proposed-beliefs.md, mark entries as [ACCEPT], then run:", file=sys.stderr)
+        print("  expert-build accept-beliefs", file=sys.stderr)
+        return False
+
+    proposals_path = Path("proposed-beliefs.md")
+    if proposals_path.exists():
+        from .propose import auto_accept_proposals
+        auto_accept_proposals(str(proposals_path))
+        print("Auto-accepted all proposed beliefs", file=sys.stderr)
+
+        accept_args = SimpleNamespace(file="proposed-beliefs.md")
+        cmd_accept_beliefs(accept_args)
+
+    return True
+
+
+def _stage_derive(args, round_label=""):
+    """Stage 4: Derive new beliefs until saturated or max rounds hit.
+
+    Returns total number of beliefs added.
+    """
+    from reasons_lib.api import export_network
+    from reasons_lib.derive import build_prompt, parse_proposals, validate_proposals, apply_proposals
+
+    total_added = 0
+    prefix = f"[{round_label}] " if round_label else ""
+
+    for derive_round in range(1, args.max_derive_rounds + 1):
+        print(f"{prefix}Derive round {derive_round}/{args.max_derive_rounds}...",
+              file=sys.stderr)
+
+        data = export_network(db_path=REASONS_DB)
+        nodes = data.get("nodes", {})
+        if not nodes:
+            print(f"{prefix}No nodes in network", file=sys.stderr)
+            break
+
+        prompt, stats = build_prompt(nodes, domain=args.domain)
+        print(f"{prefix}  Network: {stats['total_in']} IN, "
+              f"{stats['total_derived']} derived, depth {stats['max_depth']}",
+              file=sys.stderr)
+
+        try:
+            response = invoke_sync(prompt, model=args.model, timeout=args.timeout)
+        except Exception as e:
+            print(f"{prefix}  Derive error: {e}", file=sys.stderr)
+            break
+
+        proposals = parse_proposals(response)
+        if not proposals:
+            print(f"{prefix}  Saturated (no proposals)", file=sys.stderr)
+            break
+
+        valid, skipped = validate_proposals(proposals, nodes)
+        for p, reason in skipped:
+            print(f"{prefix}  SKIP {p['id']}: {reason}", file=sys.stderr)
+
+        if not valid:
+            print(f"{prefix}  Saturated (no valid proposals)", file=sys.stderr)
+            break
+
+        results = apply_proposals(valid, db_path=REASONS_DB)
+        added = sum(1 for _, r in results if isinstance(r, dict))
+        total_added += added
+        print(f"{prefix}  Added {added} beliefs", file=sys.stderr)
+
+    return total_added
+
+
+def _stage_review(args, round_label=""):
+    """Stage 5: Review derived beliefs for validity.
+
+    Returns the review results dict.
+    """
+    from reasons_lib.api import review_beliefs
+
+    prefix = f"[{round_label}] " if round_label else ""
+    print(f"{prefix}Reviewing beliefs...", file=sys.stderr)
+
+    result = review_beliefs(
+        model=args.model,
+        timeout=args.timeout,
+        db_path=REASONS_DB,
+    )
+
+    reviewed = result.get("reviewed", 0)
+    invalid = result.get("invalid", 0)
+    print(f"{prefix}  Reviewed {reviewed}, invalid {invalid}", file=sys.stderr)
+
+    return result
+
+
+def _stage_repair(args, review_result, round_label=""):
+    """Stage 6: Research and repair invalid beliefs.
+
+    Returns the research results dict.
+    """
+    from reasons_lib.api import research
+
+    prefix = f"[{round_label}] " if round_label else ""
+
+    invalid_ids = [
+        r["belief_id"] for r in review_result.get("results", [])
+        if not r.get("valid", True)
+    ]
+
+    if not invalid_ids:
+        print(f"{prefix}No invalid beliefs to repair", file=sys.stderr)
+        return {"total_invalid": 0}
+
+    print(f"{prefix}Researching {len(invalid_ids)} invalid beliefs...", file=sys.stderr)
+
+    result = research(
+        belief_ids=invalid_ids,
+        model=args.model,
+        timeout=args.timeout,
+        db_path=REASONS_DB,
+    )
+
+    print(f"{prefix}  Linked: {result.get('linked', 0)}, "
+          f"Softened: {result.get('softened', 0)}, "
+          f"Abandoned: {result.get('abandoned', 0)}", file=sys.stderr)
+
+    return result
+
+
+def _stage_deduplicate(args, round_label=""):
+    """Stage 7: Remove duplicate beliefs."""
+    from reasons_lib.api import deduplicate
+
+    prefix = f"[{round_label}] " if round_label else ""
+    print(f"{prefix}Deduplicating...", file=sys.stderr)
+
+    result = deduplicate(auto=True, db_path=REASONS_DB)
+    retracted = result.get("retracted", [])
+    clusters = result.get("clusters", [])
+
+    if retracted:
+        print(f"{prefix}  {len(clusters)} clusters, retracted {len(retracted)}",
+              file=sys.stderr)
+    else:
+        print(f"{prefix}  No duplicates found", file=sys.stderr)
+
+    return result
+
+
+def _stage_export(args):
+    """Stage 9: Export network and README card."""
+    from reasons_lib.api import export_network, export_card
+
+    data = export_network(db_path=REASONS_DB)
+
+    network_path = Path("network.json")
+    import json
+    network_path.write_text(json.dumps(data, indent=2))
+    print(f"Exported {network_path}", file=sys.stderr)
+
+    card = export_card(db_path=REASONS_DB, domain=args.domain)
+    readme_path = Path("README.md")
+    readme_path.write_text(card)
+    print(f"Exported {readme_path}", file=sys.stderr)
+
+    in_count = sum(1 for n in data.get("nodes", {}).values()
+                   if n.get("truth_value") == "IN")
+    total = len(data.get("nodes", {}))
+    print(f"\nFinal: {in_count} IN / {total} total beliefs", file=sys.stderr)
+
+
+def cmd_pipeline(args):
+    """Run end-to-end EEM construction pipeline."""
+    from .caffeinate import hold as _caffeinate
+    _caffeinate()
+
+    if not check_model_available(args.model):
+        print(f"Model not available: {args.model}", file=sys.stderr)
+        sys.exit(1)
+
+    total_stages = 9
+    has_sources = args.url or args.pdf
+
+    # Stage 1: Ingest
+    if has_sources:
+        _banner(1, total_stages, "INGEST")
+        _stage_ingest(args)
+    else:
+        print("No --url or --pdf provided, skipping ingest", file=sys.stderr)
+
+    # Stage 2: Summarize
+    _banner(2, total_stages, "SUMMARIZE")
+    _stage_summarize(args)
+
+    # Stage 3: Extract
+    _banner(3, total_stages, "EXTRACT")
+    should_continue = _stage_extract(args)
+    if not should_continue:
+        return
+
+    # Stages 4-7: Derive → Review → Repair → Deduplicate (convergence loop)
+    for cycle in range(1, args.rounds + 1):
+        label = f"cycle {cycle}/{args.rounds}"
+
+        # Stage 4: Derive
+        _banner(4, total_stages, f"DERIVE ({label})")
+        added = _stage_derive(args, round_label=label)
+
+        # Stage 5: Review
+        _banner(5, total_stages, f"REVIEW ({label})")
+        review_result = _stage_review(args, round_label=label)
+
+        invalid_count = review_result.get("invalid", 0)
+
+        # Stage 6: Repair
+        if invalid_count > 0:
+            _banner(6, total_stages, f"REPAIR ({label})")
+            _stage_repair(args, review_result, round_label=label)
+
+        # Stage 7: Deduplicate
+        _banner(7, total_stages, f"DEDUPLICATE ({label})")
+        _stage_deduplicate(args, round_label=label)
+
+        # Check convergence
+        if invalid_count == 0 and added == 0:
+            print(f"\nConverged after {cycle} cycles "
+                  f"(0 invalids, 0 new derivations)", file=sys.stderr)
+            break
+
+    # Stage 9: Export
+    _banner(9, total_stages, "EXPORT")
+    _stage_export(args)
+
+    print("\nPipeline complete.", file=sys.stderr)
diff --git a/expert_build/propose.py b/expert_build/propose.py
index ebdb609..66e6ad5 100644
--- a/expert_build/propose.py
+++ b/expert_build/propose.py
@@ -243,20 +243,28 @@ def _build_dedup_context(
     if compact:
         compact_ids = ", ".join(b["id"] for _, b in compact)
         parts.append(f"\nOther existing IDs: {compact_ids}")
 
     return "\n".join(parts) + "\n"
 
 
 # --- Commands ---
 
 
+def auto_accept_proposals(filepath: str):
+    """Rewrite all [ACCEPT/REJECT] markers to [ACCEPT] in a proposals file."""
+    path = Path(filepath)
+    text = path.read_text()
+    text = re.sub(r'\[ACCEPT/REJECT\]', '[ACCEPT]', text)
+    path.write_text(text)
+
+
 def cmd_propose_beliefs(args):
     """Extract candidate beliefs from entries for human review."""
     from .caffeinate import hold as _caffeinate
     _caffeinate()
     input_dir = Path(args.input_dir)
     if not input_dir.exists():
         print(f"Entries directory not found: {input_dir}")
         sys.exit(1)
 
     if not check_model_available(args.model):
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
new file mode 100644
index 0000000..950a938
--- /dev/null
+++ b/tests/test_pipeline.py
@@ -0,0 +1,289 @@
+"""Tests for the pipeline command."""
+
+import types
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from expert_build.pipeline import (
+    cmd_pipeline,
+    _stage_ingest,
+    _stage_extract,
+    _stage_derive,
+    _stage_review,
+    _stage_repair,
+    _stage_deduplicate,
+)
+from expert_build.propose import auto_accept_proposals
+
+
+@pytest.fixture
+def work_dir(tmp_path, monkeypatch):
+    """Set working directory to tmp_path for isolated pipeline runs."""
+    monkeypatch.chdir(tmp_path)
+    (tmp_path / "sources").mkdir()
+    (tmp_path / "entries").mkdir()
+    (tmp_path / "reasons.db").touch()
+    return tmp_path
+
+
+def make_pipeline_args(**overrides):
+    defaults = dict(
+        url=None,
+        pdf=None,
+        sources_dir="sources",
+        model="claude",
+        rounds=3,
+        max_derive_rounds=10,
+        no_auto_accept=False,
+        no_fetch=False,
+        depth=2,
+        timeout=600,
+        domain="Test domain",
+    )
+    defaults.update(overrides)
+    return types.SimpleNamespace(**defaults)
+
+
+# --- auto_accept_proposals ---
+
+class TestAutoAcceptProposals:
+    def test_replaces_markers(self, tmp_path):
+        f = tmp_path / "proposals.md"
+        f.write_text(
+            "### [ACCEPT/REJECT] belief-one\n"
+            "Text one\n"
+            "### [ACCEPT/REJECT] belief-two\n"
+            "Text two\n"
+        )
+        auto_accept_proposals(str(f))
+        text = f.read_text()
+        assert "[ACCEPT/REJECT]" not in text
+        assert text.count("[ACCEPT]") == 2
+
+    def test_preserves_already_accepted(self, tmp_path):
+        f = tmp_path / "proposals.md"
+        f.write_text(
+            "### [ACCEPT] already-good\n"
+            "Text\n"
+            "### [ACCEPT/REJECT] needs-accept\n"
+            "Text\n"
+        )
+        auto_accept_proposals(str(f))
+        text = f.read_text()
+        assert text.count("[ACCEPT]") == 2
+        assert "[ACCEPT/REJECT]" not in text
+
+    def test_no_markers_is_noop(self, tmp_path):
+        f = tmp_path / "proposals.md"
+        original = "### ACCEPT belief-one\nText\n"
+        f.write_text(original)
+        auto_accept_proposals(str(f))
+        assert f.read_text() == original
+
+
+# --- Stage: Ingest ---
+
+class TestStageIngest:
+    def test_skips_when_no_fetch(self, work_dir, capsys):
+        args = make_pipeline_args(no_fetch=True)
+        _stage_ingest(args)
+        captured = capsys.readouterr()
+        assert "Skipping fetch" in captured.err
+
+    def test_calls_fetch_docs_with_url(self, work_dir):
+        args = make_pipeline_args(url="https://example.com/docs")
+        with patch("expert_build.fetch.cmd_fetch_docs") as mock_fetch:
+            _stage_ingest(args)
+        assert mock_fetch.called
+        fetch_args = mock_fetch.call_args[0][0]
+        assert fetch_args.url == "https://example.com/docs"
+        assert fetch_args.depth == 2
+
+    def test_calls_chunk_pdf(self, work_dir):
+        args = make_pipeline_args(pdf=["paper.pdf"])
+        with patch("expert_build.chunk_pdf.cmd_chunk_pdf") as mock_chunk:
+            _stage_ingest(args)
+        assert mock_chunk.called
+
+
+# --- Stage: Extract ---
+
+class TestStageExtract:
+    def test_stops_on_no_auto_accept(self, work_dir, capsys):
+        args = make_pipeline_args(no_auto_accept=True)
+        with patch("expert_build.propose.cmd_propose_beliefs"):
+            result = _stage_extract(args)
+        assert result is False
+        captured = capsys.readouterr()
+        assert "--no-auto-accept" in captured.err
+
+    def test_auto_accepts_and_imports(self, work_dir):
+        proposals = work_dir / "proposed-beliefs.md"
+        proposals.write_text("### [ACCEPT/REJECT] test-belief\nText\n- Source: test\n")
+        args = make_pipeline_args()
+
+        with patch("expert_build.propose.cmd_propose_beliefs"), \
+             patch("expert_build.propose.cmd_accept_beliefs") as mock_accept:
+            result = _stage_extract(args)
+
+        assert result is True
+        assert mock_accept.called
+        text = proposals.read_text()
+        assert "[ACCEPT]" in text
+        assert "[ACCEPT/REJECT]" not in text
+
+
+# --- Stage: Derive ---
+
+class TestStageDerive:
+    def test_returns_zero_on_empty_network(self, work_dir):
+        args = make_pipeline_args()
+        with patch("reasons_lib.api.export_network", return_value={"nodes": {}}):
+            added = _stage_derive(args)
+        assert added == 0
+
+    def test_saturates_on_no_proposals(self, work_dir):
+        args = make_pipeline_args()
+        nodes = {"belief-1": {"text": "Test", "truth_value": "IN", "justifications": []}}
+        stats = {"total_in": 1, "total_derived": 0, "max_depth": 0, "agents": 0}
+        with patch("reasons_lib.api.export_network", return_value={"nodes": nodes}), \
+             patch("reasons_lib.derive.build_prompt", return_value=("prompt", stats)), \
+             patch("expert_build.llm.invoke_sync", return_value="No proposals"), \
+             patch("reasons_lib.derive.parse_proposals", return_value=[]):
+            added = _stage_derive(args)
+        assert added == 0
+
+    def test_applies_valid_proposals(self, work_dir):
+        args = make_pipeline_args(max_derive_rounds=1)
+        nodes = {"belief-1": {"text": "Test", "truth_value": "IN", "justifications": []}}
+        stats = {"total_in": 1, "total_derived": 0, "max_depth": 0, "agents": 0}
+        proposal = {
+            "id": "derived-1", "text": "Derived",
+            "antecedents": ["belief-1"], "unless": [],
+            "label": "test", "kind": "derive",
+        }
+        with patch("reasons_lib.api.export_network", return_value={"nodes": nodes}), \
+             patch("reasons_lib.derive.build_prompt", return_value=("prompt", stats)), \
+             patch("expert_build.llm.invoke_sync", return_value="proposal text"), \
+             patch("reasons_lib.derive.parse_proposals", return_value=[proposal]), \
+             patch("reasons_lib.derive.validate_proposals", return_value=([proposal], [])), \
+             patch("reasons_lib.derive.apply_proposals", return_value=[(proposal, {"truth_value": "IN"})]):
+            added = _stage_derive(args)
+        assert added == 1
+
+
+# --- Stage: Review ---
+
+class TestStageReview:
+    def test_returns_review_result(self, work_dir):
+        args = make_pipeline_args()
+        result = {"reviewed": 5, "invalid": 2, "results": []}
+        with patch("reasons_lib.api.review_beliefs", return_value=result):
+            got = _stage_review(args)
+        assert got["reviewed"] == 5
+        assert got["invalid"] == 2
+
+
+# --- Stage: Repair ---
+
+class TestStageRepair:
+    def test_skips_when_no_invalids(self, work_dir, capsys):
+        args = make_pipeline_args()
+        review_result = {"results": [{"belief_id": "b1", "valid": True}]}
+        result = _stage_repair(args, review_result)
+        assert result["total_invalid"] == 0
+        captured = capsys.readouterr()
+        assert "No invalid beliefs" in captured.err
+
+    def test_calls_research_with_invalid_ids(self, work_dir):
+        args = make_pipeline_args()
+        review_result = {"results": [
+            {"belief_id": "b1", "valid": False},
+            {"belief_id": "b2", "valid": True},
+            {"belief_id": "b3", "valid": False},
+        ]}
+        research_result = {
+            "total_invalid": 2, "linked": 1,
+            "softened": 1, "abandoned": 0,
+        }
+        with patch("reasons_lib.api.research", return_value=research_result) as mock_research:
+            result = _stage_repair(args, review_result)
+        assert mock_research.called
+        call_kwargs = mock_research.call_args[1]
+        assert set(call_kwargs["belief_ids"]) == {"b1", "b3"}
+        assert result["linked"] == 1
+
+
+# --- Stage: Deduplicate ---
+
+class TestStageDeduplicate:
+    def test_reports_no_duplicates(self, work_dir, capsys):
+        args = make_pipeline_args()
+        with patch("reasons_lib.api.deduplicate", return_value={"clusters": [], "retracted": []}):
+            _stage_deduplicate(args)
+        captured = capsys.readouterr()
+        assert "No duplicates found" in captured.err
+
+
+# --- Full Pipeline ---
+
+class TestCmdPipeline:
+    def test_model_not_available_exits(self, work_dir):
+        args = make_pipeline_args(model="nonexistent")
+        with patch("expert_build.llm.check_model_available", return_value=False), \
+             pytest.raises(SystemExit):
+            cmd_pipeline(args)
+
+    def test_converges_early_on_zero_invalids_and_zero_added(self, work_dir, capsys):
+        args = make_pipeline_args(rounds=3, url=None, pdf=None)
+        review_result = {"reviewed": 5, "invalid": 0, "results": []}
+
+        with patch("expert_build.llm.check_model_available", return_value=True), \
+             patch("expert_build.pipeline._stage_summarize"), \
+             patch("expert_build.pipeline._stage_extract", return_value=True), \
+             patch("expert_build.pipeline._stage_derive", return_value=0), \
+             patch("expert_build.pipeline._stage_review", return_value=review_result), \
+             patch("expert_build.pipeline._stage_deduplicate"), \
+             patch("expert_build.pipeline._stage_export"), \
+             patch("expert_build.caffeinate.hold"):
+            cmd_pipeline(args)
+
+        captured = capsys.readouterr()
+        assert "Converged after 1 cycle" in captured.err
+
+    def test_runs_all_rounds_without_convergence(self, work_dir, capsys):
+        args = make_pipeline_args(rounds=2, url=None, pdf=None)
+        review_result = {"reviewed": 5, "invalid": 1, "results": [
+            {"belief_id": "b1", "valid": False},
+        ]}
+
+        with patch("expert_build.llm.check_model_available", return_value=True), \
+             patch("expert_build.pipeline._stage_summarize"), \
+             patch("expert_build.pipeline._stage_extract", return_value=True), \
+             patch("expert_build.pipeline._stage_derive", return_value=1), \
+             patch("expert_build.pipeline._stage_review", return_value=review_result), \
+             patch("expert_build.pipeline._stage_repair"), \
+             patch("expert_build.pipeline._stage_deduplicate"), \
+             patch("expert_build.pipeline._stage_export"), \
+             patch("expert_build.caffeinate.hold"):
+            cmd_pipeline(args)
+
+        captured = capsys.readouterr()
+        assert "Converged" not in captured.err
+        assert "Pipeline complete" in captured.err
+
+    def test_no_auto_accept_stops_early(self, work_dir, capsys):
+        args = make_pipeline_args(no_auto_accept=True, url=None, pdf=None)
+
+        with patch("expert_build.llm.check_model_available", return_value=True), \
+             patch("expert_build.pipeline._stage_summarize"), \
+             patch("expert_build.pipeline._stage_extract", return_value=False), \
+             patch("expert_build.pipeline._stage_derive") as mock_derive, \
+             patch("expert_build.pipeline._stage_export") as mock_export, \
+             patch("expert_build.caffeinate.hold"):
+            cmd_pipeline(args)
+
+        assert not mock_derive.called
+        assert not mock_export.called
diff --git a/uv.lock b/uv.lock
index 5a8e73f..45bd19e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -199,33 +199,43 @@ wheels = [
 name = "fsspec"
 version = "2026.2.0"
 source = { registry = "https://pypi.org/simple" }
 sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" }
 wheels = [
     { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
 ]
 
 [[package]]
 name = "ftl-reasons"
-version = "0.34.0"
+version = "0.43.0"
 source = { editable = "../ftl-reasons" }
 
 [package.metadata]
 requires-dist = [
+    { name = "langchain-anthropic", marker = "extra == 'api'", specifier = ">=0.3" },
+    { name = "langchain-anthropic", marker = "extra == 'llm'", specifier = ">=0.3" },
+    { name = "langchain-google-vertexai", marker = "extra == 'llm'", specifier = ">=2.0" },
+    { name = "langchain-google-vertexai", marker = "extra == 'vertex'", specifier = ">=2.0" },
+    { name = "langfuse", marker = "extra == 'langfuse'", specifier = ">=2.0" },
+    { name = "langfuse", marker = "extra == 'llm'", specifier = ">=2.0" },
+    { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" },
     { name = "psycopg", extras = ["binary"], marker = "extra == 'pg'", specifier = ">=3.1" },
     { name = "psycopg", extras = ["binary"], marker = "extra == 'test-pg'", specifier = ">=3.1" },
     { name = "pytest", marker = "extra == 'test'" },
     { name = "pytest", marker = "extra == 'test-pg'" },
     { name = "pytest-cov", marker = "extra == 'test'" },
     { name = "pytest-cov", marker = "extra == 'test-pg'" },
+    { name = "pyyaml", marker = "extra == 'test'" },
+    { name = "scikit-learn", marker = "extra == 'cluster'", specifier = ">=1.2" },
+    { name = "sentence-transformers", marker = "extra == 'cluster'", specifier = ">=2.2" },
 ]
-provides-extras = ["test", "pg", "test-pg"]
+provides-extras = ["test", "pg", "test-pg", "cluster", "mcp", "api", "vertex", "langfuse", "llm"]
 
 [[package]]
 name = "h11"
 version = "0.16.0"
 source = { registry = "https://pypi.org/simple" }
 sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
 wheels = [
     { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
 ]
 

```

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