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/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" },
 ]
 

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "expert_build/cli.py:main": {
    "function": "main",
    "file": "expert_build/cli.py",
    "start_line": 17,
    "end_line": 139,
    "source": "def main():\n    parser = argparse.ArgumentParser(\n        prog=\"expert-build\",\n        description=\"Build expert agents from documented domains\",\n    )\n    parser.add_argument(\"--version\", action=\"version\", version=f\"%(prog)s {__version__}\")\n\n    sub = parser.add_subparsers(dest=\"command\")\n\n    # -- init --\n    init_p = sub.add_parser(\"init\", help=\"Bootstrap a new expert agent repo\")\n    init_p.add_argument(\"name\", help=\"Domain name (e.g., rhcsa, kubernetes)\")\n    init_p.add_argument(\"--domain\", help=\"One-line domain description\")\n    init_p.add_argument(\"--no-git\", action=\"store_true\", help=\"Skip git init (for subdirectories of existing repos)\")\n\n    # -- fetch-docs --\n    fetch_p = sub.add_parser(\"fetch-docs\", help=\"Fetch documentation from URLs\")\n    fetch_p.add_argument(\"url\", help=\"Starting URL to fetch\")\n    fetch_p.add_argument(\"--depth\", type=int, default=1, help=\"Crawl depth (default: 1)\")\n    fetch_p.add_argument(\"--output-dir\", default=\"sources\", help=\"Output directory (default: sources)\")\n    fetch_p.add_argument(\"--selector\", default=\"main,article,.content,body\",\n                         help=\"CSS selectors for content (comma-separated, default: main,article,.content,body)\")\n    fetch_p.add_argument(\"--sitemap\", action=\"store_true\", help=\"Use sitemap.xml for URL discovery\")\n    fetch_p.add_argument(\"--include\", help=\"URL pattern to include (glob)\")\n    fetch_p.add_argument(\"--exclude\", help=\"URL pattern to exclude (glob)\")\n    fetch_p.add_argument(\"--delay\", type=float, default=1.0, help=\"Delay between requests in seconds (default: 1.0)\")\n\n    # -- chunk-pdf --\n    chunk_p = sub.add_parser(\"chunk-pdf\", help=\"Chunk a PDF paper into section entries\")\n    chunk_p.add_argument(\"pdf\", help=\"Path to PDF file\")\n    chunk_p.add_argument(\"--prefix\", help=\"Entry filename prefix (e.g., 'doyle-1979')\")\n    chunk_p.add_argument(\"--source-label\", help=\"Citation label for Source line\")\n    chunk_p.add_argument(\"--dry-run\", action=\"store_true\", help=\"Show sections without creating entries\")\n\n    # -- summarize --\n    sum_p = sub.add_parser(\"summarize\", help=\"Generate entries from source documents\")\n    sum_p.add_argument(\"--input-dir\", default=\"sources\", help=\"Source directory (default: sources)\")\n    sum_p.add_argument(\"--limit\", type=int, help=\"Max files to process\")\n    sum_p.add_argument(\"--model\", default=\"claude\", help=\"Model to use (default: claude)\")\n\n    # -- propose-beliefs --\n    prop_p = sub.add_parser(\"propose-beliefs\", help=\"Extract candidate beliefs from entries\")\n    prop_p.add_argument(\"--input-dir\", default=\"entries\", help=\"Entries directory (default: entries)\")\n    prop_p.add_argument(\"--output\", default=\"proposed-beliefs.md\",\n                        help=\"Output file (default: proposed-beliefs.md)\")\n    prop_p.add_argument(\"--model\", default=\"claude\", help=\"Model to use (default: claude)\")\n    prop_p.add_argument(\"--batch-size\", type=int, default=5,\n                        help=\"Entries per LLM batch (default: 5)\")\n    prop_p.add_argument(\"--entry\", action=\"append\",\n                        help=\"Process specific entry file(s) instead of all entries\")\n    prop_p.add_argument(\"--all\", action=\"store_true\",\n                        help=\"Re-process all entries (ignore processed tracking)\")\n\n    # -- accept-beliefs --\n    accept_p = sub.add_parser(\"accept-beliefs\", help=\"Import accepted beliefs from proposals\")\n    accept_p.add_argument(\"--file\", default=\"proposed-beliefs.md\",\n                          help=\"Proposals file (default: proposed-beliefs.md)\")\n\n    # -- cert-coverage --\n    cert_p = sub.add_parser(\"cert-coverage\", help=\"Map cert objectives to beliefs\")\n    cert_p.add_argument(\"objectives_file\", help=\"Path to certification objectives\")\n    cert_p.add_argument(\"--beliefs-file\", type=Path, default=Path(\"reasons.db\"))\n    cert_p.add_argument(\"--model\", default=None, help=\"Use LLM for semantic matching\")\n\n    # -- exam --\n    exam_p = sub.add_parser(\"exam\", help=\"Run practice questions, discover gaps\")\n    exam_p.add_argument(\"questions_file\", help=\"Path to practice questions\")\n    exam_p.add_argument(\"--model\", default=\"claude\", help=\"Model to use (default: claude)\")\n    exam_p.add_argument(\"--beliefs-file\", type=Path, default=Path(\"reasons.db\"))\n    exam_p.add_argument(\"--limit\", type=int, help=\"Max questions to process\")\n    exam_p.add_argument(\"--output\", \"-o\", type=Path, default=None,\n                        help=\"Save results to file (markdown)\")\n    exam_p.add_argument(\"--no-judge\", action=\"store_true\",\n                        help=\"Disable LLM judge for open-ended questions (use string matching)\")\n\n    # -- pipeline --\n    pipe_p = sub.add_parser(\"pipeline\", help=\"Run end-to-end EEM construction pipeline\")\n    pipe_p.add_argument(\"--url\", help=\"Starting URL for doc fetching\")\n    pipe_p.add_argument(\"--pdf\", action=\"append\", help=\"PDF files to chunk (repeatable)\")\n    pipe_p.add_argument(\"--sources-dir\", default=\"sources\", help=\"Source directory (default: sources)\")\n    pipe_p.add_argument(\"--model\", default=\"claude\", help=\"Model for LLM calls (default: claude)\")\n    pipe_p.add_argument(\"--rounds\", type=int, default=3,\n                        help=\"Max derive/review/repair cycles (default: 3)\")\n    pipe_p.add_argument(\"--max-derive-rounds\", type=int, default=10,\n                        help=\"Max derive exhaust rounds per cycle (default: 10)\")\n    pipe_p.add_argument(\"--no-auto-accept\", action=\"store_true\",\n                        help=\"Stop after propose-beliefs for human review\")\n    pipe_p.add_argument(\"--no-fetch\", action=\"store_true\",\n                        help=\"Skip fetch-docs (use existing sources/)\")\n    pipe_p.add_argument(\"--depth\", type=int, default=2,\n                        help=\"Crawl depth for fetch-docs (default: 2)\")\n    pipe_p.add_argument(\"--timeout\", type=int, default=600,\n                        help=\"LLM timeout in seconds (default: 600)\")\n    pipe_p.add_argument(\"--domain\", help=\"Domain description for derive context\")\n\n    # -- status --\n    sub.add_parser(\"status\", help=\"Show pipeline progress\")\n\n    # -- install-skill --\n    skill_p = sub.add_parser(\"install-skill\", help=\"Install Claude Code skill file\")\n    skill_p.add_argument(\"--skill-dir\", type=Path, default=Path(\".claude/skills\"),\n                         help=\"Target skills directory\")\n\n    args = parser.parse_args()\n\n    if not args.command:\n        parser.print_help()\n        sys.exit(1)\n\n    commands = {\n        \"init\": lambda a: _lazy(\"init_cmd\", \"cmd_init\")(a),\n        \"chunk-pdf\": lambda a: _lazy(\"chunk_pdf\", \"cmd_chunk_pdf\")(a),\n        \"fetch-docs\": lambda a: _lazy(\"fetch\", \"cmd_fetch_docs\")(a),\n        \"summarize\": lambda a: _lazy(\"summarize\", \"cmd_summarize\")(a),\n        \"propose-beliefs\": lambda a: _lazy(\"propose\", \"cmd_propose_beliefs\")(a),\n        \"accept-beliefs\": lambda a: _lazy(\"propose\", \"cmd_accept_beliefs\")(a),\n        \"cert-coverage\": lambda a: _lazy(\"coverage\", \"cmd_cert_coverage\")(a),\n        \"exam\": lambda a: _lazy(\"exam\", \"cmd_exam\")(a),\n        \"pipeline\": lambda a: _lazy(\"pipeline\", \"cmd_pipeline\")(a),\n        \"status\": lambda a: _lazy(\"init_cmd\", \"cmd_status\")(a),\n        \"install-skill\": lambda a: _lazy(\"init_cmd\", \"cmd_install_skill\")(a),\n    }\n    commands[args.command](args)"
  },
  "expert_build/pipeline.py:cmd_pipeline": {
    "function": "cmd_pipeline",
    "file": "expert_build/pipeline.py",
    "start_line": 246,
    "end_line": 308,
    "source": "def cmd_pipeline(args):\n    \"\"\"Run end-to-end EEM construction pipeline.\"\"\"\n    from .caffeinate import hold as _caffeinate\n    _caffeinate()\n\n    if not check_model_available(args.model):\n        print(f\"Model not available: {args.model}\", file=sys.stderr)\n        sys.exit(1)\n\n    total_stages = 9\n    has_sources = args.url or args.pdf\n\n    # Stage 1: Ingest\n    if has_sources:\n        _banner(1, total_stages, \"INGEST\")\n        _stage_ingest(args)\n    else:\n        print(\"No --url or --pdf provided, skipping ingest\", file=sys.stderr)\n\n    # Stage 2: Summarize\n    _banner(2, total_stages, \"SUMMARIZE\")\n    _stage_summarize(args)\n\n    # Stage 3: Extract\n    _banner(3, total_stages, \"EXTRACT\")\n    should_continue = _stage_extract(args)\n    if not should_continue:\n        return\n\n    # Stages 4-7: Derive \u2192 Review \u2192 Repair \u2192 Deduplicate (convergence loop)\n    for cycle in range(1, args.rounds + 1):\n        label = f\"cycle {cycle}/{args.rounds}\"\n\n        # Stage 4: Derive\n        _banner(4, total_stages, f\"DERIVE ({label})\")\n        added = _stage_derive(args, round_label=label)\n\n        # Stage 5: Review\n        _banner(5, total_stages, f\"REVIEW ({label})\")\n        review_result = _stage_review(args, round_label=label)\n\n        invalid_count = review_result.get(\"invalid\", 0)\n\n        # Stage 6: Repair\n        if invalid_count > 0:\n            _banner(6, total_stages, f\"REPAIR ({label})\")\n            _stage_repair(args, review_result, round_label=label)\n\n        # Stage 7: Deduplicate\n        _banner(7, total_stages, f\"DEDUPLICATE ({label})\")\n        _stage_deduplicate(args, round_label=label)\n\n        # Check convergence\n        if invalid_count == 0 and added == 0:\n            print(f\"\\nConverged after {cycle} cycles \"\n                  f\"(0 invalids, 0 new derivations)\", file=sys.stderr)\n            break\n\n    # Stage 9: Export\n    _banner(9, total_stages, \"EXPORT\")\n    _stage_export(args)\n\n    print(\"\\nPipeline complete.\", file=sys.stderr)"
  },
  "expert_build/propose.py:_build_dedup_context": {
    "function": "_build_dedup_context",
    "file": "expert_build/propose.py",
    "start_line": 208,
    "end_line": 247,
    "source": "def _build_dedup_context(\n    existing_beliefs: list[dict],\n    batch_entry_paths: list[str],\n    batch_text: str,\n    max_detailed: int = 50,\n    max_compact: int = 200,\n    belief_vectors: dict[str, list[float]] | None = None,\n) -> str:\n    \"\"\"Build per-batch dedup context: relevant beliefs with text, rest as compact IDs.\"\"\"\n    if not existing_beliefs:\n        return \"\"\n\n    if belief_vectors:\n        scored = _score_by_embedding(\n            existing_beliefs, belief_vectors, batch_text, batch_entry_paths,\n        )\n    else:\n        scored = _score_by_keywords(\n            existing_beliefs, batch_text, batch_entry_paths,\n        )\n\n    detailed = scored[:max_detailed]\n    compact = scored[max_detailed:max_detailed + max_compact]\n\n    parts = [\n        \"\\n\\n## Already Accepted Beliefs\\n\\n\"\n        \"The following beliefs already exist. Do NOT propose beliefs with these IDs \"\n        \"or that duplicate their meaning under different names.\\n\"\n    ]\n\n    if detailed:\n        parts.append(\"\\nRelevant existing beliefs:\")\n        for _, belief in detailed:\n            parts.append(f\"- `{belief['id']}`: {belief['text']}\")\n\n    if compact:\n        compact_ids = \", \".join(b[\"id\"] for _, b in compact)\n        parts.append(f\"\\nOther existing IDs: {compact_ids}\")\n\n    return \"\\n\".join(parts) + \"\\n\""
  },
  "expert_build/propose.py:cmd_propose_beliefs": {
    "function": "cmd_propose_beliefs",
    "file": "expert_build/propose.py",
    "start_line": 261,
    "end_line": 428,
    "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    all_proposals = []\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            all_proposals.append(result)\n        except Exception as e:\n            print(f\"  ERROR: {e}\")\n            continue\n\n    # Filter out proposals whose IDs already exist\n    filtered_proposals = []\n    skipped = 0\n    for proposal in all_proposals:\n        lines = proposal.split(\"\\n\")\n        filtered_lines = []\n        skip_until_next = False\n        for line in lines:\n            m = re.match(r\"^### \\[?(?:ACCEPT|REJECT)\\]? (\\S+)\", line)\n            if m:\n                belief_id = m.group(1)\n                if belief_id in existing_ids:\n                    skip_until_next = True\n                    skipped += 1\n                    continue\n                else:\n                    skip_until_next = False\n            if skip_until_next:\n                if line.startswith(\"### \"):\n                    skip_until_next = False\n                    filtered_lines.append(line)\n                continue\n            filtered_lines.append(line)\n        filtered_proposals.append(\"\\n\".join(filtered_lines))\n\n    if skipped:\n        print(f\"  Filtered {skipped} already-accepted beliefs\")\n\n    # Record processed entries\n    _save_processed(processed_path, entries, processed)\n\n    # Write proposals file (append if it already exists)\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    if output.exists() and output.stat().st_size > 0:\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            for proposal in filtered_proposals:\n                f.write(proposal)\n                f.write(\"\\n\\n\")\n        print(f\"\\nAppended to {output}\")\n    else:\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            for proposal in filtered_proposals:\n                f.write(proposal)\n                f.write(\"\\n\\n\")\n        print(f\"\\nWrote {output}\")\n\n    print(\"Review the file, mark entries as [ACCEPT] or [REJECT], then run:\")\n    print(\"  expert-build accept-beliefs\")"
  },
  "tests/test_pipeline.py:TestCmdPipeline.test_no_auto_accept_stops_early": {
    "function": "test_no_auto_accept_stops_early",
    "file": "tests/test_pipeline.py",
    "start_line": 277,
    "end_line": 289,
    "source": "    def test_no_auto_accept_stops_early(self, work_dir, capsys):\n        args = make_pipeline_args(no_auto_accept=True, url=None, pdf=None)\n\n        with patch(\"expert_build.llm.check_model_available\", return_value=True), \\\n             patch(\"expert_build.pipeline._stage_summarize\"), \\\n             patch(\"expert_build.pipeline._stage_extract\", return_value=False), \\\n             patch(\"expert_build.pipeline._stage_derive\") as mock_derive, \\\n             patch(\"expert_build.pipeline._stage_export\") as mock_export, \\\n             patch(\"expert_build.caffeinate.hold\"):\n            cmd_pipeline(args)\n\n        assert not mock_derive.called\n        assert not mock_export.called",
    "class_name": "TestCmdPipeline"
  },
  "related_test:tests/test_pipeline.py": {
    "path": "tests/test_pipeline.py",
    "covers": [
      "expert_build/pipeline.py",
      "expert_build/propose.py"
    ],
    "symbols_referenced": [
      "TestAutoAcceptProposals",
      "TestCmdPipeline",
      "TestStageDeduplicate",
      "TestStageDerive",
      "TestStageExtract",
      "TestStageIngest",
      "TestStageRepair",
      "TestStageReview",
      "_stage_deduplicate",
      "_stage_derive",
      "_stage_export",
      "_stage_extract",
      "_stage_ingest",
      "_stage_repair",
      "_stage_review",
      "_stage_summarize",
      "auto_accept_proposals",
      "cmd_pipeline",
      "cmd_propose_beliefs",
      "main",
      "make_pipeline_args",
      "test_applies_valid_proposals",
      "test_auto_accepts_and_imports",
      "test_calls_chunk_pdf",
      "test_calls_fetch_docs_with_url",
      "test_calls_research_with_invalid_ids",
      "test_converges_early_on_zero_invalids_and_zero_added",
      "test_model_not_available_exits",
      "test_no_auto_accept_stops_early",
      "test_no_markers_is_noop",
      "test_preserves_already_accepted",
      "test_replaces_markers",
      "test_reports_no_duplicates",
      "test_returns_review_result",
      "test_returns_zero_on_empty_network",
      "test_runs_all_rounds_without_convergence",
      "test_saturates_on_no_proposals",
      "test_skips_when_no_fetch",
      "test_skips_when_no_invalids",
      "test_stops_on_no_auto_accept",
      "work_dir"
    ],
    "content": "\"\"\"Tests for the pipeline command.\"\"\"\n\nimport types\nfrom pathlib import Path\nfrom unittest.mock import patch, MagicMock\n\nimport pytest\n\nfrom expert_build.pipeline import (\n    cmd_pipeline,\n    _stage_ingest,\n    _stage_extract,\n    _stage_derive,\n    _stage_review,\n    _stage_repair,\n    _stage_deduplicate,\n)\nfrom expert_build.propose import auto_accept_proposals\n\n\n@pytest.fixture\ndef work_dir(tmp_path, monkeypatch):\n    \"\"\"Set working directory to tmp_path for isolated pipeline runs.\"\"\"\n    monkeypatch.chdir(tmp_path)\n    (tmp_path / \"sources\").mkdir()\n    (tmp_path / \"entries\").mkdir()\n    (tmp_path / \"reasons.db\").touch()\n    return tmp_path\n\n\ndef make_pipeline_args(**overrides):\n    defaults = dict(\n        url=None,\n        pdf=None,\n        sources_dir=\"sources\",\n        model=\"claude\",\n        rounds=3,\n        max_derive_rounds=10,\n        no_auto_accept=False,\n        no_fetch=False,\n        depth=2,\n        timeout=600,\n        domain=\"Test domain\",\n    )\n    defaults.update(overrides)\n    return types.SimpleNamespace(**defaults)\n\n\n# --- auto_accept_proposals ---\n\nclass TestAutoAcceptProposals:\n    def test_replaces_markers(self, tmp_path):\n        f = tmp_path / \"proposals.md\"\n        f.write_text(\n            \"### [ACCEPT/REJECT] belief-one\\n\"\n            \"Text one\\n\"\n            \"### [ACCEPT/REJECT] belief-two\\n\"\n            \"Text two\\n\"\n        )\n        auto_accept_proposals(str(f))\n        text = f.read_text()\n        assert \"[ACCEPT/REJECT]\" not in text\n        assert text.count(\"[ACCEPT]\") == 2\n\n    def test_preserves_already_accepted(self, tmp_path):\n        f = tmp_path / \"proposals.md\"\n        f.write_text(\n            \"### [ACCEPT] already-good\\n\"\n            \"Text\\n\"\n            \"### [ACCEPT/REJECT] needs-accept\\n\"\n            \"Text\\n\"\n        )\n        auto_accept_proposals(str(f))\n        text = f.read_text()\n        assert text.count(\"[ACCEPT]\") == 2\n        assert \"[ACCEPT/REJECT]\" not in text\n\n    def test_no_markers_is_noop(self, tmp_path):\n        f = tmp_path / \"proposals.md\"\n        original = \"### ACCEPT belief-one\\nText\\n\"\n        f.write_text(original)\n        auto_accept_proposals(str(f))\n        assert f.read_text() == original\n\n\n# --- Stage: Ingest ---\n\nclass TestStageIngest:\n    def test_skips_when_no_fetch(self, work_dir, capsys):\n        args = make_pipeline_args(no_fetch=True)\n        _stage_ingest(args)\n        captured = capsys.readouterr()\n        assert \"Skipping fetch\" in captured.err\n\n    def test_calls_fetch_docs_with_url(self, work_dir):\n        args = make_pipeline_args(url=\"https://example.com/docs\")\n        with patch(\"expert_build.fetch.cmd_fetch_docs\") as mock_fetch:\n            _stage_ingest(args)\n        assert mock_fetch.called\n        fetch_args = mock_fetch.call_args[0][0]\n        assert fetch_args.url == \"https://example.com/docs\"\n        assert fetch_args.depth == 2\n\n    def test_calls_chunk_pdf(self, work_dir):\n        args = make_pipeline_args(pdf=[\"paper.pdf\"])\n        with patch(\"expert_build.chunk_pdf.cmd_chunk_pdf\") as mock_chunk:\n            _stage_ingest(args)\n        assert mock_chunk.called\n\n\n# --- Stage: Extract ---\n\nclass TestStageExtract:\n    def test_stops_on_no_auto_accept(self, work_dir, capsys):\n        args = make_pipeline_args(no_auto_accept=True)\n        with patch(\"expert_build.propose.cmd_propose_beliefs\"):\n            result = _stage_extract(args)\n        assert result is False\n        captured = capsys.readouterr()\n        assert \"--no-auto-accept\" in captured.err\n\n    def test_auto_accepts_and_imports(self, work_dir):\n        proposals = work_dir / \"proposed-beliefs.md\"\n        proposals.write_text(\"### [ACCEPT/REJECT] test-belief\\nText\\n- Source: test\\n\")\n        args = make_pipeline_args()\n\n        with patch(\"expert_build.propose.cmd_propose_beliefs\"), \\\n             patch(\"expert_build.propose.cmd_accept_beliefs\") as mock_accept:\n            result = _stage_extract(args)\n\n        assert result is True\n        assert mock_accept.called\n        text = proposals.read_text()\n        assert \"[ACCEPT]\" in text\n        assert \"[ACCEPT/REJECT]\" not in text\n\n\n# --- Stage: Derive ---\n\nclass TestStageDerive:\n    def test_returns_zero_on_empty_network(self, work_dir):\n        args = make_pipeline_args()\n        with patch(\"reasons_lib.api.export_network\", return_value={\"nodes\": {}}):\n            added = _stage_derive(args)\n        assert added == 0\n\n    def test_saturates_on_no_proposals(self, work_dir):\n        args = make_pipeline_args()\n        nodes = {\"belief-1\": {\"text\": \"Test\", \"truth_value\": \"IN\", \"justifications\": []}}\n        stats = {\"total_in\": 1, \"total_derived\": 0, \"max_depth\": 0, \"agents\": 0}\n        with patch(\"reasons_lib.api.export_network\", return_value={\"nodes\": nodes}), \\\n             patch(\"reasons_lib.derive.build_prompt\", return_value=(\"prompt\", stats)), \\\n             patch(\"expert_build.llm.invoke_sync\", return_value=\"No proposals\"), \\\n             patch(\"reasons_lib.derive.parse_proposals\", return_value=[]):\n            added = _stage_derive(args)\n        assert added == 0\n\n    def test_applies_valid_proposals(self, work_dir):\n        args = make_pipeline_args(max_derive_rounds=1)\n        nodes = {\"belief-1\": {\"text\": \"Test\", \"truth_value\": \"IN\", \"justifications\": []}}\n        stats = {\"total_in\": 1, \"total_derived\": 0, \"max_depth\": 0, \"agents\": 0}\n        proposal = {\n            \"id\": \"derived-1\", \"text\": \"Derived\",\n            \"antecedents\": [\"belief-1\"], \"unless\": [],\n            \"label\": \"test\", \"kind\": \"derive\",\n        }\n        with patch(\"reasons_lib.api.export_network\", return_value={\"nodes\": nodes}), \\\n             patch(\"reasons_lib.derive.build_prompt\", return_value=(\"prompt\", stats)), \\\n             patch(\"expert_build.llm.invoke_sync\", return_value=\"proposal text\"), \\\n             patch(\"reasons_lib.derive.parse_proposals\", return_value=[proposal]), \\\n             patch(\"reasons_lib.derive.validate_proposals\", return_value=([proposal], [])), \\\n             patch(\"reasons_lib.derive.apply_proposals\", return_value=[(proposal, {\"truth_value\": \"IN\"})]):\n            added = _stage_derive(args)\n        assert added == 1\n\n\n# --- Stage: Review ---\n\nclass TestStageReview:\n    def test_returns_review_result(self, work_dir):\n        args = make_pipeline_args()\n        result = {\"reviewed\": 5, \"invalid\": 2, \"results\": []}\n        with patch(\"reasons_lib.api.review_beliefs\", return_value=result):\n            got = _stage_review(args)\n        assert got[\"reviewed\"] == 5\n        assert got[\"invalid\"] == 2\n\n\n# --- Stage: Repair ---\n\nclass TestStageRepair:\n    def test_skips_when_no_invalids(self, work_dir, capsys):\n        args = make_pipeline_args()\n        review_result = {\"results\": [{\"belief_id\": \"b1\", \"valid\": True}]}\n        result = _stage_repair(args, review_result)\n        assert result[\"total_invalid\"] == 0\n        captured = capsys.readouterr()\n        assert \"No invalid beliefs\" in captured.err\n\n    def test_calls_research_with_invalid_ids(self, work_dir):\n        args = make_pipeline_args()\n        review_result = {\"results\": [\n            {\"belief_id\": \"b1\", \"valid\": False},\n            {\"belief_id\": \"b2\", \"valid\": True},\n            {\"belief_id\": \"b3\", \"valid\": False},\n        ]}\n        research_result = {\n            \"total_invalid\": 2, \"linked\": 1,\n            \"softened\": 1, \"abandoned\": 0,\n        }\n        with patch(\"reasons_lib.api.research\", return_value=research_result) as mock_research:\n            result = _stage_repair(args, review_result)\n        assert mock_research.called\n        call_kwargs = mock_research.call_args[1]\n        assert set(call_kwargs[\"belief_ids\"]) == {\"b1\", \"b3\"}\n        assert result[\"linked\"] == 1\n\n\n# --- Stage: Deduplicate ---\n\nclass TestStageDeduplicate:\n    def test_reports_no_duplicates(self, work_dir, capsys):\n        args = make_pipeline_args()\n        with patch(\"reasons_lib.api.deduplicate\", return_value={\"clusters\": [], \"retracted\": []}):\n            _stage_deduplicate(args)\n        captured = capsys.readouterr()\n        assert \"No duplicates found\" in captured.err\n\n\n# --- Full Pipeline ---\n\nclass TestCmdPipeline:\n    def test_model_not_available_exits(self, work_dir):\n        args = make_pipeline_args(model=\"nonexistent\")\n        with patch(\"expert_build.llm.check_model_available\", return_value=False), \\\n             pytest.raises(SystemExit):\n            cmd_pipeline(args)\n\n    def test_converges_early_on_zero_invalids_and_zero_added(self, work_dir, capsys):\n        args = make_pipeline_args(rounds=3, url=None, pdf=None)\n        review_result = {\"reviewed\": 5, \"invalid\": 0, \"results\": []}\n\n        with patch(\"expert_build.llm.check_model_available\", return_value=True), \\\n             patch(\"expert_build.pipeline._stage_summarize\"), \\\n             patch(\"expert_build.pipeline._stage_extract\", return_value=True), \\\n             patch(\"expert_build.pipeline._stage_derive\", return_value=0), \\\n             patch(\"expert_build.pipeline._stage_review\", return_value=review_result), \\\n             patch(\"expert_build.pipeline._stage_deduplicate\"), \\\n             patch(\"expert_build.pipeline._stage_export\"), \\\n             patch(\"expert_build.caffeinate.hold\"):\n            cmd_pipeline(args)\n\n        captured = capsys.readouterr()\n        assert \"Converged after 1 cycle\" in captured.err\n\n    def test_runs_all_rounds_without_convergence(self, work_dir, capsys):\n        args = make_pipeline_args(rounds=2, url=None, pdf=None)\n        review_result = {\"reviewed\": 5, \"invalid\": 1, \"results\": [\n            {\"belief_id\": \"b1\", \"valid\": False},\n        ]}\n\n        with patch(\"expert_build.llm.check_model_available\", return_value=True), \\\n             patch(\"expert_build.pipeline._stage_summarize\"), \\\n             patch(\"expert_build.pipeline._stage_extract\", return_value=True), \\\n             patch(\"expert_build.pipeline._stage_derive\", return_value=1), \\\n             patch(\"expert_build.pipeline._stage_review\", return_value=review_result), \\\n             patch(\"expert_build.pipeline._stage_repair\"), \\\n             patch(\"expert_build.pipeline._stage_deduplicate\"), \\\n             patch(\"expert_build.pipeline._stage_export\"), \\\n             patch(\"expert_build.caffeinate.hold\"):\n            cmd_pipeline(args)\n\n        captured = capsys.readouterr()\n        assert \"Converged\" not in captured.err\n        assert \"Pipeline complete\" in captured.err\n\n    def test_no_auto_accept_stops_early(self, work_dir, capsys):\n        args = make_pipeline_args(no_auto_accept=True, url=None, pdf=None)\n\n        with patch(\"expert_build.llm.check_model_available\", return_value=True), \\\n             patch(\"expert_build.pipeline._stage_summarize\"), \\\n             patch(\"expert_build.pipeline._stage_extract\", return_value=False), \\\n             patch(\"expert_build.pipeline._stage_derive\") as mock_derive, \\\n             patch(\"expert_build.pipeline._stage_export\") as mock_export, \\\n             patch(\"expert_build.caffeinate.hold\"):\n            cmd_pipeline(args)\n\n        assert not mock_derive.called\n        assert not mock_export.called",
    "truncated": false,
    "line_count": 289
  },
  "cmd_accept_beliefs_body": {
    "function": "cmd_accept_beliefs",
    "file": "expert_build/propose.py",
    "start_line": 431,
    "end_line": 482,
    "source": "def cmd_accept_beliefs(args):\n    \"\"\"Import accepted beliefs from proposals file.\"\"\"\n    proposals_file = Path(args.file)\n    if not proposals_file.exists():\n        print(f\"Proposals file not found: {proposals_file}\")\n        print(\"Run: expert-build propose-beliefs\")\n        sys.exit(1)\n\n    text = proposals_file.read_text()\n\n    # Parse accepted beliefs \u2014 tolerate both ### [ACCEPT] and ### ACCEPT\n    pattern = re.compile(\n        r\"### \\[?ACCEPT\\]? (\\S+)\\n\"\n        r\"(.+?)\\n\"\n        r\"- Source: (.+?)\\n\"\n        r\"(?:- Source URL: (.+?)\\n)?\"\n    )\n    matches = pattern.findall(text)\n\n    if not matches:\n        print(\"No [ACCEPT] entries found in proposals file.\")\n        print(\"Edit the file and change [ACCEPT/REJECT] to [ACCEPT] for beliefs to keep.\")\n        return\n\n    print(f\"Found {len(matches)} accepted beliefs\")\n\n    added = 0\n    failed = 0\n    for match in matches:\n        belief_id, claim_text, source = match[0], match[1], match[2]\n        source_url = match[3] if len(match) > 3 else \"\"\n        if source_url and source_url.lower() == \"none\":\n            source_url = \"\"\n        try:\n            add_node(\n                node_id=belief_id,\n                text=claim_text.strip(),\n                source=source.strip(),\n                source_url=source_url.strip() if source_url else \"\",\n                db_path=REASONS_DB,\n            )\n            print(f\"  Added: {belief_id}\")\n            added += 1\n        except Exception as e:\n            err = str(e)\n            if \"already exists\" in err.lower() or \"duplicate\" in err.lower():\n                print(f\"  EXISTS: {belief_id}\")\n            else:\n                print(f\"  FAIL: {belief_id}: {err}\")\n                failed += 1\n\n    print(f\"\\nAccepted {added} beliefs ({failed} failed)\")"
  },
  "cmd_propose_beliefs_body": {
    "function": "cmd_propose_beliefs",
    "file": "expert_build/propose.py",
    "start_line": 261,
    "end_line": 428,
    "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    all_proposals = []\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            all_proposals.append(result)\n        except Exception as e:\n            print(f\"  ERROR: {e}\")\n            continue\n\n    # Filter out proposals whose IDs already exist\n    filtered_proposals = []\n    skipped = 0\n    for proposal in all_proposals:\n        lines = proposal.split(\"\\n\")\n        filtered_lines = []\n        skip_until_next = False\n        for line in lines:\n            m = re.match(r\"^### \\[?(?:ACCEPT|REJECT)\\]? (\\S+)\", line)\n            if m:\n                belief_id = m.group(1)\n                if belief_id in existing_ids:\n                    skip_until_next = True\n                    skipped += 1\n                    continue\n                else:\n                    skip_until_next = False\n            if skip_until_next:\n                if line.startswith(\"### \"):\n                    skip_until_next = False\n                    filtered_lines.append(line)\n                continue\n            filtered_lines.append(line)\n        filtered_proposals.append(\"\\n\".join(filtered_lines))\n\n    if skipped:\n        print(f\"  Filtered {skipped} already-accepted beliefs\")\n\n    # Record processed entries\n    _save_processed(processed_path, entries, processed)\n\n    # Write proposals file (append if it already exists)\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    if output.exists() and output.stat().st_size > 0:\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            for proposal in filtered_proposals:\n                f.write(proposal)\n                f.write(\"\\n\\n\")\n        print(f\"\\nAppended to {output}\")\n    else:\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            for proposal in filtered_proposals:\n                f.write(proposal)\n                f.write(\"\\n\\n\")\n        print(f\"\\nWrote {output}\")\n\n    print(\"Review the file, mark entries as [ACCEPT] or [REJECT], then run:\")\n    print(\"  expert-build accept-beliefs\")"
  },
  "cmd_fetch_docs_body": {
    "function": "cmd_fetch_docs",
    "file": "expert_build/fetch.py",
    "start_line": 175,
    "end_line": 262,
    "source": "def cmd_fetch_docs(args):\n    \"\"\"Fetch documentation from URLs and save as markdown.\"\"\"\n    from .caffeinate import hold as _caffeinate\n    _caffeinate()\n    output_dir = Path(args.output_dir)\n    output_dir.mkdir(parents=True, exist_ok=True)\n    base_domain = urlparse(args.url).netloc\n\n    headers = {\"User-Agent\": \"expert-build/0.1 (documentation fetcher)\"}\n\n    with httpx.Client(timeout=30, headers=headers, follow_redirects=True) as client:\n        if args.sitemap:\n            urls = fetch_sitemap(args.url, client)\n            urls = [u for u in urls if matches_patterns(u, args.include, args.exclude)]\n            print(f\"Found {len(urls)} URLs in sitemap\")\n            queue = [(u, 0) for u in urls]\n        else:\n            queue = [(args.url, 0)]\n\n        visited = set()\n        fetched = 0\n\n        while queue:\n            url, depth = queue.pop(0)\n            if url in visited:\n                continue\n            if depth > args.depth:\n                continue\n\n            visited.add(url)\n\n            try:\n                resp = client.get(url)\n                resp.raise_for_status()\n            except httpx.HTTPError as e:\n                print(f\"  SKIP {url}: {e}\")\n                continue\n\n            content_type = resp.headers.get(\"content-type\", \"\")\n            if \"html\" not in content_type:\n                continue\n\n            soup = BeautifulSoup(resp.text, \"html.parser\")\n\n            # Find content area using selector list\n            content_element = None\n            for selector in args.selector.split(\",\"):\n                selector = selector.strip()\n                content_element = soup.select_one(selector)\n                if content_element:\n                    break\n            if not content_element:\n                content_element = soup.body or soup\n\n            md = html_to_markdown(content_element)\n\n            if not md.strip():\n                continue\n\n            slug = slugify_url(url)\n            out_path = output_dir / f\"{slug}.md\"\n\n            # Write with frontmatter\n            frontmatter = (\n                f\"---\\n\"\n                f\"source: {url}\\n\"\n                f\"fetched: {date.today().isoformat()}\\n\"\n                f\"---\\n\\n\"\n            )\n            out_path.write_text(frontmatter + md)\n            fetched += 1\n            print(f\"  {out_path}\")\n\n            # Discover links for crawling (only if not sitemap mode)\n            if not args.sitemap and depth < args.depth:\n                for a in soup.find_all(\"a\", href=True):\n                    href = urljoin(url, a[\"href\"])\n                    # Strip fragment\n                    href = href.split(\"#\")[0]\n                    # Only follow same-domain links\n                    if urlparse(href).netloc == base_domain:\n                        if href not in visited and matches_patterns(href, args.include, args.exclude):\n                            queue.append((href, depth + 1))\n\n            if args.delay > 0:\n                time.sleep(args.delay)\n\n    print(f\"\\nFetched {fetched} pages to {output_dir}/\")"
  },
  "cmd_chunk_pdf_body": {
    "function": "cmd_chunk_pdf",
    "file": "expert_build/chunk_pdf.py",
    "start_line": 151,
    "end_line": 257,
    "source": "def cmd_chunk_pdf(args):\n    \"\"\"Chunk a PDF paper into section-by-section entries.\"\"\"\n    pdf_path = Path(args.pdf).resolve()\n    if not pdf_path.exists():\n        print(f\"PDF not found: {pdf_path}\")\n        sys.exit(1)\n\n    if pdf_path.suffix.lower() != \".pdf\":\n        print(f\"Not a PDF file: {pdf_path}\")\n        sys.exit(1)\n\n    prefix = args.prefix or slugify(pdf_path.stem)\n    source_label = args.source_label or pdf_path.stem\n\n    print(f\"Reading PDF: {pdf_path}\")\n    pages = extract_text_by_page(pdf_path)\n    print(f\"  {len(pages)} pages extracted\")\n\n    if not pages:\n        print(\"ERROR: No pages found in PDF.\")\n        sys.exit(1)\n\n    if not check_text_quality(pages):\n        print(\"ERROR: PDF appears to be scanned with no text layer.\")\n        print(\"OCR the PDF first (e.g., with ocrmypdf) and try again.\")\n        sys.exit(1)\n\n    print(\"Identifying sections...\")\n    sections = identify_sections(pages)\n\n    if not sections:\n        print(\"  No sections found. Falling back to one entry per page.\")\n        sections = [\n            {\"number\": str(i + 1), \"title\": f\"Page {i + 1}\", \"start_page\": i + 1, \"end_page\": i + 1}\n            for i in range(len(pages))\n        ]\n\n    print(f\"  Found {len(sections)} sections:\")\n    for s in sections:\n        print(f\"    {s['number']}. {s['title']} (pp. {s['start_page']}-{s['end_page']})\")\n\n    if args.dry_run:\n        print(\"\\n(dry run \u2014 no entries created)\")\n        return\n\n    manifest = Path(f\".chunked-{prefix}\")\n    done = set()\n    if manifest.exists():\n        done = set(manifest.read_text().strip().split(\"\\n\"))\n\n    created = 0\n    skipped = 0\n\n    for section in sections:\n        filename = make_entry_filename(prefix, section)\n\n        if filename in done:\n            print(f\"  SKIP (already chunked): {filename}\")\n            skipped += 1\n            continue\n\n        print(f\"  Creating: Section {section['number']} \u2014 {section['title']}...\")\n\n        content = format_section_content(pages, section, source_label)\n        title = f\"Section {section['number']}: {section['title']}\"\n\n        try:\n            with tempfile.NamedTemporaryFile(\n                mode=\"w\", suffix=\".md\", delete=False,\n            ) as tmp:\n                tmp.write(content)\n                tmp_path = tmp.name\n\n            result = subprocess.run(\n                [\"entry\", \"create\", filename, title, \"--content-file\", tmp_path],\n                capture_output=True,\n                text=True,\n            )\n\n            Path(tmp_path).unlink(missing_ok=True)\n\n            if result.returncode == 0:\n                print(f\"    -> {result.stdout.strip()}\")\n            else:\n                result = subprocess.run(\n                    [\"entry\", \"create\", filename, title, \"--content\", content],\n                    capture_output=True,\n                    text=True,\n                )\n                if result.returncode == 0:\n                    print(f\"    -> {result.stdout.strip()}\")\n                else:\n                    print(f\"    WARN: entry create failed: {result.stderr.strip()}\")\n                    continue\n        except FileNotFoundError:\n            print(\"  ERROR: entry CLI not found. Install with: uv tool install entry\")\n            sys.exit(1)\n\n        with manifest.open(\"a\") as f:\n            f.write(f\"{filename}\\n\")\n        done.add(filename)\n\n        created += 1\n\n    print(f\"\\nChunked {created} sections ({skipped} already done)\")\n    if created:\n        print(\"Next: expert-build propose-beliefs\")"
  },
  "cmd_summarize_body": {
    "function": "cmd_summarize",
    "file": "expert_build/summarize.py",
    "start_line": 12,
    "end_line": 142,
    "source": "def cmd_summarize(args):\n    \"\"\"Generate entries from source documents.\"\"\"\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\"Source directory not found: {input_dir}\")\n        print(\"Run: expert-build fetch-docs <url>\")\n        sys.exit(1)\n\n    if not check_model_available(args.model):\n        print(f\"Model not available: {args.model}\")\n        print(\"Install claude CLI or specify --model\")\n        sys.exit(1)\n\n    sources = sorted(\n        [*input_dir.glob(\"*.md\"), *input_dir.glob(\"*.py\")],\n        key=lambda p: p.name,\n    )\n    if not sources:\n        print(f\"No .md or .py files in {input_dir}\")\n        return\n\n    if args.limit:\n        sources = sources[:args.limit]\n\n    # Track what's been summarized\n    manifest = Path(\".summarized\")\n    done = set()\n    if manifest.exists():\n        done = set(manifest.read_text().strip().split(\"\\n\"))\n\n    processed = 0\n    skipped = 0\n\n    for source_path in sources:\n        if str(source_path) in done:\n            skipped += 1\n            continue\n\n        print(f\"Summarizing: {source_path.name}\")\n\n        content = source_path.read_text()\n\n        # Extract and strip frontmatter\n        source_url = None\n        source_id = None\n        if content.startswith(\"---\"):\n            end = content.find(\"---\", 3)\n            if end != -1:\n                frontmatter = content[3:end]\n                for line in frontmatter.splitlines():\n                    if line.startswith(\"source_url:\"):\n                        source_url = line.split(\":\", 1)[1].strip()\n                    elif line.startswith(\"source_id:\"):\n                        source_id = line.split(\":\", 1)[1].strip()\n                content = content[end + 3:].strip()\n\n        if not content.strip():\n            print(f\"  SKIP (empty)\")\n            continue\n\n        # Truncate very long documents\n        if len(content) > 30000:\n            original_len = len(content)\n            content = content[:30000] + \"\\n\\n[Truncated \u2014 original was longer]\"\n            if source_path.suffix == \".pdf\":\n                print(f\"  WARN: truncated from {original_len} to 30000 chars. \"\n                      f\"Consider: expert-build chunk-pdf {source_path}\")\n            else:\n                print(f\"  WARN: truncated from {original_len} to 30000 chars. \"\n                      f\"Large documents may lose tail content.\")\n\n        template = SUMMARIZE_CODE if source_path.suffix == \".py\" else SUMMARIZE\n        prompt = template.format(content=content)\n\n        try:\n            summary = invoke_sync(prompt, model=args.model)\n        except Exception as e:\n            print(f\"  ERROR: {e}\")\n            continue\n\n        # Extract a title from the summary or source filename\n        title_match = re.search(r\"^#+ (.+)$\", summary, re.MULTILINE)\n        title = title_match.group(1) if title_match else source_path.stem.replace(\"-\", \" \").title()\n        topic = source_path.stem\n\n        # Create entry via entry CLI\n        entry_path = None\n        try:\n            result = subprocess.run(\n                [\"entry\", \"create\", topic, title, \"--content\", summary],\n                capture_output=True, text=True,\n            )\n            if result.returncode == 0:\n                entry_path = result.stdout.strip().replace(\"Created \", \"\")\n                print(f\"  -> {result.stdout.strip()}\")\n            else:\n                # Try alternative invocation\n                result = subprocess.run(\n                    [\"entry\", \"create\", topic, title],\n                    input=summary,\n                    capture_output=True, text=True,\n                )\n                if result.returncode == 0:\n                    entry_path = result.stdout.strip().replace(\"Created \", \"\")\n                    print(f\"  -> {result.stdout.strip()}\")\n                else:\n                    print(f\"  WARN: entry create failed: {result.stderr.strip()}\")\n        except FileNotFoundError:\n            print(\"  ERROR: entry CLI not found. Install with: uv tool install entry\")\n            sys.exit(1)\n\n        # Prepend source provenance frontmatter to the entry file\n        if entry_path and source_url:\n            ep = Path(entry_path)\n            if ep.exists():\n                fm = f\"---\\nsource_url: {source_url}\\n\"\n                if source_id:\n                    fm += f\"source_id: {source_id}\\n\"\n                fm += \"---\\n\\n\"\n                ep.write_text(fm + ep.read_text())\n\n        # Record as done\n        with manifest.open(\"a\") as f:\n            f.write(f\"{source_path}\\n\")\n        done.add(str(source_path))\n\n        processed += 1\n\n    print(f\"\\nSummarized {processed} sources ({skipped} already done)\")"
  },
  "reasons_db_constant": {
    "symbol": "REASONS_DB",
    "usages": [
      {
        "file": "expert_build/exam.py",
        "line": 12,
        "text": "REASONS_DB = \"reasons.db\""
      },
      {
        "file": "expert_build/exam.py",
        "line": 88,
        "text": "def load_beliefs_for_context(db_path: str = REASONS_DB) -> str:"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 11,
        "text": "REASONS_DB = \"reasons.db\""
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 35,
        "text": "if not (cwd / REASONS_DB).exists():"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 36,
        "text": "init_db(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 39,
        "text": "print(f\"{REASONS_DB} already exists, skipping reasons init\")"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 103,
        "text": "reasons_db = cwd / REASONS_DB"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 107,
        "text": "status = reasons_status(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 109,
        "text": "network = export_network(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/propose.py",
        "line": 16,
        "text": "REASONS_DB = \"reasons.db\""
      },
      {
        "file": "expert_build/propose.py",
        "line": 41,
        "text": "def _load_existing_beliefs(db_path: str = REASONS_DB) -> list[dict]:"
      },
      {
        "file": "expert_build/propose.py",
        "line": 470,
        "text": "db_path=REASONS_DB,"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 12,
        "text": "REASONS_DB = \"reasons.db\""
      },
      {
        "file": "expert_build/coverage.py",
        "line": 46,
        "text": "def load_beliefs(db_path: str = REASONS_DB) -> list[dict]:"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 9,
        "text": "from .propose import REASONS_DB"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 109,
        "text": "data = export_network(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 139,
        "text": "results = apply_proposals(valid, db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 160,
        "text": "db_path=REASONS_DB,"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 194,
        "text": "db_path=REASONS_DB,"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 211,
        "text": "result = deduplicate(auto=True, db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 228,
        "text": "data = export_network(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 235,
        "text": "card = export_card(db_path=REASONS_DB, domain=args.domain)"
      }
    ],
    "production_usages": [
      {
        "file": "expert_build/exam.py",
        "line": 12,
        "text": "REASONS_DB = \"reasons.db\""
      },
      {
        "file": "expert_build/exam.py",
        "line": 88,
        "text": "def load_beliefs_for_context(db_path: str = REASONS_DB) -> str:"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 11,
        "text": "REASONS_DB = \"reasons.db\""
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 35,
        "text": "if not (cwd / REASONS_DB).exists():"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 36,
        "text": "init_db(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 39,
        "text": "print(f\"{REASONS_DB} already exists, skipping reasons init\")"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 103,
        "text": "reasons_db = cwd / REASONS_DB"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 107,
        "text": "status = reasons_status(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/init_cmd.py",
        "line": 109,
        "text": "network = export_network(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/propose.py",
        "line": 16,
        "text": "REASONS_DB = \"reasons.db\""
      },
      {
        "file": "expert_build/propose.py",
        "line": 41,
        "text": "def _load_existing_beliefs(db_path: str = REASONS_DB) -> list[dict]:"
      },
      {
        "file": "expert_build/propose.py",
        "line": 470,
        "text": "db_path=REASONS_DB,"
      },
      {
        "file": "expert_build/coverage.py",
        "line": 12,
        "text": "REASONS_DB = \"reasons.db\""
      },
      {
        "file": "expert_build/coverage.py",
        "line": 46,
        "text": "def load_beliefs(db_path: str = REASONS_DB) -> list[dict]:"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 9,
        "text": "from .propose import REASONS_DB"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 109,
        "text": "data = export_network(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 139,
        "text": "results = apply_proposals(valid, db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 160,
        "text": "db_path=REASONS_DB,"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 194,
        "text": "db_path=REASONS_DB,"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 211,
        "text": "result = deduplicate(auto=True, db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 228,
        "text": "data = export_network(db_path=REASONS_DB)"
      },
      {
        "file": "expert_build/pipeline.py",
        "line": 235,
        "text": "card = export_card(db_path=REASONS_DB, domain=args.domain)"
      }
    ],
    "test_usages": [],
    "production_count": 22,
    "test_count": 0,
    "total_count": 22
  },
  "invoke_sync_raises": {
    "function": "invoke_sync",
    "file": "expert_build/llm.py",
    "explicit_raises": [],
    "calls": [
      "invoke",
      "run"
    ]
  },
  "check_model_available_body": {
    "function": "check_model_available",
    "file": "expert_build/llm.py",
    "start_line": 15,
    "end_line": 20,
    "source": "def check_model_available(model: str) -> bool:\n    \"\"\"Check if a model's CLI is available.\"\"\"\n    if model not in MODEL_COMMANDS:\n        return False\n    cmd = MODEL_COMMANDS[model][0]\n    return shutil.which(cmd) is not None"
  },
  "review_beliefs_raises": {
    "error": "File not found: reasons_lib/api.py",
    "function": "review_beliefs"
  },
  "research_body": {
    "error": "File not found: reasons_lib/api.py",
    "file": "reasons_lib/api.py"
  },
  "deduplicate_body": {
    "error": "File not found: reasons_lib/api.py",
    "file": "reasons_lib/api.py"
  },
  "export_card_body": {
    "error": "File not found: reasons_lib/api.py",
    "file": "reasons_lib/api.py"
  },
  "build_prompt_body": {
    "error": "File not found: reasons_lib/derive.py",
    "file": "reasons_lib/derive.py"
  },
  "apply_proposals_body": {
    "error": "File not found: reasons_lib/derive.py",
    "file": "reasons_lib/derive.py"
  },
  "caffeinate_hold_body": {
    "function": "hold",
    "file": "expert_build/caffeinate.py",
    "start_line": 10,
    "end_line": 25,
    "source": "def hold():\n    \"\"\"Start caffeinate to prevent idle sleep. No-op on non-macOS.\"\"\"\n    global _process\n    if _process is not None:\n        return\n    if platform.system() != \"Darwin\":\n        return\n    try:\n        _process = subprocess.Popen(\n            [\"caffeinate\", \"-i\"],\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n        )\n        atexit.register(release)\n    except FileNotFoundError:\n        pass"
  },
  "pipeline_imports": {
    "file": "expert_build/pipeline.py",
    "imports": [
      "re",
      "sys"
    ],
    "from_imports": [
      {
        "module": "pathlib",
        "names": [
          "Path"
        ]
      },
      {
        "module": "types",
        "names": [
          "SimpleNamespace"
        ]
      },
      {
        "module": "llm",
        "names": [
          "check_model_available",
          "invoke_sync"
        ]
      },
      {
        "module": "propose",
        "names": [
          "REASONS_DB"
        ]
      }
    ],
    "import_section": "\"\"\"End-to-end EEM construction pipeline.\"\"\"\n\nimport re\nimport sys\nfrom pathlib import Path\nfrom types import SimpleNamespace\n\nfrom .llm import check_model_available, invoke_sync\nfrom .propose import REASONS_DB\n\n\ndef _banner(stage_num, total, name):\n    print(f\"\\n{'=' * 50}\", file=sys.stderr)\n    print(f\"  Stage {stage_num}/{total}: {name}\", file=sys.stderr)\n    print(f\"{'=' * 50}\\n\", file=sys.stderr)\n\n\ndef _stage_ingest(args):\n    \"\"\"Stage 1: Fetch docs or chunk PDFs into sources/.\"\"\""
  },
  "project_deps": {
    "repo": "/Users/ben/git/expert-agent-builder",
    "pyproject_toml": "[project]\nname = \"expert-agent-builder\"\nversion = \"0.2.0\"\ndescription = \"Build expert agents from documented domains\"\nreadme = \"README.md\"\nrequires-python = \">=3.12\"\nlicense = \"MIT\"\ndependencies = [\"httpx>=0.27\", \"beautifulsoup4>=4.12\", \"ftl-reasons\", \"pypdf>=4.0\"]\n\n[project.optional-dependencies]\nembeddings = [\"fastembed>=0.7.4\", \"numpy\"]\n\n[project.scripts]\nexpert-build = \"expert_build.cli:main\"\n\n[tool.uv.sources]\nftl-reasons = { path = \"../ftl-reasons\", editable = true }\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"expert_build\"]\n",
    "dependencies": [
      "httpx>=0.27",
      "beautifulsoup4>=4.12",
      "ftl-reasons",
      "pypdf>=4.0"
    ],
    "optional_dependencies": {
      "embeddings": [
        "fastembed>=0.7.4",
        "numpy"
      ]
    }
  }
}
```

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.
