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/ftl_code_expert/cli.py b/ftl_code_expert/cli.py
index d245062..1835243 100644
--- a/ftl_code_expert/cli.py
+++ b/ftl_code_expert/cli.py
@@ -4259,6 +4259,198 @@ def update(ctx, since, since_commit, since_last, do_file_issues):
         click.echo("All steps completed successfully.", err=True)
 
 
+# --- analyze ---
+
+
+@cli.command("analyze")
+@click.argument("repo_path", type=click.Path(exists=True, file_okay=False))
+@click.option("--domain", "-d", default=None, help="One-line domain description")
+@click.option("--limit", default=500, show_default=True,
+              help="Max files to explore (0 = unlimited)")
+@click.option("--file-issues", "do_file_issues", is_flag=True, default=False,
+              help="Also file GitHub/GitLab issues for active blockers")
+@click.pass_context
+def analyze(ctx, repo_path, domain, limit, do_file_issues):
+    """Full automated analysis of a codebase from scratch.
+
+    Runs the complete pipeline in one command:
+      1. init (bootstrap knowledge base)
+      2. scan (enumerate all source files + LLM overview)
+      3. explore (explore up to --limit files)
+      4. propose-beliefs (extract beliefs from entries)
+      5. review-proposals (LLM quality filter)
+      6. accept-beliefs (import reviewed beliefs)
+      7. derive --exhaust (compute all logical consequences)
+      8. generate-summary (summary report)
+
+    Example:
+        code-expert analyze ~/git/my-project --domain "Web framework"
+        code-expert analyze ~/git/my-project --limit 100
+        code-expert analyze ~/git/my-project --limit 0  # no cap
+    """
+    from .caffeinate import hold as _caffeinate
+    _caffeinate()
+
+    errors = []
+    started = datetime.now().isoformat(timespec="seconds")
+
+    ctx.obj["repo"] = os.path.abspath(repo_path)
+
+    # Step 1: init
+    click.echo("\n=== Step 1: Init ===\n", err=True)
+    try:
+        ctx.invoke(init, repo_path=repo_path, domain=domain)
+    except SystemExit as e:
+        if e.code and e.code != 0:
+            errors.append(f"init exited with code {e.code}")
+            click.echo(f"WARN: init failed (exit {e.code}), continuing...", err=True)
+    except Exception as e:
+        errors.append(f"init: {e}")
+        click.echo(f"WARN: init failed: {e}, continuing...", err=True)
+
+    # Step 2: scan
+    click.echo("\n=== Step 2: Scan ===\n", err=True)
+    try:
+        ctx.invoke(scan)
+    except SystemExit as e:
+        if e.code and e.code != 0:
+            errors.append(f"scan exited with code {e.code}")
+            click.echo(f"WARN: scan failed (exit {e.code}), continuing...", err=True)
+    except Exception as e:
+        errors.append(f"scan: {e}")
+        click.echo(f"WARN: scan failed: {e}, continuing...", err=True)
+
+    # Step 3: explore
+    project_dir = _get_project_dir(ctx)
+    explore_limit = limit if limit > 0 else pending_count(project_dir)
+    click.echo(f"\n=== Step 3: Explore (up to {explore_limit} topics) ===\n", err=True)
+    try:
+        ctx.invoke(explore, do_skip=False, pick_index=None, loop_max=explore_limit)
+    except SystemExit as e:
+        if e.code and e.code != 0:
+            errors.append(f"explore exited with code {e.code}")
+            click.echo(f"WARN: explore failed (exit {e.code}), continuing...", err=True)
+    except Exception as e:
+        errors.append(f"explore: {e}")
+        click.echo(f"WARN: explore failed: {e}, continuing...", err=True)
+
+    # Snapshot current node IDs before belief extraction
+    try:
+        network = _load_network()
+        pre_run_ids = set(network.get("nodes", {}).keys())
+    except Exception:
+        pre_run_ids = set()
+
+    # Step 4: propose-beliefs
+    click.echo("\n=== Step 4: Propose beliefs ===\n", err=True)
+    try:
+        ctx.invoke(propose_beliefs)
+    except SystemExit as e:
+        if e.code and e.code != 0:
+            errors.append(f"propose-beliefs exited with code {e.code}")
+            click.echo(f"WARN: propose-beliefs failed (exit {e.code}), continuing...", err=True)
+    except Exception as e:
+        errors.append(f"propose-beliefs: {e}")
+        click.echo(f"WARN: propose-beliefs failed: {e}, continuing...", err=True)
+
+    # Step 5: review-proposals
+    click.echo("\n=== Step 5: Review proposals ===\n", err=True)
+    try:
+        ctx.invoke(review_proposals)
+    except SystemExit as e:
+        if e.code and e.code != 0:
+            errors.append(f"review-proposals exited with code {e.code}")
+            click.echo(f"WARN: review-proposals failed (exit {e.code}), continuing...", err=True)
+    except Exception as e:
+        errors.append(f"review-proposals: {e}")
+        click.echo(f"WARN: review-proposals failed: {e}, continuing...", err=True)
+
+    # Step 6: accept-beliefs
+    click.echo("\n=== Step 6: Accept beliefs ===\n", err=True)
+    try:
+        ctx.invoke(accept_beliefs)
+    except SystemExit as e:
+        if e.code and e.code != 0:
+            errors.append(f"accept-beliefs exited with code {e.code}")
+            click.echo(f"WARN: accept-beliefs failed (exit {e.code}), continuing...", err=True)
+    except Exception as e:
+        errors.append(f"accept-beliefs: {e}")
+        click.echo(f"WARN: accept-beliefs failed: {e}, continuing...", err=True)
+
+    # Step 7: derive --exhaust
+    click.echo("\n=== Step 7: Derive (exhaust) ===\n", err=True)
+    try:
+        ctx.invoke(derive, exhaust=True)
+    except SystemExit as e:
+        if e.code and e.code != 0:
+            errors.append(f"derive exited with code {e.code}")
+            click.echo(f"WARN: derive failed (exit {e.code}), continuing...", err=True)
+    except Exception as e:
+        errors.append(f"derive: {e}")
+        click.echo(f"WARN: derive failed: {e}, continuing...", err=True)
+
+    # Step 8: generate-summary
+    click.echo("\n=== Step 8: Generate summary ===\n", err=True)
+    try:
+        ctx.invoke(generate_summary, snapshot_ids=tuple(pre_run_ids))
+    except SystemExit as e:
+        if e.code and e.code != 0:
+            errors.append(f"generate-summary exited with code {e.code}")
+    except Exception as e:
+        errors.append(f"generate-summary: {e}")
+        click.echo(f"WARN: generate-summary failed: {e}", err=True)
+
+    # Step 9 (opt-in): file-issues
+    if do_file_issues:
+        click.echo("\n=== Step 9: File issues ===\n", err=True)
+        try:
+            ctx.invoke(file_issues)
+        except SystemExit as e:
+            if e.code and e.code != 0:
+                errors.append(f"file-issues exited with code {e.code}")
+        except Exception as e:
+            errors.append(f"file-issues: {e}")
+            click.echo(f"WARN: file-issues failed: {e}", err=True)
+
+    # Save analyze checkpoint
+    try:
+        post_network = _load_network()
+        post_run_ids = set(post_network.get("nodes", {}).keys())
+    except Exception:
+        post_run_ids = pre_run_ids
+
+    checkpoint = {
+        "started": started,
+        "finished": datetime.now().isoformat(timespec="seconds"),
+        "explore_limit": limit,
+        "beliefs_before": len(pre_run_ids),
+        "beliefs_after": len(post_run_ids),
+        "beliefs_added": len(post_run_ids - pre_run_ids),
+        "errors": errors,
+    }
+    project_dir = _get_project_dir(ctx)
+    if project_dir:
+        os.makedirs(project_dir, exist_ok=True)
+        checkpoint_path = os.path.join(project_dir, "last-analyze.json")
+        with open(checkpoint_path, "w") as f:
+            json.dump(checkpoint, f, indent=2)
+        click.echo(f"Analyze checkpoint saved to {checkpoint_path}", err=True)
+
+    # Final report
+    click.echo("\n=== Analysis complete ===\n", err=True)
+    click.echo(f"Beliefs: {len(pre_run_ids)} → {len(post_run_ids)} "
+               f"(+{len(post_run_ids - pre_run_ids)})", err=True)
+    remaining = pending_count(project_dir)
+    if remaining:
+        click.echo(f"Topics remaining: {remaining} (use explore --loop to continue)", err=True)
+    if errors:
+        click.echo(f"Completed with {len(errors)} warning(s):", err=True)
+        for err in errors:
+            click.echo(f"  - {err}", err=True)
+    else:
+        click.echo("All steps completed successfully.", err=True)
+
+
 # --- install-skill ---
 
 

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "init_signature": {
    "function": "init",
    "file": "ftl_code_expert/cli.py",
    "start_line": 261,
    "end_line": 324,
    "source": "@cli.command()\n@click.argument(\"repo_path\", type=click.Path(exists=True, file_okay=False))\n@click.option(\"--domain\", \"-d\", default=None, help=\"One-line domain description\")\ndef init(repo_path, domain):\n    \"\"\"Bootstrap a code-expert knowledge base for a codebase.\"\"\"\n    abs_repo = os.path.abspath(repo_path)\n    repo_name = os.path.basename(abs_repo)\n\n    if not domain:\n        domain = repo_name\n\n    # Check prerequisites \u2014 reasons OR beliefs required, not both\n    for tool in [\"git\", \"entry\"]:\n        if not shutil.which(tool):\n            click.echo(f\"Error: {tool} not found on PATH\", err=True)\n            click.echo(f\"Install with: uv tool install {tool}\", err=True)\n            sys.exit(1)\n    if not shutil.which(\"reasons\") and not shutil.which(\"beliefs\"):\n        click.echo(\"Error: neither reasons nor beliefs found on PATH\", err=True)\n        click.echo(\"Install with: uv tool install ftl-reasons\", err=True)\n        sys.exit(1)\n\n    # Create project dir\n    project_dir = Path.cwd() / PROJECT_DIR\n    project_dir.mkdir(parents=True, exist_ok=True)\n\n    # Save config\n    _save_config({\n        \"repo_path\": abs_repo,\n        \"domain\": domain,\n        \"created\": date.today().isoformat(),\n    })\n\n    # Create entries dir\n    Path(\"entries\").mkdir(exist_ok=True)\n\n    # Init reasons as primary store if available, otherwise beliefs\n    if _has_reasons():\n        if not Path(\"reasons.db\").exists():\n            subprocess.run([\"reasons\", \"init\"], capture_output=True)\n            subprocess.run(\n                [\"reasons\", \"add-repo\", repo_name, abs_repo],\n                capture_output=True,\n            )\n            click.echo(\"Initialized reasons.db\")\n        # Generate beliefs.md from reasons\n        if not Path(\"beliefs.md\").exists():\n            _reasons_export()\n    elif not Path(\"beliefs.md\").exists():\n        subprocess.run([\"beliefs\", \"init\"], capture_output=True)\n        click.echo(\"Initialized beliefs.md\")\n\n    # Generate CLAUDE.md\n    template_path = Path(__file__).parent / \"data\" / \"CLAUDE.md.template\"\n    if template_path.exists():\n        template = template_path.read_text()\n        claude_md = template.replace(\"{{DOMAIN}}\", domain).replace(\"{{REPO_PATH}}\", abs_repo)\n        Path(\"CLAUDE.md\").write_text(claude_md)\n        click.echo(\"Generated CLAUDE.md\")\n\n    click.echo(f\"\\nInitialized code-expert for {repo_name}\")\n    click.echo(f\"  Repo: {abs_repo}\")\n    click.echo(f\"  Domain: {domain}\")\n    click.echo(f\"\\nNext: code-expert scan\")"
  },
  "scan_signature": {
    "function": "scan",
    "file": "ftl_code_expert/cli.py",
    "start_line": 330,
    "end_line": 384,
    "source": "@cli.command()\n@click.pass_context\ndef scan(ctx):\n    \"\"\"Scan a repo to identify key files and populate the exploration queue.\"\"\"\n    from .caffeinate import hold as _caffeinate\n    _caffeinate()\n    repo_path = _get_repo(ctx)\n    model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n\n    if not check_model_available(model):\n        click.echo(f\"Error: Model '{model}' CLI not available\", err=True)\n        sys.exit(1)\n\n    click.echo(f\"Scanning {repo_path}...\", err=True)\n\n    tree = get_repo_structure(repo_path, max_depth=3)\n    _, config_content = _find_project_config(repo_path)\n    readme_content = get_file_content(os.path.join(repo_path, \"README.md\"))\n    entry_points = _find_entry_points(repo_path, config_content)\n\n    prompt = build_scan_prompt(\n        tree=tree,\n        config_content=config_content,\n        readme_content=readme_content,\n        entry_points=entry_points or None,\n    )\n\n    click.echo(f\"Running {model}...\", err=True)\n    try:\n        result = asyncio.run(invoke(prompt, model, timeout=timeout))\n    except Exception as e:\n        click.echo(f\"Error: {e}\", err=True)\n        sys.exit(1)\n\n    # Create entry\n    repo_name = os.path.basename(repo_path)\n    _create_entry(f\"scan-{repo_name}\", f\"Scan: {repo_name}\", result)\n\n    project_dir = _get_project_dir(ctx)\n\n    # Enumerate all source files as file topics (BFS: breadth before depth)\n    source_files = list_source_files(repo_path)\n    if source_files:\n        file_topics = [\n            Topic(title=f, kind=\"file\", target=f, source=f\"scan:{repo_name}\")\n            for f in source_files\n        ]\n        added = add_topics(file_topics, project_dir)\n        click.echo(f\"Queued {added} file(s) for exploration\", err=True)\n\n    # Enqueue LLM-suggested topics (functions, generals appended after files)\n    _enqueue_topics(result, source=f\"scan:{repo_name}\", project_dir=project_dir)\n\n    _emit(ctx, result)"
  },
  "explore_signature": {
    "function": "explore",
    "file": "ftl_code_expert/cli.py",
    "start_line": 711,
    "end_line": 828,
    "source": "@cli.command()\n@click.option(\"--skip\", \"do_skip\", is_flag=True, default=False,\n              help=\"Skip the next topic\")\n@click.option(\"--pick\", \"pick_index\", type=str, default=None,\n              help=\"Pick topic(s) by index \u2014 single (3) or comma-separated (1,3,8)\")\n@click.option(\"--loop\", \"loop_max\", type=int, default=None,\n              help=\"Continuously explore up to N topics [default: 10]\")\n@click.pass_context\ndef explore(ctx, do_skip, pick_index, loop_max):\n    \"\"\"Explore the next topic in the queue (or --skip / --pick N[,N,...]).\"\"\"\n    from .caffeinate import hold as _caffeinate\n    _caffeinate()\n    project_dir = _get_project_dir(ctx)\n\n    if loop_max is not None:\n        if do_skip or pick_index:\n            click.echo(\"Error: --loop cannot be combined with --skip or --pick\", err=True)\n            sys.exit(1)\n        _explore_loop(ctx, project_dir, loop_max)\n        return\n\n    if do_skip:\n        if skip_topic(0, project_dir):\n            queue = load_queue(project_dir)\n            pending = [t for t in queue if t.status == \"pending\"]\n            if pending:\n                click.echo(f\"Skipped. Next: [{pending[0].kind}] {pending[0].target}\")\n            else:\n                click.echo(\"Skipped. No more pending topics.\")\n        else:\n            click.echo(\"Nothing to skip.\")\n        return\n\n    if pick_index is not None:\n        # Parse comma-separated indices\n        try:\n            indices = [int(x.strip()) for x in pick_index.split(\",\")]\n        except ValueError:\n            click.echo(f\"Error: --pick must be integers, got: {pick_index}\", err=True)\n            sys.exit(1)\n\n        if len(indices) > 1:\n            topics = pop_multiple(indices, project_dir)\n        else:\n            topics = [pop_at(indices[0], project_dir)]\n    else:\n        topics = [pop_next(project_dir)]\n\n    # Filter out None (invalid indices)\n    valid_topics = [(i, t) for i, t in zip(\n        indices if pick_index is not None else [0],\n        topics,\n    ) if t is not None]\n\n    if not valid_topics:\n        click.echo(\"No pending topics. Run `code-expert scan` to discover topics.\")\n        return\n\n    invalid_count = len(topics) - len(valid_topics)\n    if invalid_count:\n        click.echo(f\"Warning: {invalid_count} index(es) out of bounds, skipped.\", err=True)\n\n    repo_path = _get_repo(ctx)\n    abs_repo = os.path.abspath(repo_path)\n    model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n    parallel = ctx.obj[\"parallel\"]\n\n    if not check_model_available(model):\n        click.echo(f\"Error: Model '{model}' CLI not available\", err=True)\n        sys.exit(1)\n\n    topics_only = [topic for _, topic in valid_topics]\n    for seq, (_, topic) in enumerate(valid_topics):\n        click.echo(f\"  [{seq + 1}/{len(valid_topics)}] [{topic.kind}] {topic.target}\", err=True)\n\n    if parallel > 1 and len(topics_only) > 1:\n        click.echo(f\"\\nExploring {len(topics_only)} topics (parallel={parallel})...\", err=True)\n        results = asyncio.run(\n            _explore_topics_concurrent(topics_only, model, abs_repo, timeout, parallel)\n        )\n        for r in results:\n            if isinstance(r, Exception):\n                click.echo(f\"  Error: {r}\", err=True)\n            elif r is not None:\n                _, result, entry_name, entry_title, source = r\n                _finalize_topic(ctx, entry_name, entry_title, source, result)\n    else:\n        for seq, (idx, topic) in enumerate(valid_topics):\n            if len(valid_topics) > 1:\n                click.echo(f\"\\n{'=' * 40}\", err=True)\n                click.echo(f\"[{seq + 1}/{len(valid_topics)}] Topic #{idx}\", err=True)\n                click.echo(f\"{'=' * 40}\", err=True)\n\n            click.echo(f\"Topic: [{topic.kind}] {topic.target}\", err=True)\n            click.echo(f\"  {topic.title}\", err=True)\n            if topic.source:\n                click.echo(f\"  (from {topic.source})\", err=True)\n            click.echo(err=True)\n\n            if topic.kind == \"file\":\n                _run_file_topic(ctx, topic, model, abs_repo)\n            elif topic.kind == \"function\":\n                _run_function_topic(ctx, topic, model, abs_repo)\n            elif topic.kind == \"repo\":\n                _run_repo_topic(ctx, topic, model, abs_repo)\n            elif topic.kind == \"diff\":\n                _run_diff_topic(ctx, topic, model, abs_repo)\n            elif topic.kind == \"general\":\n                _run_general_topic(ctx, topic, model, abs_repo)\n            else:\n                click.echo(f\"Unknown topic kind: {topic.kind}\", err=True)\n\n    remaining = pending_count(project_dir)\n    if remaining:\n        click.echo(f\"\\n{remaining} topic(s) remaining. Run `code-expert explore` to continue.\", err=True)\n    else:\n        click.echo(\"\\nNo more topics. Exploration complete.\", err=True)"
  },
  "propose_beliefs_signature": {
    "function": "propose_beliefs",
    "file": "ftl_code_expert/cli.py",
    "start_line": 1404,
    "end_line": 1601,
    "source": "@cli.command(\"propose-beliefs\")\n@click.option(\"--batch-size\", type=int, default=5, help=\"Entries per LLM batch (default: 5)\")\n@click.option(\"--output\", default=\"proposed-beliefs.md\", help=\"Output file\")\n@click.option(\"--model\", \"-m\", default=None, help=\"Override model\")\n@click.option(\"--entry\", \"entry_paths\", multiple=True, type=click.Path(exists=True),\n              help=\"Process specific entry file(s) instead of all entries\")\n@click.option(\"--all\", \"process_all\", is_flag=True,\n              help=\"Re-process all entries (ignore processed tracking)\")\n@click.option(\"--auto\", \"auto_accept\", is_flag=True, default=False,\n              help=\"Automatically accept all proposed beliefs (no review step)\")\n@click.option(\"--since\", default=None,\n              help=\"Only process entries from this date onward (YYYY-MM-DD)\")\n@click.pass_context\ndef propose_beliefs(ctx, batch_size, output, model, entry_paths, process_all, auto_accept, since):\n    \"\"\"Extract candidate beliefs from entries for human review.\"\"\"\n    from .caffeinate import hold as _caffeinate\n    _caffeinate()\n    if model is None:\n        model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n\n    if not check_model_available(model):\n        click.echo(f\"Error: Model '{model}' CLI not available\", err=True)\n        sys.exit(1)\n\n    # Collect entries\n    if entry_paths:\n        entries = [Path(p) for p in entry_paths]\n    else:\n        input_dir = Path(\"entries\")\n        if not input_dir.exists():\n            click.echo(\"No entries/ directory found. Run explorations first.\")\n            sys.exit(1)\n        entries = sorted(input_dir.rglob(\"*.md\"))\n\n    if not entries:\n        click.echo(\"No .md files found.\")\n        return\n\n    # Filter by date if --since provided\n    if since:\n        before = len(entries)\n        entries = [e for e in entries if (_entry_date(e) or \"\") >= since]\n        click.echo(f\"Filtered to {len(entries)} entries since {since} (from {before} total)\", err=True)\n        if not entries:\n            click.echo(\"No entries found since that date.\")\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    if not process_all and not entry_paths:\n        total = len(entries)\n        entries = _filter_unprocessed(entries, processed)\n        skipped = total - len(entries)\n        if skipped:\n            click.echo(f\"Skipping {skipped} already-processed entries (use --all to reprocess)\")\n        if not entries:\n            click.echo(\"No new entries to process.\")\n            return\n\n    # Load existing beliefs (IDs + text + source) for dedup context\n    existing_beliefs = _load_existing_beliefs(Path(\"beliefs.md\"))\n    existing_ids = {b[\"id\"] for b in existing_beliefs}\n\n    if existing_ids:\n        click.echo(f\"Found {len(existing_ids)} existing beliefs (will skip duplicates)\")\n\n    # Compute belief embeddings once for all batches (if fastembed available)\n    belief_vectors = None\n    if existing_beliefs and _has_embeddings():\n        click.echo(\"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        click.echo(f\"  {len(belief_vectors)} belief vectors ready\")\n    elif existing_beliefs:\n        click.echo(\"(install fastembed for semantic dedup: uv pip install 'ftl-code-expert[embeddings]')\")\n\n    click.echo(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        current_batch.append(f\"--- FILE: {entry_path} ---\\n{content}\")\n        current_paths.append(str(entry_path))\n        if len(current_batch) >= 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    parallel = ctx.obj[\"parallel\"]\n    click.echo(f\"Processing {len(batches)} batches (batch size: {batch_size}, parallel: {parallel})...\")\n\n    prompts = []\n    for i, batch_text in enumerate(batches):\n        existing_context = _build_dedup_context(\n            existing_beliefs, batch_paths[i], batch_text,\n            belief_vectors=belief_vectors,\n        )\n        prompts.append(PROPOSE_BELIEFS_CODE.format(entries=batch_text) + existing_context)\n\n    results = invoke_concurrent_sync(prompts, model=model, timeout=timeout, max_concurrent=parallel)\n\n    all_proposals = []\n    for i, result in enumerate(results):\n        if isinstance(result, Exception):\n            click.echo(f\"  ERROR in batch {i + 1}: {result}\")\n        else:\n            click.echo(f\"  Batch {i + 1}/{len(batches)} done\")\n            all_proposals.append(result)\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                # Skip lines until the next ### header\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        click.echo(f\"  Filtered {skipped} already-accepted beliefs\")\n\n    # Record processed entries\n    _save_processed(processed_path, entries, processed)\n\n    if auto_accept:\n        # Parse proposals and accept all directly\n        accept_pattern = re.compile(\n            r\"^### \\[?(?:ACCEPT(?:/REJECT)?|REJECT)\\]? (\\S+)\\n(.+?)\\n- Source: (.+?)(?:\\n|$)\",\n            re.MULTILINE,\n        )\n        matches = []\n        for proposal in filtered_proposals:\n            matches.extend(accept_pattern.findall(proposal))\n        if not matches:\n            click.echo(\"No beliefs extracted from proposals.\")\n            return\n        click.echo(f\"\\nAuto-accepting {len(matches)} beliefs...\")\n        _accept_proposals(matches)\n        return\n\n    # Write proposals file (append if it already exists)\n    source_desc = \", \".join(str(e) for e in entries) if entry_paths else f\"{len(entries)} entries from entries/\"\n    output_path = Path(output)\n    if output_path.exists() and output_path.stat().st_size > 0:\n        with output_path.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:** {model}\\n\\n\")\n            for proposal in filtered_proposals:\n                f.write(proposal)\n                f.write(\"\\n\\n\")\n        click.echo(f\"\\nAppended to {output_path}\")\n    else:\n        with output_path.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: `code-expert 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:** {model}\\n\\n\")\n            for proposal in filtered_proposals:\n                f.write(proposal)\n                f.write(\"\\n\\n\")\n        click.echo(f\"\\nWrote {output_path}\")\n\n    click.echo(\"Review the file, mark entries as [ACCEPT] or [REJECT], then run:\")\n    click.echo(\"  code-expert accept-beliefs\")"
  },
  "review_proposals_signature": {
    "function": "review_proposals",
    "file": "ftl_code_expert/cli.py",
    "start_line": 1800,
    "end_line": 1943,
    "source": "@cli.command(\"review-proposals\")\n@click.option(\"--file\", \"proposals_file\", default=\"proposed-beliefs.md\",\n              help=\"Proposals file to review\")\n@click.option(\"--batch-size\", type=int, default=20,\n              help=\"Proposals per LLM batch (default: 20)\")\n@click.pass_context\ndef review_proposals(ctx, proposals_file, batch_size):\n    \"\"\"Filter low-quality belief proposals using LLM review.\n\n    Sends proposals in batches to an LLM along with existing beliefs context.\n    The LLM judges each proposal against rejection criteria:\n    meta, duplicate, ephemeral, speculative, trivial.\n\n    Pipeline position: propose-beliefs \u2192 review-proposals \u2192 accept-beliefs\n    \"\"\"\n    model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n\n    proposals_path = Path(proposals_file)\n    if not proposals_path.exists():\n        click.echo(f\"Proposals file not found: {proposals_file}\")\n        sys.exit(1)\n\n    if not check_model_available(model):\n        click.echo(f\"Error: Model '{model}' CLI not available\", err=True)\n        sys.exit(1)\n\n    text = proposals_path.read_text()\n\n    try:\n        network = _load_network()\n        existing_nodes = network.get(\"nodes\", {})\n    except Exception:\n        existing_nodes = {}\n\n    proposal_pattern = re.compile(\n        r\"(### \\[?(?:ACCEPT(?:/REJECT)?|REJECT)\\]?)\\s*(\\S+)\\n\"\n        r\"(.+?)\\n\"\n        r\"(- Source: .+?)(?=\\n###|\\n---|\\Z)\",\n        re.DOTALL,\n    )\n\n    matches = list(proposal_pattern.finditer(text))\n    if not matches:\n        click.echo(\"No proposals found in file.\")\n        return\n\n    to_review = []\n    already_rejected = 0\n    for match in matches:\n        header = match.group(1)\n        if \"[REJECT]\" in header and \"[ACCEPT\" not in header:\n            already_rejected += 1\n            continue\n        to_review.append({\n            \"match\": match,\n            \"header\": header,\n            \"id\": match.group(2),\n            \"text\": match.group(3).strip(),\n            \"source\": match.group(4).strip().removeprefix(\"- Source: \"),\n        })\n\n    if not to_review:\n        click.echo(\"No proposals to review (all already rejected).\", err=True)\n        return\n\n    parallel = ctx.obj[\"parallel\"]\n    click.echo(f\"Reviewing {len(to_review)} proposals ({already_rejected} already rejected, parallel: {parallel})...\", err=True)\n\n    existing_beliefs = _build_existing_beliefs_section(existing_nodes)\n\n    all_decisions: dict[str, tuple[bool, str | None]] = {}\n    batches = [to_review[i:i + batch_size] for i in range(0, len(to_review), batch_size)]\n\n    prompts = []\n    for batch in batches:\n        proposals_section = _build_proposals_section(batch)\n        prompts.append(REVIEW_PROMPT.format(\n            existing_beliefs=existing_beliefs,\n            proposals=proposals_section,\n        ))\n\n    results = invoke_concurrent_sync(prompts, model=model, timeout=timeout, max_concurrent=parallel)\n\n    for i, result in enumerate(results):\n        if isinstance(result, Exception):\n            click.echo(f\"  ERROR in batch {i + 1}: {result}\", err=True)\n        else:\n            click.echo(f\"  Batch {i + 1}/{len(batches)} done\", err=True)\n            decisions = _parse_review_response(result)\n            all_decisions.update(decisions)\n\n    kept = 0\n    rejected = 0\n    categories: dict[str, int] = {}\n    replacements: list[tuple[str, str]] = []\n\n    for proposal in to_review:\n        belief_id = proposal[\"id\"]\n        match = proposal[\"match\"]\n        header = proposal[\"header\"]\n\n        decision = all_decisions.get(belief_id)\n        if decision is None:\n            kept += 1\n            continue\n\n        reject, reason = decision\n        if reject:\n            rejected += 1\n            category = reason.split(\":\")[0].strip() if reason else \"unknown\"\n            categories[category] = categories.get(category, 0) + 1\n\n            old_block = match.group(0)\n            new_header = f\"### [REJECT] {belief_id}\"\n            new_block = old_block.replace(\n                f\"{header} {belief_id}\",\n                new_header,\n                1,\n            )\n            source_line = match.group(4).strip()\n            new_block = new_block.replace(\n                source_line,\n                f\"{source_line}\\n- Rejected: {reason}\",\n                1,\n            )\n            replacements.append((old_block, new_block))\n            click.echo(f\"  REJECT {belief_id}: {reason}\", err=True)\n        else:\n            kept += 1\n\n    click.echo(f\"\\nReviewed {len(to_review)} proposals: {kept} kept, {rejected} rejected\", err=True)\n    if categories:\n        click.echo(\"Rejections by category:\", err=True)\n        for cat, count in sorted(categories.items(), key=lambda x: -x[1]):\n            click.echo(f\"  {cat}: {count}\", err=True)\n\n    if replacements:\n        for old_block, new_block in replacements:\n            text = text.replace(old_block, new_block, 1)\n        proposals_path.write_text(text)\n        click.echo(f\"Updated {proposals_file}\", err=True)\n    else:\n        click.echo(\"No changes needed.\", err=True)"
  },
  "accept_beliefs_signature": {
    "function": "accept_beliefs",
    "file": "ftl_code_expert/cli.py",
    "start_line": 1725,
    "end_line": 1751,
    "source": "@cli.command(\"accept-beliefs\")\n@click.option(\"--file\", \"proposals_file\", default=\"proposed-beliefs.md\",\n              help=\"Proposals file (default: proposed-beliefs.md)\")\ndef accept_beliefs(proposals_file):\n    \"\"\"Import accepted beliefs from proposals file.\"\"\"\n    proposals_path = Path(proposals_file)\n    if not proposals_path.exists():\n        click.echo(f\"Proposals file not found: {proposals_file}\")\n        click.echo(\"Run: code-expert propose-beliefs\")\n        sys.exit(1)\n\n    text = proposals_path.read_text()\n\n    pattern = re.compile(\n        r\"### \\[?ACCEPT\\]? (\\S+)\\n\"\n        r\"(.+?)\\n\"\n        r\"- Source: (.+?)(?:\\n|$)\"\n    )\n    matches = pattern.findall(text)\n\n    if not matches:\n        click.echo(\"No [ACCEPT] entries found in proposals file.\")\n        click.echo(\"Edit the file and change [ACCEPT/REJECT] to [ACCEPT] for beliefs to keep.\")\n        return\n\n    click.echo(f\"Found {len(matches)} accepted beliefs\")\n    _accept_proposals(matches)"
  },
  "derive_signature": {
    "function": "derive",
    "file": "ftl_code_expert/cli.py",
    "start_line": 2743,
    "end_line": 2806,
    "source": "@cli.command(\"derive\")\n@click.option(\"--output\", \"-o\", default=\"proposed-derivations.md\",\n              help=\"Output file (default: proposed-derivations.md)\")\n@click.option(\"--model\", \"-m\", default=None, help=\"Override model\")\n@click.option(\"--auto\", \"auto_add\", is_flag=True, default=False,\n              help=\"Automatically add proposals to reasons (no review step)\")\n@click.option(\"--exhaust\", \"exhaust\", is_flag=True, default=False,\n              help=\"Loop until no new derivations (implies --auto)\")\n@click.option(\"--dry-run\", is_flag=True, default=False,\n              help=\"Show what would be sent to the LLM without invoking it\")\n@click.option(\"--budget\", type=int, default=300,\n              help=\"Maximum number of beliefs in prompt (default: 300)\")\n@click.option(\"--sample/--no-sample\", default=True,\n              help=\"Randomly sample beliefs instead of alphabetical truncation (default: on)\")\n@click.option(\"--topic\", default=None,\n              help=\"Keyword filter \u2014 only include beliefs matching these keywords\")\n@click.option(\"--max-rounds\", type=int, default=10,\n              help=\"Maximum rounds for --exhaust (default: 10)\")\n@click.pass_context\ndef derive(ctx, output, model, auto_add, exhaust, dry_run, budget, sample, topic, max_rounds):\n    \"\"\"Derive deeper reasoning chains from existing beliefs.\n\n    Delegates to `reasons derive` which handles prompt building, LLM\n    invocation, proposal validation (including Jaccard dedup against\n    retracted beliefs), and network updates.\n\n    Example:\n        code-expert derive              # propose derivations\n        code-expert derive --auto       # propose and add automatically\n        code-expert derive --exhaust    # loop until no new derivations\n    \"\"\"\n    from .caffeinate import hold as _caffeinate\n    _caffeinate()\n\n    if not _has_reasons():\n        click.echo(\"Error: reasons CLI required. Install with: uv tool install ftl-reasons\", err=True)\n        sys.exit(1)\n\n    if model is None:\n        model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"] if ctx.obj[\"timeout\"] != 300 else 600\n\n    cmd = [\"reasons\", \"derive\", \"-m\", model, \"--timeout\", str(timeout),\n           \"--budget\", str(budget), \"-o\", output]\n    if sample:\n        cmd.append(\"--sample\")\n    if auto_add or exhaust:\n        cmd.append(\"--auto\")\n    if exhaust:\n        cmd.extend([\"--exhaust\", \"--max-rounds\", str(max_rounds)])\n    if dry_run:\n        cmd.append(\"--dry-run\")\n    if topic:\n        cmd.extend([\"--topic\", topic])\n\n    click.echo(f\"Running: {' '.join(cmd)}\", err=True)\n    result = subprocess.run(cmd)\n\n    if result.returncode != 0:\n        sys.exit(result.returncode)\n\n    # Re-export after derive modifies the database\n    if (auto_add or exhaust) and not dry_run:\n        _reasons_export()"
  },
  "generate_summary_signature": {
    "function": "generate_summary",
    "file": "ftl_code_expert/cli.py",
    "start_line": 3567,
    "end_line": 3637,
    "source": "@cli.command(\"generate-summary\")\n@click.option(\"--snapshot-ids\", multiple=True, hidden=True,\n              help=\"Pre-run node IDs (passed by update command)\")\n@click.pass_context\ndef generate_summary(ctx, snapshot_ids):\n    \"\"\"Generate a morning summary entry of belief state.\n\n    Highlights new gated OUT beliefs, new negative IN beliefs,\n    and critical issues regardless of age.\n    \"\"\"\n    network = _load_network()\n    nodes = network.get(\"nodes\", {})\n    if not nodes:\n        click.echo(\"No beliefs found. Run explorations first.\", err=True)\n        sys.exit(1)\n\n    pre_run_ids = set(snapshot_ids) if snapshot_ids else set()\n\n    # All gated OUT beliefs\n    all_gated = _find_gated_out_beliefs(nodes)\n\n    # All negative IN beliefs\n    all_negative = _find_negative_in_beliefs(nodes)\n\n    # Split into new vs existing\n    if pre_run_ids:\n        new_gated = [b for b in all_gated if b[\"id\"] not in pre_run_ids]\n        new_negative = [b for b in all_negative if b[\"id\"] not in pre_run_ids]\n    else:\n        new_gated = all_gated\n        new_negative = all_negative\n\n    # Critical watch list \u2014 only problems, not positive assertions about safety\n    critical_gated = [b for b in all_gated if _CRITICAL_KEYWORDS.search(b[\"text\"])\n                      or any(_CRITICAL_KEYWORDS.search(bl[\"text\"]) for bl in b[\"blockers\"])]\n    critical_negative = [b for b in all_negative if _CRITICAL_KEYWORDS.search(b[\"text\"])]\n\n    # Statistics\n    total_in = sum(1 for n in nodes.values() if n.get(\"truth_value\") == \"IN\")\n    total_out = sum(1 for n in nodes.values() if n.get(\"truth_value\") == \"OUT\")\n    total_derived = sum(1 for n in nodes.values()\n                        if n.get(\"justifications\") and len(n[\"justifications\"]) > 0)\n\n    # Build summary\n    content = f\"## New Gated OUT Beliefs\\n\\n{_format_gated_section(new_gated)}\"\n    content += f\"\\n## New Negative IN Beliefs\\n\\n{_format_belief_list(new_negative)}\"\n    content += f\"\\n## Critical Watch List\\n\\n\"\n\n    if critical_gated or critical_negative:\n        if critical_gated:\n            content += f\"### Gated (blocked)\\n\\n{_format_gated_section(critical_gated)}\\n\"\n        if critical_negative:\n            content += f\"### Active Issues\\n\\n{_format_belief_list(critical_negative)}\\n\"\n    else:\n        content += \"_No critical issues detected._\\n\"\n\n    content += f\"\\n## Statistics\\n\\n\"\n    content += f\"- **Total beliefs:** {len(nodes)}\\n\"\n    content += f\"- **IN:** {total_in}\\n\"\n    content += f\"- **OUT:** {total_out}\\n\"\n    content += f\"- **Derived:** {total_derived}\\n\"\n    content += f\"- **Gated OUT (all):** {len(all_gated)}\\n\"\n    content += f\"- **Negative IN (all):** {len(all_negative)}\\n\"\n    if pre_run_ids:\n        content += f\"- **New beliefs this run:** {len(nodes) - len(pre_run_ids)}\\n\"\n        content += f\"- **New gated OUT:** {len(new_gated)}\\n\"\n        content += f\"- **New negative IN:** {len(new_negative)}\\n\"\n\n    _create_entry(\"update\", \"Update Summary\", content)\n    click.echo(f\"\\nSummary: {len(new_gated)} new gated OUT, {len(new_negative)} new negative IN, \"\n               f\"{len(critical_gated) + len(critical_negative)} critical\", err=True)"
  },
  "file_issues_signature": {
    "function": "file_issues",
    "file": "ftl_code_expert/cli.py",
    "start_line": 3274,
    "end_line": 3463,
    "source": "@cli.command(\"file-issues\")\n@click.option(\"--repo\", \"-r\", \"repo_slug\", default=None,\n              help=\"Target repo (owner/repo). Auto-detected from git remote if omitted.\")\n@click.option(\"--platform\", \"-p\", \"platform_override\", default=None,\n              type=click.Choice([\"github\", \"gitlab\"]),\n              help=\"Force platform (auto-detected if omitted)\")\n@click.option(\"--label\", \"-l\", \"labels\", multiple=True,\n              help=\"Extra labels to add (repeatable)\")\n@click.option(\"--dry-run\", is_flag=True, default=False,\n              help=\"Show what would be filed without creating issues\")\n@click.option(\"--skip-confirm\", is_flag=True, default=False,\n              help=\"Skip LLM confirmation that issues still exist in code\")\n@click.option(\"--no-negative\", is_flag=True, default=False,\n              help=\"Skip negative IN beliefs (only file gated blockers)\")\n@click.pass_context\ndef file_issues(ctx, repo_slug, platform_override, labels, dry_run, skip_confirm, no_negative):\n    \"\"\"File issues from gated blockers and negative beliefs.\n\n    Finds GATE beliefs where outlist nodes are IN (blocking the conclusion),\n    plus negative IN beliefs (bugs, gaps, risks). Before filing, confirms\n    each issue still exists in the current code using LLM verification.\n\n    Checks for existing issues to avoid duplicates.\n\n    Example:\n        code-expert file-issues              # auto-detect repo, file issues\n        code-expert file-issues --dry-run    # preview without filing\n        code-expert file-issues --skip-confirm  # skip code confirmation\n        code-expert file-issues --no-negative   # only gated blockers\n    \"\"\"\n    if not _has_reasons():\n        click.echo(\"Error: reasons CLI required. Install with: uv tool install ftl-reasons\", err=True)\n        sys.exit(1)\n\n    model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n\n    # Load network\n    network = _load_network()\n    nodes = network.get(\"nodes\", {})\n    if not nodes:\n        click.echo(\"No beliefs found. Run explorations first.\", err=True)\n        sys.exit(1)\n\n    # Build unified candidate list: [{id, text, type, gated?}]\n    candidates: list[dict] = []\n\n    # 1. Find gated blockers\n    blockers: dict[str, list[dict]] = {}\n    for nid, node in nodes.items():\n        if node.get(\"truth_value\") != \"OUT\":\n            continue\n        if node.get(\"metadata\", {}).get(\"superseded_by\"):\n            continue\n        for j in node.get(\"justifications\", []):\n            if not j.get(\"outlist\"):\n                continue\n            for outlist_id in j[\"outlist\"]:\n                if outlist_id not in nodes:\n                    continue\n                if nodes[outlist_id].get(\"truth_value\") != \"IN\":\n                    continue\n                blockers.setdefault(outlist_id, []).append({\n                    \"id\": nid,\n                    \"text\": node.get(\"text\", \"\"),\n                })\n\n    for bid, gated in blockers.items():\n        candidates.append({\n            \"id\": bid,\n            \"text\": nodes[bid].get(\"text\", \"\"),\n            \"type\": \"gate\",\n            \"gated\": gated,\n        })\n\n    # 2. Find negative IN beliefs\n    if not no_negative:\n        negative = _get_negative_beliefs(nodes, model=model)\n        gate_ids = set(blockers.keys())\n        for belief in negative:\n            if belief[\"id\"] not in gate_ids:\n                candidates.append({\n                    \"id\": belief[\"id\"],\n                    \"text\": belief[\"text\"],\n                    \"type\": \"negative\",\n                })\n\n    if not candidates:\n        click.echo(\"No active blockers or negative beliefs found.\")\n        return\n\n    gate_count = sum(1 for c in candidates if c[\"type\"] == \"gate\")\n    neg_count = sum(1 for c in candidates if c[\"type\"] == \"negative\")\n    click.echo(\n        f\"Found {gate_count} gated blocker(s) and {neg_count} negative belief(s)\",\n        err=True,\n    )\n\n    # Detect platform\n    config = _load_config()\n    target_repo_path = config.get(\"repo_path\", os.getcwd()) if config else os.getcwd()\n    project_dir = config.get(\"project_dir\") if config else None\n\n    platform = platform_override\n    if not repo_slug or not platform:\n        detected_platform, detected_slug = _detect_platform(target_repo_path)\n        if not platform:\n            platform = detected_platform\n        if not repo_slug:\n            repo_slug = detected_slug\n\n    if not platform or not repo_slug:\n        click.echo(\"Error: Could not detect platform/repo. Use --repo and --platform flags.\", err=True)\n        sys.exit(1)\n\n    cli_tool = \"gh\" if platform == \"github\" else \"glab\"\n    if not shutil.which(cli_tool):\n        click.echo(f\"Error: {cli_tool} CLI not found. Install it first.\", err=True)\n        sys.exit(1)\n\n    click.echo(f\"Platform: {platform}, Repo: {repo_slug}\", err=True)\n\n    # Dedup against existing issues\n    all_ids = [c[\"id\"] for c in candidates]\n    all_texts = {c[\"id\"]: c[\"text\"] for c in candidates}\n    if not dry_run:\n        click.echo(\"Checking for existing issues...\", err=True)\n        existing = _find_existing_issues(platform, repo_slug, all_ids, all_texts)\n        if existing:\n            click.echo(f\"  {len(existing)} already have issues: {', '.join(sorted(existing))}\", err=True)\n    else:\n        existing = set()\n\n    remaining = [c for c in candidates if c[\"id\"] not in existing]\n\n    # Confirm issues still exist in code (skip during dry-run to avoid LLM costs)\n    if not dry_run and not skip_confirm and remaining and check_model_available(model):\n        click.echo(f\"Confirming {len(remaining)} candidate(s) against current code...\", err=True)\n        confirmed = _confirm_beliefs(\n            remaining, nodes, target_repo_path,\n            model=model, timeout=timeout, project_dir=project_dir,\n        )\n        unconfirmed = len(remaining) - len(confirmed)\n        if unconfirmed:\n            click.echo(f\"  {unconfirmed} belief(s) no longer present in code\", err=True)\n        remaining = confirmed\n\n    # File issues\n    filed = []\n    skipped_ids = list(existing)\n\n    for candidate in sorted(remaining, key=lambda c: c[\"id\"]):\n        ctype = candidate[\"type\"]\n        issue_labels = [f\"reasons-{ctype}\"] + list(labels)\n        title = f\"[{candidate['id']}] {candidate['text'][:80]}\"\n\n        if ctype == \"gate\":\n            body = _build_issue_body(\n                {\"id\": candidate[\"id\"], \"text\": candidate[\"text\"]},\n                candidate[\"gated\"],\n            )\n        else:\n            body = _build_negative_issue_body(candidate)\n\n        if dry_run:\n            click.echo(f\"\\n  WOULD FILE ({ctype}): {title}\")\n            if ctype == \"gate\":\n                click.echo(f\"  Blocks: {', '.join(g['id'] for g in candidate['gated'])}\")\n            click.echo(f\"  Labels: {', '.join(issue_labels)}\")\n            continue\n\n        click.echo(f\"  Filing: {candidate['id']}...\", err=True)\n        url = _create_issue(platform, repo_slug, title, body, issue_labels)\n        if url:\n            filed.append((candidate[\"id\"], url))\n            click.echo(f\"  OK {candidate['id']}: {url}\")\n        else:\n            click.echo(f\"  FAIL {candidate['id']}\")\n\n    # Summary\n    if dry_run:\n        click.echo(\n            f\"\\nDry run: {len(remaining)} would be filed, \"\n            f\"{len(existing)} already exist, \"\n            f\"{len(candidates) - len(remaining) - len(existing)} filtered\"\n        )\n    else:\n        click.echo(f\"\\nFiled {len(filed)} issue(s), skipped {len(skipped_ids)}\")\n        for bid, url in filed:\n            click.echo(f\"  {bid}: {url}\")"
  },
  "load_network_body": {
    "function": "_load_network",
    "file": "ftl_code_expert/cli.py",
    "start_line": 2727,
    "end_line": 2740,
    "source": "def _load_network() -> dict:\n    \"\"\"Load network.json (exported from reasons).\"\"\"\n    network_path = Path(\"network.json\")\n    if not network_path.exists():\n        # Try exporting from reasons\n        if _has_reasons():\n            import subprocess\n            result = subprocess.run(\n                [\"reasons\", \"export\"], capture_output=True, text=True,\n            )\n            if result.returncode == 0:\n                return json.loads(result.stdout)\n        return {\"nodes\": {}}\n    return json.loads(network_path.read_text())"
  },
  "pending_count_body": {
    "error": "No function found at 'pending_count'",
    "file": "ftl_code_expert/cli.py"
  },
  "get_project_dir_body": {
    "function": "_get_project_dir",
    "file": "ftl_code_expert/cli.py",
    "start_line": 86,
    "end_line": 88,
    "source": "def _get_project_dir(ctx) -> str:\n    \"\"\"Resolve .code-expert directory relative to repo root.\"\"\"\n    return os.path.join(_get_repo(ctx), PROJECT_DIR)"
  },
  "caffeinate_module": {
    "function": "hold",
    "file": "ftl_code_expert/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"
  },
  "analyze_tests": {
    "source_file": "ftl_code_expert/cli.py",
    "test_files": [],
    "test_count": 0
  },
  "load_network_raises": {
    "function": "_load_network",
    "file": "ftl_code_expert/cli.py",
    "explicit_raises": [],
    "calls": [
      "loads",
      "exists",
      "Path",
      "_has_reasons",
      "run",
      "read_text"
    ]
  },
  "ctx_obj_repo_usages": {
    "symbol": "ctx.obj[\"repo\"]",
    "usages": [
      {
        "file": "ftl_code_expert/cli.py",
        "line": 252,
        "text": "ctx.obj[\"repo\"] = os.path.abspath(repo)"
      },
      {
        "file": "ftl_code_expert/cli.py",
        "line": 255,
        "text": "ctx.obj[\"repo\"] = config.get(\"repo_path\", os.getcwd()) if config else os.getcwd()"
      },
      {
        "file": "ftl_code_expert/cli.py",
        "line": 4297,
        "text": "ctx.obj[\"repo\"] = os.path.abspath(repo_path)"
      }
    ],
    "production_usages": [
      {
        "file": "ftl_code_expert/cli.py",
        "line": 252,
        "text": "ctx.obj[\"repo\"] = os.path.abspath(repo)"
      },
      {
        "file": "ftl_code_expert/cli.py",
        "line": 255,
        "text": "ctx.obj[\"repo\"] = config.get(\"repo_path\", os.getcwd()) if config else os.getcwd()"
      },
      {
        "file": "ftl_code_expert/cli.py",
        "line": 4297,
        "text": "ctx.obj[\"repo\"] = os.path.abspath(repo_path)"
      }
    ],
    "test_usages": [],
    "production_count": 3,
    "test_count": 0,
    "total_count": 3
  }
}
```

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.
