# Root Cause Analysis (RCA) Implementation Plan

> **Historical note:** This plan was written under the name "flaw tracer". The module
> has been renamed to `rca` (root cause analysis) — all paths referencing `rca`
> in this document now correspond to `rca`.
> The static DAG registry described here has since been replaced by `extract_dag.py`
> which introspects the Luigi task graph at import time.

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a CLI tool that traces problems in PlanExe reports upstream through the pipeline DAG to find root causes.

**Architecture:** Recursive depth-first search using a DAG registry built from Luigi task introspection. Three LLM prompts (problem identification, upstream check, source code analysis) use Pydantic structured output via LLMExecutor. Produces JSON + markdown reports.

**Tech Stack:** Python 3.13, llama-index LLM infrastructure, Pydantic v2, argparse, pytest

---

## File Structure

```
worker_plan/worker_plan_internal/rca/
    __init__.py                  — Package init, exports RootCauseAnalyzer
    registry.py                  — DAG mapping (auto-generated from Luigi task introspection)
    prompts.py                   — 3 Pydantic models + 3 prompt builders
    tracer.py                    — Recursive tracing algorithm (RootCauseAnalyzer class)
    output.py                    — JSON + markdown report generation
    __main__.py                  — CLI entry point (argparse)
    tests/
        __init__.py
        test_registry.py         — Lookup function tests
        test_prompts.py          — Prompt construction tests
        test_tracer.py           — Tracing algorithm tests with mock LLM
        test_output.py           — Report generation tests
```

---

### Task 1: Registry — DAG Mapping

**Files:**
- Create: `worker_plan/worker_plan_internal/rca/__init__.py`
- Create: `worker_plan/worker_plan_internal/rca/registry.py`
- Create: `worker_plan/worker_plan_internal/rca/tests/__init__.py`
- Create: `worker_plan/worker_plan_internal/rca/tests/test_registry.py`

- [ ] **Step 1: Write the failing tests**

```python
# worker_plan/worker_plan_internal/rca/tests/test_registry.py
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from worker_plan_internal.rca.registry import (
    NodeInfo,
    NODES,
    find_node_by_filename,
    get_upstream_files,
    get_source_code_paths,
)


class TestNodeInfo(unittest.TestCase):
    def test_stages_is_nonempty(self):
        self.assertGreater(len(NODES), 40)

    def test_all_stages_have_required_fields(self):
        for node in NODES:
            self.assertIsInstance(node.name, str, f"{node.name} name")
            self.assertIsInstance(node.output_files, list, f"{node.name} output_files")
            self.assertTrue(len(node.output_files) > 0, f"{node.name} has no output_files")
            self.assertIsInstance(node.inputs, list, f"{node.name} inputs")
            self.assertIsInstance(node.source_code_files, list, f"{node.name} source_code_files")
            self.assertIsInstance(node.primary_output, str, f"{node.name} primary_output")
            self.assertIn(node.primary_output, node.output_files, f"{node.name} primary_output not in output_files")

    def test_no_duplicate_node_names(self):
        names = [s.name for s in NODES]
        self.assertEqual(len(names), len(set(names)))

    def test_upstream_references_are_valid(self):
        valid_names = {s.name for s in NODES}
        for node in NODES:
            for upstream in node.inputs:
                self.assertIn(upstream, valid_names, f"{node.name} references unknown upstream '{upstream}'")


class TestFindStageByFilename(unittest.TestCase):
    def test_find_report(self):
        stage = find_node_by_filename("report.html")
        self.assertIsNotNone(stage)
        self.assertEqual(node.name, "report")

    def test_find_potential_levers_clean(self):
        stage = find_node_by_filename("potential_levers.json")
        self.assertIsNotNone(stage)
        self.assertEqual(node.name, "potential_levers")

    def test_find_potential_levers_raw(self):
        stage = find_node_by_filename("potential_levers_raw.json")
        self.assertIsNotNone(stage)
        self.assertEqual(node.name, "potential_levers")

    def test_find_executive_summary(self):
        stage = find_node_by_filename("executive_summary.md")
        self.assertIsNotNone(stage)
        self.assertEqual(node.name, "executive_summary")

    def test_unknown_filename_returns_none(self):
        stage = find_node_by_filename("zzz-unknown.txt")
        self.assertIsNone(stage)


class TestGetUpstreamFiles(unittest.TestCase):
    def test_setup_has_no_upstream(self):
        with TemporaryDirectory() as d:
            result = get_upstream_files("setup", Path(d))
            self.assertEqual(result, [])

    def test_potential_levers_upstream(self):
        with TemporaryDirectory() as d:
            output_dir = Path(d)
            # Create the expected upstream files on disk
            (output_dir / "plan.txt").write_text("plan", encoding="utf-8")
            (output_dir / "identify_purpose.md").write_text("purpose", encoding="utf-8")
            (output_dir / "plan_type.md").write_text("type", encoding="utf-8")
            (output_dir / "extract_constraints.md").write_text("constraints", encoding="utf-8")

            result = get_upstream_files("potential_levers", output_dir)
            node_names = [name for name, _ in result]
            self.assertIn("setup", node_names)
            self.assertIn("identify_purpose", node_names)
            self.assertIn("plan_type", node_names)
            self.assertIn("extract_constraints", node_names)

    def test_missing_files_are_skipped(self):
        with TemporaryDirectory() as d:
            output_dir = Path(d)
            # Only create one of the upstream files
            (output_dir / "plan.txt").write_text("plan", encoding="utf-8")

            result = get_upstream_files("potential_levers", output_dir)
            node_names = [name for name, _ in result]
            self.assertIn("setup", node_names)
            # The others should be skipped because their files don't exist
            self.assertNotIn("identify_purpose", node_names)


class TestGetSourceCodePaths(unittest.TestCase):
    def test_potential_levers_source(self):
        paths = get_source_code_paths("potential_levers")
        filenames = [p.name for p in paths]
        self.assertIn("potential_levers.py", filenames)
        self.assertIn("identify_potential_levers.py", filenames)

    def test_unknown_stage_returns_empty(self):
        paths = get_source_code_paths("nonexistent_stage")
        self.assertEqual(paths, [])
```

- [ ] **Step 2: Create package init files**

```python
# worker_plan/worker_plan_internal/rca/__init__.py
"""RCA — Root-cause analysis for PlanExe reports."""
```

```python
# worker_plan/worker_plan_internal/rca/tests/__init__.py
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `cd worker_plan && python -m pytest worker_plan_internal/rca/tests/test_registry.py -v`
Expected: FAIL with `ModuleNotFoundError` or `ImportError`

- [ ] **Step 4: Implement registry.py**

```python
# worker_plan/worker_plan_internal/rca/registry.py
"""Static DAG mapping for the PlanExe pipeline.

Maps every pipeline stage to its output files, upstream dependencies,
and source code files. Derived from the Luigi task classes in
worker_plan_internal/plan/stages/.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

# Base path for source code, relative to worker_plan/
_SOURCE_BASE = Path(__file__).resolve().parent.parent.parent  # worker_plan/


@dataclass(frozen=True)
class NodeInfo:
    """One pipeline stage."""
    name: str
    output_files: list[str]
    primary_output: str  # preferred file to read when checking for problems
    inputs: list[str] = field(default_factory=list)
    source_code_files: list[str] = field(default_factory=list)


# ── Pipeline registry (auto-generated at import time) ─────────────────
# The registry is built by extract_dag.py which introspects the Luigi
# task graph. See extract_dag.py for the full ~70-node DAG.
# Example entry:
NODES: tuple[NodeInfo, ...] = _build_registry()  # see registry.py

# Example NodeInfo:
# NodeInfo(
#     name="potential_levers",
#     output_files=("potential_levers_raw.json", "potential_levers.json"),
#     inputs=(NodeInput(from_node="setup", artifact_path="plan.txt"), ...),
#     source_code_files=("worker_plan_internal/plan/nodes/potential_levers.py", ...),
# )

_NODE_BY_NAME: dict[str, NodeInfo] = {n.name: n for n in NODES}
_NODE_BY_FILENAME: dict[str, NodeInfo] = {}
for _node in NODES:
    for _fname in _node.output_files:
        _NODE_BY_FILENAME[_fname] = _node


def find_node_by_filename(filename: str) -> NodeInfo | None:
    return _NODE_BY_FILENAME.get(filename)


def get_upstream_files(node_name: str, output_dir: Path) -> list[tuple[str, Path]]:
    node = _NODE_BY_NAME.get(node_name)
    if node is None:
        return []
    result = []
    for inp in node.inputs:
        artifact_path = output_dir / inp.artifact_path
        if artifact_path.exists():
            result.append((inp.from_node, artifact_path))
    return result


def get_source_code_paths(node_name: str) -> list[Path]:
    node = _NODE_BY_NAME.get(node_name)
    if node is None:
        return []
    return [_SOURCE_BASE / f for f in node.source_code_files]
```
- [ ] **Step 5: Run tests to verify they pass**

Run: `cd worker_plan && python -m pytest worker_plan_internal/rca/tests/test_registry.py -v`
Expected: All tests PASS

- [ ] **Step 6: Commit**

```bash
git add worker_plan/worker_plan_internal/rca/__init__.py worker_plan/worker_plan_internal/rca/registry.py worker_plan/worker_plan_internal/rca/tests/__init__.py worker_plan/worker_plan_internal/rca/tests/test_registry.py
git commit -m "feat: add rca registry with full pipeline DAG mapping"
```

---

### Task 2: Prompts — Pydantic Models and Prompt Builders

**Files:**
- Create: `worker_plan/worker_plan_internal/rca/prompts.py`
- Create: `worker_plan/worker_plan_internal/rca/tests/test_prompts.py`

- [ ] **Step 1: Write the failing tests**

```python
# worker_plan/worker_plan_internal/rca/tests/test_prompts.py
import unittest
from llama_index.core.llms import ChatMessage, MessageRole
from worker_plan_internal.rca.prompts import (
    IdentifiedProblem,
    ProblemIdentificationResult,
    UpstreamCheckResult,
    SourceCodeAnalysisResult,
    build_problem_identification_messages,
    build_upstream_check_messages,
    build_source_code_analysis_messages,
)


class TestPydanticModels(unittest.TestCase):
    def test_identified_problem_valid(self):
        problem = IdentifiedProblem(
            description="Budget figure is fabricated",
            evidence="The budget is CZK 500,000",
            severity="HIGH",
        )
        self.assertEqual(problem.severity, "HIGH")

    def test_identified_problem_rejects_invalid_severity(self):
        with self.assertRaises(Exception):
            IdentifiedProblem(
                description="test",
                evidence="test",
                severity="CRITICAL",
            )

    def test_problem_identification_result(self):
        result = ProblemIdentificationResult(problems=[
            IdentifiedProblem(description="test", evidence="quote", severity="LOW"),
        ])
        self.assertEqual(len(result.problems), 1)

    def test_upstream_check_result_found(self):
        result = UpstreamCheckResult(found=True, evidence="quote", explanation="precursor")
        self.assertTrue(result.found)
        self.assertEqual(result.evidence, "quote")

    def test_upstream_check_result_not_found(self):
        result = UpstreamCheckResult(found=False, evidence=None, explanation="clean")
        self.assertFalse(result.found)

    def test_source_code_analysis_result(self):
        result = SourceCodeAnalysisResult(
            likely_cause="prompt lacks validation",
            relevant_code_section="system_prompt = ...",
            suggestion="add grounding check",
        )
        self.assertIsInstance(result.likely_cause, str)


class TestBuildProblemIdentificationMessages(unittest.TestCase):
    def test_returns_chat_messages(self):
        messages = build_problem_identification_messages(
            filename="report.html",
            file_content="<html>report content</html>",
            user_problem_description="budget is wrong",
        )
        self.assertIsInstance(messages, list)
        self.assertEqual(len(messages), 2)
        self.assertEqual(messages[0].role, MessageRole.SYSTEM)
        self.assertEqual(messages[1].role, MessageRole.USER)

    def test_user_message_contains_inputs(self):
        messages = build_problem_identification_messages(
            filename="executive_summary.md",
            file_content="# Summary\nBudget: 500k",
            user_problem_description="fabricated budget",
        )
        user_content = messages[1].content
        self.assertIn("executive_summary.md", user_content)
        self.assertIn("# Summary", user_content)
        self.assertIn("fabricated budget", user_content)


class TestBuildUpstreamCheckMessages(unittest.TestCase):
    def test_returns_chat_messages(self):
        messages = build_upstream_check_messages(
            problem_description="Budget is fabricated",
            evidence_quote="CZK 500,000",
            upstream_filename="project_plan.md",
            upstream_file_content="# Project Plan\nBudget: 500k",
        )
        self.assertIsInstance(messages, list)
        self.assertEqual(len(messages), 2)

    def test_user_message_contains_problem_and_upstream(self):
        messages = build_upstream_check_messages(
            problem_description="Missing market sizing",
            evidence_quote="growing Czech market",
            upstream_filename="make_assumptions.md",
            upstream_file_content="# Assumptions\nMarket is growing",
        )
        user_content = messages[1].content
        self.assertIn("Missing market sizing", user_content)
        self.assertIn("growing Czech market", user_content)
        self.assertIn("make_assumptions.md", user_content)


class TestBuildSourceCodeAnalysisMessages(unittest.TestCase):
    def test_returns_chat_messages(self):
        messages = build_source_code_analysis_messages(
            problem_description="Budget fabricated",
            evidence_quote="CZK 500,000",
            source_code_contents=[
                ("stages/make_assumptions.py", "class MakeAssumptionsTask: ..."),
                ("assume/make_assumptions.py", "def execute(llm, query): ..."),
            ],
        )
        self.assertIsInstance(messages, list)
        self.assertEqual(len(messages), 2)

    def test_user_message_contains_source_code(self):
        messages = build_source_code_analysis_messages(
            problem_description="Missing analysis",
            evidence_quote="no data",
            source_code_contents=[
                ("my_stage.py", "SYSTEM_PROMPT = 'Generate assumptions'"),
            ],
        )
        user_content = messages[1].content
        self.assertIn("my_stage.py", user_content)
        self.assertIn("SYSTEM_PROMPT", user_content)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd worker_plan && python -m pytest worker_plan_internal/rca/tests/test_prompts.py -v`
Expected: FAIL with `ImportError`

- [ ] **Step 3: Implement prompts.py**

```python
# worker_plan/worker_plan_internal/rca/prompts.py
"""Pydantic models and prompt builders for RCA."""
from typing import Literal
from pydantic import BaseModel, Field
from llama_index.core.llms import ChatMessage, MessageRole


# ── Pydantic models for structured LLM output ──────────────────────────

class IdentifiedProblem(BaseModel):
    """A discrete problem found in a pipeline output file."""
    description: str = Field(description="One-sentence description of the problem")
    evidence: str = Field(description="Direct quote from the file demonstrating the problem")
    severity: Literal["HIGH", "MEDIUM", "LOW"] = Field(
        description="HIGH: fabricated data or missing critical analysis. MEDIUM: weak reasoning or vague claims. LOW: minor gaps."
    )


class ProblemIdentificationResult(BaseModel):
    """Result of analyzing a file for problems."""
    problems: list[IdentifiedProblem] = Field(description="List of discrete problems found in the file")


class UpstreamCheckResult(BaseModel):
    """Result of checking an upstream file for a problem precursor."""
    found: bool = Field(description="True if this file contains the problem or a precursor to it")
    evidence: str | None = Field(description="Direct quote from the file if found, null otherwise")
    explanation: str = Field(description="How this connects to the downstream problem, or why this file is clean")


class SourceCodeAnalysisResult(BaseModel):
    """Result of analyzing source code at a problem's origin stage."""
    likely_cause: str = Field(description="What in the prompt or logic likely caused the problem")
    relevant_code_section: str = Field(description="The specific code or prompt text responsible")
    suggestion: str = Field(description="How to fix or prevent this problem")


# ── Prompt builders ─────────────────────────────────────────────────────

def build_problem_identification_messages(
    filename: str,
    file_content: str,
    user_problem_description: str,
) -> list[ChatMessage]:
    """Build messages for Phase 1: identifying discrete problems in a file."""
    system = (
        "You are analyzing an intermediary file from a project planning pipeline.\n"
        "The user has identified problems in this output. Identify each discrete problem.\n"
        "For each problem, provide a short description (one sentence), a direct quote "
        "from the file as evidence, and a severity level.\n"
        "Only identify real problems — do not flag stylistic preferences or minor formatting issues.\n"
        "Severity levels:\n"
        "- HIGH: fabricated data, invented statistics, or missing critical analysis\n"
        "- MEDIUM: weak reasoning, vague unsupported claims, or shallow treatment\n"
        "- LOW: minor gaps that don't significantly impact the plan"
    )
    user = (
        f"User's observation:\n{user_problem_description}\n\n"
        f"Filename: {filename}\n"
        f"File content:\n{file_content}"
    )
    return [
        ChatMessage(role=MessageRole.SYSTEM, content=system),
        ChatMessage(role=MessageRole.USER, content=user),
    ]


def build_upstream_check_messages(
    problem_description: str,
    evidence_quote: str,
    upstream_filename: str,
    upstream_file_content: str,
) -> list[ChatMessage]:
    """Build messages for Phase 2: checking if a problem exists in an upstream file."""
    system = (
        "You are tracing a problem through a project planning pipeline to find where it originated.\n"
        "A downstream file contains a problem. You are examining an upstream file that was an input "
        "to the stage that produced the problematic output.\n"
        "Determine if this upstream file contains the same problem or a precursor to it.\n"
        "If YES: quote the relevant passage and explain how it connects to the downstream problem.\n"
        "If NO: explain why this file is clean regarding this specific problem."
    )
    user = (
        f"Problem: {problem_description}\n"
        f"Evidence from downstream: {evidence_quote}\n\n"
        f"Upstream filename: {upstream_filename}\n"
        f"Upstream file content:\n{upstream_file_content}"
    )
    return [
        ChatMessage(role=MessageRole.SYSTEM, content=system),
        ChatMessage(role=MessageRole.USER, content=user),
    ]


def build_source_code_analysis_messages(
    problem_description: str,
    evidence_quote: str,
    source_code_contents: list[tuple[str, str]],
) -> list[ChatMessage]:
    """Build messages for Phase 3: analyzing source code at problem origin.

    Args:
        source_code_contents: list of (filename, content) tuples
    """
    system = (
        "A problem was introduced at this pipeline stage. The problem exists in its output "
        "but NOT in any of its inputs, so this stage created it.\n"
        "Examine the source code to identify what in the prompt text, logic, or processing "
        "likely caused this problem. Be specific — point to lines or prompt phrases.\n"
        "Focus on the system prompt text and the data transformation logic."
    )
    source_sections = []
    for fname, content in source_code_contents:
        source_sections.append(f"--- {fname} ---\n{content}")
    source_text = "\n\n".join(source_sections)

    user = (
        f"Problem: {problem_description}\n"
        f"Evidence from output: {evidence_quote}\n\n"
        f"Source code files:\n{source_text}"
    )
    return [
        ChatMessage(role=MessageRole.SYSTEM, content=system),
        ChatMessage(role=MessageRole.USER, content=user),
    ]
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd worker_plan && python -m pytest worker_plan_internal/rca/tests/test_prompts.py -v`
Expected: All tests PASS

- [ ] **Step 5: Commit**

```bash
git add worker_plan/worker_plan_internal/rca/prompts.py worker_plan/worker_plan_internal/rca/tests/test_prompts.py
git commit -m "feat: add rca Pydantic models and prompt builders"
```

---

### Task 3: Tracer — Recursive Algorithm

**Files:**
- Create: `worker_plan/worker_plan_internal/rca/tracer.py`
- Create: `worker_plan/worker_plan_internal/rca/tests/test_tracer.py`

- [ ] **Step 1: Write the failing tests**

```python
# worker_plan/worker_plan_internal/rca/tests/test_tracer.py
import json
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory

from worker_plan_internal.rca.tracer import RootCauseAnalyzer, RCAResult, TracedProblem, TraceEntry
from worker_plan_internal.llm_util.response_mockllm import ResponseMockLLM
from worker_plan_internal.llm_util.llm_executor import LLMExecutor, LLMModelWithInstance


def _make_executor(responses: list[str]) -> LLMExecutor:
    """Create an LLMExecutor backed by a mock LLM with pre-set responses."""
    llm = ResponseMockLLM(responses=responses)
    llm_models = LLMModelWithInstance.from_instances([llm])
    return LLMExecutor(llm_models=llm_models)


class TestRCAResult(unittest.TestCase):
    def test_dataclass_creation(self):
        result = RCAResult(
            starting_file="report.html",
            problem_description="test",
            output_dir="/tmp/test",
            problems=[],
            llm_calls_made=0,
        )
        self.assertEqual(result.starting_file, "report.html")
        self.assertEqual(len(result.problems), 0)


class TestRootCauseAnalyzerPhase1(unittest.TestCase):
    """Test problem identification (Phase 1) using mock LLM."""

    def test_identify_problems(self):
        """The tracer should parse LLM output into IdentifiedProblem objects."""
        with TemporaryDirectory() as d:
            output_dir = Path(d)
            # Create a minimal output file
            report_file = output_dir / "executive_summary.md"
            report_file.write_text("# Summary\nBudget: CZK 500,000", encoding="utf-8")
            # Create upstream file so trace can proceed
            (output_dir / "project_plan.md").write_text("# Plan", encoding="utf-8")

            # Mock LLM response for problem identification (Phase 1)
            problem_response = json.dumps({
                "problems": [
                    {
                        "description": "Budget is unvalidated",
                        "evidence": "CZK 500,000",
                        "severity": "HIGH",
                    }
                ]
            })
            # Mock LLM response for upstream check (Phase 2) — not found, so origin is here
            upstream_response = json.dumps({
                "found": False,
                "evidence": None,
                "explanation": "No budget mentioned upstream",
            })
            # Mock LLM response for source code analysis (Phase 3)
            source_response = json.dumps({
                "likely_cause": "Prompt asks for budget without data",
                "relevant_code_section": "system_prompt = ...",
                "suggestion": "Add validation step",
            })

            executor = _make_executor([problem_response, upstream_response, source_response])
            source_base = Path(__file__).resolve().parent.parent.parent.parent  # worker_plan/
            tracer = RootCauseAnalyzer(
                output_dir=output_dir,
                llm_executor=executor,
                source_code_base=source_base,
                max_depth=15,
                verbose=False,
            )
            result = tracer.trace("executive_summary.md", "budget is unvalidated")

            self.assertIsInstance(result, RCAResult)
            self.assertGreaterEqual(len(result.problems), 1)
            problem = result.problems[0]
            self.assertEqual(problem.description, "Budget is unvalidated")
            self.assertEqual(problem.severity, "HIGH")


class TestRootCauseAnalyzerUpstreamTrace(unittest.TestCase):
    """Test upstream tracing (Phase 2) with a simple two-level chain."""

    def test_traces_problem_upstream(self):
        with TemporaryDirectory() as d:
            output_dir = Path(d)
            # Create files for a simple chain: executive_summary -> project_plan -> setup
            (output_dir / "executive_summary.md").write_text("Budget: CZK 500,000", encoding="utf-8")
            (output_dir / "project_plan.md").write_text("Budget: CZK 500,000", encoding="utf-8")
            (output_dir / "plan.txt").write_text("Open a tea shop", encoding="utf-8")
            # Create other upstream files that executive_summary depends on
            (output_dir / "strategic_decisions.md").write_text("decisions", encoding="utf-8")
            (output_dir / "scenarios.md").write_text("scenarios", encoding="utf-8")
            (output_dir / "consolidate_assumptions_full.md").write_text("assumptions", encoding="utf-8")

            responses = [
                # Phase 1: identify problems in executive_summary
                json.dumps({"problems": [{"description": "Budget fabricated", "evidence": "CZK 500,000", "severity": "HIGH"}]}),
                # Phase 2: check each upstream of executive_summary
                # strategic_decisions_markdown
                json.dumps({"found": False, "evidence": None, "explanation": "clean"}),
                # scenarios_markdown
                json.dumps({"found": False, "evidence": None, "explanation": "clean"}),
                # consolidate_assumptions_markdown
                json.dumps({"found": False, "evidence": None, "explanation": "clean"}),
                # project_plan — problem found here
                json.dumps({"found": True, "evidence": "Budget: CZK 500,000", "explanation": "Budget originates here"}),
                # Now trace project_plan's upstreams
                # setup
                json.dumps({"found": False, "evidence": None, "explanation": "clean"}),
                # strategic_decisions_markdown (already checked, dedup)
                # scenarios_markdown (already checked, dedup)
                # consolidate_assumptions_markdown (already checked, dedup)
                # pre_project_assessment (not on disk, skipped)
                # Phase 3: source code analysis at project_plan (the origin)
                json.dumps({"likely_cause": "Prompt generates budget", "relevant_code_section": "...", "suggestion": "fix"}),
            ]

            executor = _make_executor(responses)
            source_base = Path(__file__).resolve().parent.parent.parent.parent
            tracer = RootCauseAnalyzer(
                output_dir=output_dir,
                llm_executor=executor,
                source_code_base=source_base,
                max_depth=15,
                verbose=False,
            )
            result = tracer.trace("executive_summary.md", "budget is fabricated")

            self.assertEqual(len(result.problems), 1)
            problem = result.problems[0]
            # The trace should include at least executive_summary and project_plan
            trace_stages = [entry.stage for entry in problem.trace]
            self.assertIn("executive_summary", trace_stages)
            self.assertIn("project_plan", trace_stages)
            # Origin should be project_plan (problem found there but not in its upstream)
            self.assertEqual(problem.origin_stage, "project_plan")


class TestRootCauseAnalyzerMaxDepth(unittest.TestCase):
    def test_respects_max_depth(self):
        with TemporaryDirectory() as d:
            output_dir = Path(d)
            (output_dir / "executive_summary.md").write_text("Budget: 500k", encoding="utf-8")

            responses = [
                json.dumps({"problems": [{"description": "test problem", "evidence": "500k", "severity": "LOW"}]}),
            ]
            executor = _make_executor(responses)
            source_base = Path(__file__).resolve().parent.parent.parent.parent
            tracer = RootCauseAnalyzer(
                output_dir=output_dir,
                llm_executor=executor,
                source_code_base=source_base,
                max_depth=0,  # zero depth = no upstream tracing
                verbose=False,
            )
            result = tracer.trace("executive_summary.md", "test")

            self.assertEqual(len(result.problems), 1)
            # With max_depth=0, no upstream tracing happens
            self.assertEqual(len(result.problems[0].trace), 1)  # only the starting file
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd worker_plan && python -m pytest worker_plan_internal/rca/tests/test_tracer.py -v`
Expected: FAIL with `ImportError`

- [ ] **Step 3: Implement tracer.py**

```python
# worker_plan/worker_plan_internal/rca/tracer.py
"""Recursive depth-first root cause analyzer for PlanExe pipeline outputs."""
import json
import logging
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

from llama_index.core.llms.llm import LLM

from worker_plan_internal.rca.registry import (
    find_node_by_filename,
    get_upstream_files,
    get_source_code_paths,
)
from worker_plan_internal.rca.prompts import (
    ProblemIdentificationResult,
    UpstreamCheckResult,
    SourceCodeAnalysisResult,
    build_problem_identification_messages,
    build_upstream_check_messages,
    build_source_code_analysis_messages,
)
from worker_plan_internal.llm_util.llm_executor import LLMExecutor

logger = logging.getLogger(__name__)


@dataclass
class TraceEntry:
    """One hop in a problem's upstream trace."""
    stage: str
    file: str
    evidence: str
    is_origin: bool = False


@dataclass
class OriginInfo:
    """Source code analysis at a problem's origin stage."""
    stage: str
    file: str
    source_code_files: list[str]
    likely_cause: str
    suggestion: str


@dataclass
class TracedProblem:
    """A fully traced problem with its upstream chain."""
    id: str
    description: str
    severity: str
    starting_evidence: str
    trace: list[TraceEntry]
    origin_stage: Optional[str] = None
    origin: Optional[OriginInfo] = None
    depth: int = 0
    trace_complete: bool = True


@dataclass
class RCAResult:
    """Complete result of a root cause analysis run."""
    starting_file: str
    problem_description: str
    output_dir: str
    problems: list[TracedProblem]
    llm_calls_made: int = 0


class RootCauseAnalyzer:
    """Traces problems upstream through the PlanExe pipeline DAG."""

    def __init__(
        self,
        output_dir: Path,
        llm_executor: LLMExecutor,
        source_code_base: Path,
        max_depth: int = 15,
        verbose: bool = False,
    ):
        self.output_dir = output_dir
        self.llm_executor = llm_executor
        self.source_code_base = source_code_base
        self.max_depth = max_depth
        self.verbose = verbose
        self._llm_calls = 0
        self._checked: set[tuple[str, str]] = set()  # (node_name, problem_description) dedup

    def trace(self, starting_file: str, problem_description: str) -> RCAResult:
        """Main entry point. Identify problems and trace each upstream."""
        self._llm_calls = 0
        self._checked.clear()

        file_path = self.output_dir / starting_file
        if not file_path.exists():
            raise FileNotFoundError(f"Starting file not found: {file_path}")

        file_content = file_path.read_text(encoding="utf-8")
        stage = find_node_by_filename(starting_file)
        node_name = node.name if stage else "unknown"

        # Phase 1: Identify problems
        self._log(f"Phase 1: Identifying problems in {starting_file}")
        identified = self._identify_problems(starting_file, file_content, problem_description)
        self._log(f"  Found {len(identified.problems)} problem(s)")

        traced_problems: list[TracedProblem] = []
        for i, problem in enumerate(identified.problems):
            problem_id = f"problem_{i + 1:03d}"
            self._log(f"\nTracing {problem_id}: {problem.description}")

            starting_entry = TraceEntry(
                stage=node_name,
                file=starting_file,
                evidence=problem.evidence,
                is_origin=False,
            )

            traced = TracedProblem(
                id=problem_id,
                description=problem.description,
                severity=problem.severity,
                starting_evidence=problem.evidence,
                trace=[starting_entry],
            )

            if stage and self.max_depth > 0:
                self._trace_upstream(traced, node_name, problem.description, problem.evidence, depth=0)

            # Mark the last trace entry as origin if no deeper origin was found
            if traced.origin_stage is None and traced.trace:
                last = traced.trace[-1]
                last.is_origin = True
                traced.origin_stage = last.stage
                traced.depth = len(traced.trace) - 1

                # Phase 3: Source code analysis at origin
                self._analyze_source_code(traced, last.stage, problem.description, last.evidence)

            traced_problems.append(traced)

        # Sort by depth (deepest origin first)
        traced_problems.sort(key=lambda f: f.depth, reverse=True)

        return RCAResult(
            starting_file=starting_file,
            problem_description=problem_description,
            output_dir=str(self.output_dir),
            problems=traced_problems,
            llm_calls_made=self._llm_calls,
        )

    def _identify_problems(self, filename: str, file_content: str, user_description: str) -> ProblemIdentificationResult:
        """Phase 1: Ask LLM to identify discrete problems in the starting file."""
        messages = build_problem_identification_messages(filename, file_content, user_description)

        def execute(llm: LLM) -> ProblemIdentificationResult:
            sllm = llm.as_structured_llm(ProblemIdentificationResult)
            response = sllm.chat(messages)
            return response.raw

        self._llm_calls += 1
        return self.llm_executor.run(execute)

    def _check_upstream(self, problem_description: str, evidence: str, upstream_filename: str, upstream_content: str) -> UpstreamCheckResult:
        """Phase 2: Ask LLM if a problem exists in an upstream file."""
        messages = build_upstream_check_messages(problem_description, evidence, upstream_filename, upstream_content)

        def execute(llm: LLM) -> UpstreamCheckResult:
            sllm = llm.as_structured_llm(UpstreamCheckResult)
            response = sllm.chat(messages)
            return response.raw

        self._llm_calls += 1
        return self.llm_executor.run(execute)

    def _trace_upstream(
        self,
        traced: TracedProblem,
        current_stage: str,
        problem_description: str,
        evidence: str,
        depth: int,
    ) -> None:
        """Recursively trace a problem through upstream stages."""
        if depth >= self.max_depth:
            traced.trace_complete = False
            self._log(f"  Max depth {self.max_depth} reached at {current_stage}")
            return

        upstream_files = get_upstream_files(current_stage, self.output_dir)
        if not upstream_files:
            return  # No upstream = this is the origin

        found_upstream = False
        for upstream_name, upstream_path in upstream_files:
            dedup_key = (upstream_name, problem_description)
            if dedup_key in self._checked:
                self._log(f"  Skipping {upstream_name} (already checked for this problem)")
                continue
            self._checked.add(dedup_key)

            upstream_content = upstream_path.read_text(encoding="utf-8")
            self._log(f"  Checking upstream: {upstream_name} ({upstream_path.name})")

            result = self._check_upstream(problem_description, evidence, upstream_path.name, upstream_content)

            if result.found:
                self._log(f"  -> FOUND in {upstream_name}")
                found_upstream = True
                entry = TraceEntry(
                    stage=upstream_name,
                    file=upstream_path.name,
                    evidence=result.evidence or "",
                    is_origin=False,
                )
                traced.trace.append(entry)

                # Recurse deeper
                self._trace_upstream(
                    traced, upstream_name, problem_description,
                    result.evidence or evidence, depth + 1,
                )
                # After recursion, if origin was found deeper, stop tracing other branches
                if traced.origin_stage is not None:
                    return

        if not found_upstream:
            # Current stage is the origin — problem exists here but not in any upstream
            traced.origin_stage = current_stage
            traced.depth = len(traced.trace)
            # Mark the current stage entry as origin
            for entry in traced.trace:
                if entry.stage == current_stage:
                    entry.is_origin = True

    def _analyze_source_code(self, traced: TracedProblem, node_name: str, problem_description: str, evidence: str) -> None:
        """Phase 3: Analyze source code at the origin stage."""
        source_paths = get_source_code_paths(node_name)
        if not source_paths:
            return

        source_contents: list[tuple[str, str]] = []
        for path in source_paths:
            if path.exists():
                content = path.read_text(encoding="utf-8")
                source_contents.append((path.name, content))

        if not source_contents:
            return

        self._log(f"  Phase 3: Analyzing source code for {node_name}")
        messages = build_source_code_analysis_messages(problem_description, evidence, source_contents)

        def execute(llm: LLM) -> SourceCodeAnalysisResult:
            sllm = llm.as_structured_llm(SourceCodeAnalysisResult)
            response = sllm.chat(messages)
            return response.raw

        self._llm_calls += 1
        try:
            analysis = self.llm_executor.run(execute)
            source_file_names = [name for name, _ in source_contents]
            traced.origin = OriginInfo(
                stage=node_name,
                file=traced.trace[-1].file if traced.trace else "",
                source_code_files=source_file_names,
                likely_cause=analysis.likely_cause,
                suggestion=analysis.suggestion,
            )
        except Exception as e:
            logger.warning(f"Source code analysis failed for {node_name}: {e}")

    def _log(self, message: str) -> None:
        """Print to stderr if verbose mode is enabled."""
        if self.verbose:
            print(message, file=sys.stderr)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd worker_plan && python -m pytest worker_plan_internal/rca/tests/test_tracer.py -v`
Expected: All tests PASS

- [ ] **Step 5: Commit**

```bash
git add worker_plan/worker_plan_internal/rca/tracer.py worker_plan/worker_plan_internal/rca/tests/test_tracer.py
git commit -m "feat: add rca recursive tracing algorithm"
```

---

### Task 4: Output — JSON and Markdown Reports

**Files:**
- Create: `worker_plan/worker_plan_internal/rca/output.py`
- Create: `worker_plan/worker_plan_internal/rca/tests/test_output.py`

- [ ] **Step 1: Write the failing tests**

```python
# worker_plan/worker_plan_internal/rca/tests/test_output.py
import json
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory

from worker_plan_internal.rca.tracer import (
    RCAResult,
    TracedProblem,
    TraceEntry,
    OriginInfo,
)
from worker_plan_internal.rca.output import write_json_report, write_markdown_report


def _make_sample_result() -> RCAResult:
    """Create a sample RCAResult for testing."""
    return RCAResult(
        starting_file="executive_summary.md",
        problem_description="Budget is unvalidated",
        output_dir="/tmp/test_output",
        problems=[
            TracedProblem(
                id="problem_001",
                description="Budget of CZK 500,000 is unvalidated",
                severity="HIGH",
                starting_evidence="CZK 500,000",
                trace=[
                    TraceEntry(stage="executive_summary", file="executive_summary.md", evidence="CZK 500,000", is_origin=False),
                    TraceEntry(stage="project_plan", file="project_plan.md", evidence="Budget: 500k", is_origin=False),
                    TraceEntry(stage="make_assumptions", file="make_assumptions.md", evidence="Assume budget of 500k", is_origin=True),
                ],
                origin_stage="make_assumptions",
                origin=OriginInfo(
                    stage="make_assumptions",
                    file="make_assumptions.md",
                    source_code_files=["make_assumptions.py"],
                    likely_cause="Prompt generates budget without data",
                    suggestion="Add validation step",
                ),
                depth=3,
            ),
            TracedProblem(
                id="problem_002",
                description="Missing market sizing",
                severity="MEDIUM",
                starting_evidence="growing Czech market",
                trace=[
                    TraceEntry(stage="executive_summary", file="executive_summary.md", evidence="growing Czech market", is_origin=True),
                ],
                origin_stage="executive_summary",
                depth=1,
            ),
        ],
        llm_calls_made=8,
    )


class TestWriteJsonReport(unittest.TestCase):
    def test_writes_valid_json(self):
        with TemporaryDirectory() as d:
            output_path = Path(d) / "root_cause_analysis.json"
            result = _make_sample_result()
            write_json_report(result, output_path)

            self.assertTrue(output_path.exists())
            data = json.loads(output_path.read_text(encoding="utf-8"))
            self.assertIn("input", data)
            self.assertIn("problems", data)
            self.assertIn("summary", data)

    def test_json_contains_correct_summary(self):
        with TemporaryDirectory() as d:
            output_path = Path(d) / "root_cause_analysis.json"
            result = _make_sample_result()
            write_json_report(result, output_path)

            data = json.loads(output_path.read_text(encoding="utf-8"))
            summary = data["summary"]
            self.assertEqual(summary["total_problems"], 2)
            self.assertEqual(summary["deepest_origin_node"], "make_assumptions")
            self.assertEqual(summary["deepest_origin_depth"], 3)
            self.assertEqual(summary["llm_calls_made"], 8)

    def test_json_problems_sorted_by_depth(self):
        with TemporaryDirectory() as d:
            output_path = Path(d) / "root_cause_analysis.json"
            result = _make_sample_result()
            write_json_report(result, output_path)

            data = json.loads(output_path.read_text(encoding="utf-8"))
            depths = [f["depth"] for f in data["problems"]]
            self.assertEqual(depths, sorted(depths, reverse=True))


class TestWriteMarkdownReport(unittest.TestCase):
    def test_writes_markdown_file(self):
        with TemporaryDirectory() as d:
            output_path = Path(d) / "root_cause_analysis.md"
            result = _make_sample_result()
            write_markdown_report(result, output_path)

            self.assertTrue(output_path.exists())
            content = output_path.read_text(encoding="utf-8")
            self.assertIn("# Root Cause Analysis Report", content)

    def test_markdown_contains_problem_details(self):
        with TemporaryDirectory() as d:
            output_path = Path(d) / "root_cause_analysis.md"
            result = _make_sample_result()
            write_markdown_report(result, output_path)

            content = output_path.read_text(encoding="utf-8")
            self.assertIn("Budget of CZK 500,000 is unvalidated", content)
            self.assertIn("make_assumptions", content)
            self.assertIn("executive_summary", content)

    def test_markdown_contains_trace_table(self):
        with TemporaryDirectory() as d:
            output_path = Path(d) / "root_cause_analysis.md"
            result = _make_sample_result()
            write_markdown_report(result, output_path)

            content = output_path.read_text(encoding="utf-8")
            self.assertIn("| Node |", content)
            self.assertIn("| File |", content)

    def test_empty_result_produces_valid_markdown(self):
        with TemporaryDirectory() as d:
            output_path = Path(d) / "root_cause_analysis.md"
            result = RCAResult(
                starting_file="report.html",
                problem_description="test",
                output_dir="/tmp",
                problems=[],
                llm_calls_made=1,
            )
            write_markdown_report(result, output_path)

            content = output_path.read_text(encoding="utf-8")
            self.assertIn("Problems found:** 0", content)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd worker_plan && python -m pytest worker_plan_internal/rca/tests/test_output.py -v`
Expected: FAIL with `ImportError`

- [ ] **Step 3: Implement output.py**

```python
# worker_plan/worker_plan_internal/rca/output.py
"""JSON and markdown report generation for root cause analysis results."""
import json
from datetime import datetime, UTC
from pathlib import Path

from worker_plan_internal.rca.tracer import RCAResult


def write_json_report(result: RCAResult, output_path: Path) -> None:
    """Write the RCA result as a JSON file."""
    data = {
        "input": {
            "starting_file": result.starting_file,
            "problem_description": result.problem_description,
            "output_dir": result.output_dir,
            "timestamp": datetime.now(UTC).isoformat(),
        },
        "problems": [],
        "summary": {
            "total_problems": len(result.problems),
            "deepest_origin_node": None,
            "deepest_origin_depth": 0,
            "llm_calls_made": result.llm_calls_made,
        },
    }

    max_depth = 0
    deepest_stage = None

    for problem in result.problems:
        problem_data = {
            "id": problem.id,
            "description": problem.description,
            "severity": problem.severity,
            "starting_evidence": problem.starting_evidence,
            "trace": [
                {
                    "stage": entry.stage,
                    "file": entry.file,
                    "evidence": entry.evidence,
                    "is_origin": entry.is_origin,
                }
                for entry in problem.trace
            ],
            "origin": None,
            "depth": problem.depth,
            "trace_complete": problem.trace_complete,
        }

        if problem.origin:
            problem_data["origin"] = {
                "stage": problem.origin.stage,
                "file": problem.origin.file,
                "source_code_files": problem.origin.source_code_files,
                "likely_cause": problem.origin.likely_cause,
                "suggestion": problem.origin.suggestion,
            }

        if problem.depth > max_depth:
            max_depth = problem.depth
            deepest_stage = problem.origin_stage

        data["problems"].append(problem_data)

    data["summary"]["deepest_origin_node"] = deepest_stage
    data["summary"]["deepest_origin_depth"] = max_depth

    output_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")


def write_markdown_report(result: RCAResult, output_path: Path) -> None:
    """Write the RCA result as a markdown report."""
    lines: list[str] = []
    lines.append("# Root Cause Analysis Report")
    lines.append("")
    lines.append(f"**Input:** {result.starting_file}")
    lines.append(f"**Problems found:** {len(result.problems)}")

    if result.problems:
        deepest = max(result.problems, key=lambda f: f.depth)
        lines.append(f"**Deepest origin:** {deepest.origin_stage} (depth {deepest.depth})")
    lines.append(f"**LLM calls:** {result.llm_calls_made}")
    lines.append("")

    for problem in result.problems:
        lines.append("---")
        lines.append("")
        lines.append(f"## {problem.id.replace('_', ' ').title()} ({problem.severity}): {problem.description}")
        lines.append("")

        # Trace chain summary
        node_names = [entry.stage for entry in problem.trace]
        chain_parts = []
        for name in node_names:
            if name == problem.origin_stage:
                chain_parts.append(f"**{name}** (origin)")
            else:
                chain_parts.append(name)
        lines.append(f"**Trace:** {' -> '.join(chain_parts)}")
        lines.append("")

        if not problem.trace_complete:
            lines.append("*Note: trace incomplete — max depth reached.*")
            lines.append("")

        # Trace table
        lines.append("| Node | File | Evidence |")
        lines.append("|-------|------|----------|")
        for entry in problem.trace:
            node_cell = f"**{entry.stage}**" if entry.is_origin else entry.stage
            evidence_cell = _escape_table_cell(entry.evidence)
            lines.append(f"| {node_cell} | {entry.file} | {evidence_cell} |")
        lines.append("")

        # Origin analysis
        if problem.origin:
            lines.append(f"**Root cause:** {problem.origin.likely_cause}")
            lines.append("")
            lines.append(f"**Source files:** {', '.join(problem.origin.source_code_files)}")
            lines.append("")
            lines.append(f"**Suggestion:** {problem.origin.suggestion}")
            lines.append("")

    output_path.write_text("\n".join(lines), encoding="utf-8")


def _escape_table_cell(text: str) -> str:
    """Escape pipe characters and collapse newlines for markdown table cells."""
    return text.replace("|", "\\|").replace("\n", " ")
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd worker_plan && python -m pytest worker_plan_internal/rca/tests/test_output.py -v`
Expected: All tests PASS

- [ ] **Step 5: Commit**

```bash
git add worker_plan/worker_plan_internal/rca/output.py worker_plan/worker_plan_internal/rca/tests/test_output.py
git commit -m "feat: add rca JSON and markdown report generation"
```

---

### Task 5: CLI Entry Point

**Files:**
- Create: `worker_plan/worker_plan_internal/rca/__main__.py`

- [ ] **Step 1: Implement __main__.py**

```python
# worker_plan/worker_plan_internal/rca/__main__.py
"""CLI entry point for RCA.

Usage:
    python -m worker_plan_internal.rca \
        --dir /path/to/output \
        --file report.html \
        --problem "The budget appears unvalidated..." \
        --output-dir /path/to/output \
        --max-depth 15 \
        --verbose
"""
import argparse
import sys
from pathlib import Path

from worker_plan_internal.rca.tracer import RootCauseAnalyzer
from worker_plan_internal.rca.output import write_json_report, write_markdown_report
from worker_plan_internal.llm_util.llm_executor import LLMExecutor, LLMModelFromName, RetryConfig
from worker_plan_internal.llm_factory import get_llm_names_by_priority


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Trace problems in PlanExe reports upstream to their root cause.",
    )
    parser.add_argument(
        "--dir", required=True, type=Path,
        help="Path to the output directory containing intermediary files",
    )
    parser.add_argument(
        "--file", required=True,
        help="Starting file to analyze (relative to --dir)",
    )
    parser.add_argument(
        "--problem", required=True,
        help="Text description of the observed problem(s)",
    )
    parser.add_argument(
        "--output-dir", type=Path, default=None,
        help="Where to write root_cause_analysis.json and root_cause_analysis.md (defaults to --dir)",
    )
    parser.add_argument(
        "--max-depth", type=int, default=15,
        help="Maximum upstream hops per problem (default: 15)",
    )
    parser.add_argument(
        "--verbose", action="store_true",
        help="Print each LLM call and result to stderr",
    )
    args = parser.parse_args()

    output_dir: Path = args.dir.resolve()
    if not output_dir.is_dir():
        print(f"Error: --dir is not a directory: {output_dir}", file=sys.stderr)
        sys.exit(1)

    starting_file = args.file
    if not (output_dir / starting_file).exists():
        print(f"Error: starting file not found: {output_dir / starting_file}", file=sys.stderr)
        sys.exit(1)

    report_dir: Path = (args.output_dir or args.dir).resolve()
    report_dir.mkdir(parents=True, exist_ok=True)

    # Set up LLM executor with priority-ordered models from the active profile
    llm_names = get_llm_names_by_priority()
    if not llm_names:
        print("Error: no LLM models configured. Check PLANEXE_MODEL_PROFILE.", file=sys.stderr)
        sys.exit(1)

    llm_models = LLMModelFromName.from_names(llm_names)
    executor = LLMExecutor(
        llm_models=llm_models,
        retry_config=RetryConfig(max_retries=2),
        max_validation_retries=1,
    )

    # Source code base is the worker_plan/ directory
    source_code_base = Path(__file__).resolve().parent.parent.parent

    tracer = RootCauseAnalyzer(
        output_dir=output_dir,
        llm_executor=executor,
        source_code_base=source_code_base,
        max_depth=args.max_depth,
        verbose=args.verbose,
    )

    print(f"Tracing problems in {starting_file}...", file=sys.stderr)
    result = tracer.trace(starting_file, args.problem)

    # Write reports
    json_path = report_dir / "root_cause_analysis.json"
    md_path = report_dir / "root_cause_analysis.md"
    write_json_report(result, json_path)
    write_markdown_report(result, md_path)

    # Print summary
    print(f"\nProblems found: {len(result.problems)}", file=sys.stderr)
    if result.problems:
        deepest = max(result.problems, key=lambda f: f.depth)
        print(f"Deepest origin: {deepest.origin_stage} (depth {deepest.depth})", file=sys.stderr)
    print(f"LLM calls made: {result.llm_calls_made}", file=sys.stderr)
    print(f"\nReports written:", file=sys.stderr)
    print(f"  JSON: {json_path}", file=sys.stderr)
    print(f"  Markdown: {md_path}", file=sys.stderr)


if __name__ == "__main__":
    main()
```

- [ ] **Step 2: Verify the module is importable**

Run: `cd worker_plan && python -c "from worker_plan_internal.rca.__main__ import main; print('OK')"`
Expected: `OK`

- [ ] **Step 3: Verify --help works**

Run: `cd worker_plan && python -m worker_plan_internal.rca --help`
Expected: Help text showing `--dir`, `--file`, `--problem`, `--output-dir`, `--max-depth`, `--verbose`

- [ ] **Step 4: Commit**

```bash
git add worker_plan/worker_plan_internal/rca/__main__.py
git commit -m "feat: add rca CLI entry point"
```

---

### Task 6: Run All Tests and Final Verification

**Files:**
- No new files

- [ ] **Step 1: Run the full test suite for the rca package**

Run: `cd worker_plan && python -m pytest worker_plan_internal/rca/tests/ -v`
Expected: All tests PASS

- [ ] **Step 2: Run the broader worker_plan test suite to check for regressions**

Run: `cd worker_plan && python -m pytest -v --timeout=30`
Expected: No new failures

- [ ] **Step 3: Commit any fixes if needed**

If tests required fixes, commit them:
```bash
git add -u
git commit -m "fix: address test issues in rca"
```
