# Autopsy v0 Implementation Plan

> **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:** Implement `cctx autopsy <session>` (and `--since`) — a forensic CLI that detects retry loops, scope creep, and stale context in Claude Code sessions, attributes waste cost, and generates copy-pasteable CLAUDE.md patches.

**Architecture:** Parser produces `SessionTrace` → Diagnostician runs three classifiers → Orchestrator builds `Diagnosis` → Recommender generates `Patch` diffs → Terminal renderer displays the report → CLI wires everything together.

**Tech Stack:** Python 3.10+, `click`, `rich-click`, `rich`, stdlib `dataclasses`/`re`/`json`

**Spec:** `docs/superpowers/specs/2026-05-14-autopsy-design.md`
**GitHub issues:** #41–#49, #9, #10

---

## Pre-flight

- [ ] Confirm you are on a feature branch (NOT `main`): `git branch --show-current`
- [ ] If on `main`, create a branch: `git checkout -b feat/autopsy-v0`
- [ ] Verify existing tests pass: `CCTX_OFFLINE=1 pytest -q`

---

## Task 1: Add runtime dependencies (#41 prereq)

**Files:**
- Modify: `pyproject.toml`

- [ ] **Step 1: Add runtime deps to pyproject.toml**

Open `pyproject.toml` and replace the empty `dependencies = []` with:

```toml
dependencies = [
    "click>=8.0",
    "rich-click>=1.8",
    "rich>=13.0",
    "anthropic>=0.25",
]
```

- [ ] **Step 2: Install updated deps**

```bash
pip install -e ".[dev]"
```

Expected: packages install without conflicts.

- [ ] **Step 3: Verify imports work**

```bash
python -c "import click; import rich; import rich_click; import anthropic; print('ok')"
```

Expected: `ok`

- [ ] **Step 4: Commit**

```bash
git add pyproject.toml
git commit -m "chore: add runtime deps for autopsy v0 (click, rich, anthropic)"
```

---

## Task 2: Extend models.py with autopsy types (#41)

**Files:**
- Modify: `cctx/models.py`
- Modify: `tests/test_models.py`

**Context:** `models.py` has three pre-pivot dead-code dataclasses (`Recommendation`, `SessionSummary`, `ProjectAnalysis`) that the autopsy pivot superseded. Remove them and their tests, then add the five new autopsy types.

- [ ] **Step 1: Write failing tests for the new types**

Append to `tests/test_models.py`:

```python
# ---------------------------------------------------------------------------
# Autopsy types — M2
# ---------------------------------------------------------------------------


def test_finding_kind_values():
    from cctx.models import FindingKind

    assert FindingKind.RETRY_LOOP.value == "retry_loop"
    assert FindingKind.SCOPE_CREEP.value == "scope_creep"
    assert FindingKind.STALE_CONTEXT.value == "stale_context"


def test_severity_and_confidence_are_str_enums():
    from cctx.models import Confidence, Severity

    # str-based so they serialize without a custom encoder
    assert isinstance(Severity.HIGH, str)
    assert Severity.HIGH == "high"
    assert Confidence.MEDIUM == "medium"


def test_finding_instantiates():
    from cctx.models import Confidence, Finding, FindingKind, Severity

    f = Finding(
        kind=FindingKind.RETRY_LOOP,
        severity=Severity.HIGH,
        confidence=Confidence.HIGH,
        first_turn=12,
        last_turn=16,
        evidence={"occurrences": [], "loop_length": 3},
        cost_usd=None,
        summary="Edit(src/foo.py) failed 3× between turns 12–16",
    )
    assert f.kind is FindingKind.RETRY_LOOP
    assert f.severity is Severity.HIGH
    assert f.confidence is Confidence.HIGH
    assert f.first_turn == 12
    assert f.last_turn == 16
    assert f.cost_usd is None


def test_finding_last_turn_none():
    from cctx.models import Confidence, Finding, FindingKind, Severity

    f = Finding(
        kind=FindingKind.SCOPE_CREEP,
        severity=Severity.MEDIUM,
        confidence=Confidence.MEDIUM,
        first_turn=5,
        last_turn=None,
        evidence={"phrases": []},
        cost_usd=None,
        summary="'while I'm here' at turn 5",
    )
    assert f.last_turn is None


def test_patch_instantiates():
    from cctx.models import FindingKind, Patch

    p = Patch(
        target_file="CLAUDE.md",
        description="Add retry discipline rule",
        unified_diff="+## Retry discipline\n+\n+Stop after two failures.",
        finding_kind=FindingKind.RETRY_LOOP,
        evidence_summary="Edit(src/foo.py) failed 3× between turns 12–16",
    )
    assert p.target_file == "CLAUDE.md"
    assert p.finding_kind is FindingKind.RETRY_LOOP
    assert "+## Retry discipline" in p.unified_diff


def test_diagnosis_instantiates():
    from cctx.models import Confidence, Diagnosis, Finding, FindingKind, Severity

    f = Finding(
        kind=FindingKind.RETRY_LOOP,
        severity=Severity.HIGH,
        confidence=Confidence.HIGH,
        first_turn=12,
        last_turn=16,
        evidence={},
        cost_usd=None,
        summary="test",
    )
    d = Diagnosis(
        session_id="abc123",
        findings=[f],
        inflection_turn=12,
        patches=[],
        total_cost_usd=2.14,
        waste_cost_usd=0.0,
        analysed_at=_utcnow(),
    )
    assert d.session_id == "abc123"
    assert len(d.findings) == 1
    assert d.inflection_turn == 12
    assert d.patches == []
    assert d.waste_cost_usd == 0.0


def test_diagnosis_no_findings():
    from cctx.models import Diagnosis

    d = Diagnosis(
        session_id="clean",
        findings=[],
        inflection_turn=None,
        patches=[],
        total_cost_usd=0.50,
        waste_cost_usd=0.0,
        analysed_at=_utcnow(),
    )
    assert d.inflection_turn is None


def test_kind_evidence_instantiates():
    from cctx.models import FindingKind, KindEvidence

    ev = KindEvidence(
        kind=FindingKind.STALE_CONTEXT,
        session_count=8,
        total_waste_usd=4.30,
        example_summaries=["22K-token Bash result stale 14 turns"],
    )
    assert ev.session_count == 8
    assert ev.total_waste_usd == 4.30


def test_aggregate_report_instantiates():
    from datetime import timedelta

    from cctx.models import AggregateReport, FindingKind, KindEvidence

    ev = KindEvidence(FindingKind.RETRY_LOOP, 3, 0.0, [])
    report = AggregateReport(
        window=timedelta(days=7),
        sessions_analysed=12,
        sessions_with_findings=8,
        total_cost_usd=24.10,
        waste_cost_usd=4.30,
        by_kind={FindingKind.RETRY_LOOP: ev},
        patches=[],
    )
    assert report.sessions_analysed == 12
    assert FindingKind.RETRY_LOOP in report.by_kind
```

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

```bash
CCTX_OFFLINE=1 pytest tests/test_models.py -k "finding or patch or diagnosis or kind_evidence or aggregate_report" -v
```

Expected: `ImportError` or `FAILED` — types don't exist yet.

- [ ] **Step 3: Remove pre-pivot dead code from models.py**

In `cctx/models.py`, delete the entire "Analysis-layer data model" section (the `Recommendation`, `SessionSummary`, and `ProjectAnalysis` dataclasses). Replace it with the autopsy types:

```python
# ---------------------------------------------------------------------------
# Autopsy types — M2
# ---------------------------------------------------------------------------

from __future__ import annotations

import dataclasses
from datetime import datetime, timedelta
from enum import Enum
from typing import Any


class FindingKind(str, Enum):
    RETRY_LOOP    = "retry_loop"
    SCOPE_CREEP   = "scope_creep"
    STALE_CONTEXT = "stale_context"


class Severity(str, Enum):
    HIGH   = "high"
    MEDIUM = "medium"
    LOW    = "low"


class Confidence(str, Enum):
    HIGH   = "high"
    MEDIUM = "medium"


@dataclass
class Finding:
    kind:       FindingKind
    severity:   Severity
    confidence: Confidence
    first_turn: int
    last_turn:  int | None
    evidence:   dict[str, Any]
    cost_usd:   float | None
    summary:    str


@dataclass
class Patch:
    target_file:      str
    description:      str
    unified_diff:     str
    finding_kind:     FindingKind
    evidence_summary: str


@dataclass
class Diagnosis:
    session_id:      str
    findings:        list[Finding]
    inflection_turn: int | None
    patches:         list[Patch]
    total_cost_usd:  float
    waste_cost_usd:  float
    analysed_at:     datetime


@dataclass
class KindEvidence:
    kind:              FindingKind
    session_count:     int
    total_waste_usd:   float
    example_summaries: list[str]


@dataclass
class AggregateReport:
    window:                 timedelta
    sessions_analysed:      int
    sessions_with_findings: int
    total_cost_usd:         float
    waste_cost_usd:         float
    by_kind:                dict[FindingKind, KindEvidence]
    patches:                list[Patch]
```

**Note:** `models.py` already has `from __future__ import annotations` at the top and `from dataclasses import dataclass` — extend those imports rather than duplicating them. Add `from datetime import timedelta` and `from typing import Any` to the existing import block.

- [ ] **Step 4: Remove the dead-code tests from test_models.py**

Delete the three test functions for the pre-pivot types:
- `test_recommendation_instantiates_with_all_fields`
- `test_session_summary_instantiates`
- `test_session_summary_compaction_turn_none`
- `test_project_analysis_instantiates_with_recommendations`

- [ ] **Step 5: Run the full models test suite**

```bash
CCTX_OFFLINE=1 pytest tests/test_models.py -v
```

Expected: all pass. Green on the new M2 tests; dead-code tests removed.

- [ ] **Step 6: Commit**

```bash
git add cctx/models.py tests/test_models.py
git commit -m "feat(models): add Finding, Patch, Diagnosis, KindEvidence, AggregateReport (#41)"
```

---

## Task 3: Create diagnostician package skeleton (#42 prereq)

**Files:**
- Create: `cctx/diagnostician/__init__.py`
- Create: `cctx/diagnostician/patterns/__init__.py`
- Create: `tests/diagnostician/__init__.py`

- [ ] **Step 1: Create package files**

```bash
mkdir -p cctx/diagnostician/patterns
touch cctx/diagnostician/__init__.py
touch cctx/diagnostician/patterns/__init__.py
mkdir -p tests/diagnostician
touch tests/diagnostician/__init__.py
```

- [ ] **Step 2: Verify pytest still passes**

```bash
CCTX_OFFLINE=1 pytest -q
```

Expected: same count as before, all pass.

- [ ] **Step 3: Commit**

```bash
git add cctx/diagnostician/ tests/diagnostician/
git commit -m "chore: add diagnostician package skeleton"
```

---

## Task 4: Inflection detector (#42)

**Files:**
- Create: `cctx/diagnostician/inflection.py`
- Create: `tests/diagnostician/test_inflection.py`

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

Create `tests/diagnostician/test_inflection.py`:

```python
"""Tests for cctx/diagnostician/inflection.py."""
from __future__ import annotations

from datetime import datetime, timezone

import pytest

UTC = timezone.utc


def _make_finding(first_turn: int):
    from cctx.models import Confidence, Finding, FindingKind, Severity

    return Finding(
        kind=FindingKind.RETRY_LOOP,
        severity=Severity.MEDIUM,
        confidence=Confidence.HIGH,
        first_turn=first_turn,
        last_turn=None,
        evidence={},
        cost_usd=None,
        summary="test",
    )


def test_detect_no_findings_returns_none():
    from cctx.diagnostician.inflection import detect

    assert detect([]) is None


def test_detect_single_finding_returns_its_turn():
    from cctx.diagnostician.inflection import detect

    assert detect([_make_finding(7)]) == 7


def test_detect_returns_minimum_first_turn():
    from cctx.diagnostician.inflection import detect

    findings = [_make_finding(12), _make_finding(5), _make_finding(9)]
    assert detect(findings) == 5


def test_detect_all_same_first_turn():
    from cctx.diagnostician.inflection import detect

    findings = [_make_finding(3), _make_finding(3)]
    assert detect(findings) == 3
```

- [ ] **Step 2: Run to confirm failure**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_inflection.py -v
```

Expected: `ModuleNotFoundError` — `inflection` doesn't exist yet.

- [ ] **Step 3: Implement**

Create `cctx/diagnostician/inflection.py`:

```python
"""Inflection-turn detection.

The inflection turn is the earliest turn in the session where a classifier
found a problem. Future versions may detect inflection from turn-level signals
that precede any classifier finding (rising error rate, apology language, etc.).
"""
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from cctx.models import Finding


def detect(findings: list[Finding]) -> int | None:
    """Return the earliest first_turn across all findings, or None."""
    if not findings:
        return None
    return min(f.first_turn for f in findings)
```

- [ ] **Step 4: Run tests**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_inflection.py -v
```

Expected: 4 tests pass.

- [ ] **Step 5: Commit**

```bash
git add cctx/diagnostician/inflection.py tests/diagnostician/test_inflection.py
git commit -m "feat(diagnostician): inflection-turn detector (#42)"
```

---

## Task 5: Retry-loop classifier (#43)

**Files:**
- Create: `cctx/diagnostician/patterns/retry_loop.py`
- Create: `tests/diagnostician/test_retry_loop.py`

- [ ] **Step 1: Add a SessionTrace builder helper to the diagnostician conftest**

Create `tests/diagnostician/conftest.py`:

```python
"""Shared helpers for diagnostician tests."""
from __future__ import annotations

from datetime import datetime, timezone
from pathlib import Path

UTC = timezone.utc
_TS = "2026-05-14T10:00:00.000Z"


def _dt(offset_seconds: int = 0) -> datetime:
    from datetime import timedelta
    base = datetime(2026, 5, 14, 10, 0, 0, tzinfo=UTC)
    return base + timedelta(seconds=offset_seconds)


def make_tool_use(tool_use_id: str, tool_name: str, tool_input: dict):
    from cctx.models import ToolUse
    return ToolUse(tool_name=tool_name, tool_use_id=tool_use_id, tool_input=tool_input)


def make_tool_result(tool_use_id: str, tool_name: str, content: str, is_error: bool = False):
    from cctx.models import ToolResult
    return ToolResult(
        tool_name=tool_name,
        tool_use_id=tool_use_id,
        content=content,
        structured=None,
        is_error=is_error,
    )


def make_assistant_turn(turn_number: int, tool_uses=None, text: str = ""):
    from cctx.models import Turn
    return Turn(
        turn_number=turn_number,
        uuid=f"uuid-a{turn_number}",
        parent_uuid=None,
        role="assistant",
        text=text,
        thinking="",
        tool_uses=tool_uses or [],
        tool_results=[],
        usage=None,
        model="claude-sonnet-4-6",
        stop_reason="tool_use",
        timestamp=_dt(turn_number * 10),
        duration_ms=500,
    )


def make_tool_result_turn(turn_number: int, tool_results=None):
    from cctx.models import Turn
    return Turn(
        turn_number=turn_number,
        uuid=f"uuid-r{turn_number}",
        parent_uuid=f"uuid-a{turn_number - 1}",
        role="tool_result",
        text="",
        thinking="",
        tool_uses=[],
        tool_results=tool_results or [],
        usage=None,
        model=None,
        stop_reason=None,
        timestamp=_dt(turn_number * 10 + 2),
        duration_ms=100,
    )


def make_user_turn(turn_number: int, text: str = "do the thing"):
    from cctx.models import Turn
    return Turn(
        turn_number=turn_number,
        uuid=f"uuid-u{turn_number}",
        parent_uuid=None,
        role="user",
        text=text,
        thinking="",
        tool_uses=[],
        tool_results=[],
        usage=None,
        model=None,
        stop_reason=None,
        timestamp=_dt(turn_number * 10),
        duration_ms=None,
    )


def make_trace(turns, model: str = "claude-sonnet-4-6"):
    from cctx.models import SessionTrace
    return SessionTrace(
        session_id="test-session",
        parent_session_id=None,
        project_path="/Users/test/Projects/demo",
        cwd="/Users/test/Projects/demo",
        primary_model=model,
        claude_code_version="2.1.138",
        turns=turns,
        subagents=[],
        attachments=[],
        raw_tool_result_files=[],
        initial_context_tokens=0,
        tool_names_loaded=["Edit", "Bash", "Read"],
        start_time=_dt(0),
        end_time=_dt(100),
        source_path=Path("/Users/test/.claude/projects/demo/test-session.jsonl"),
        subagent_meta={},
        warnings=[],
        subagent_parse_errors=[],
    )
```

- [ ] **Step 2: Write failing tests**

Create `tests/diagnostician/test_retry_loop.py`:

```python
"""Tests for cctx/diagnostician/patterns/retry_loop.py."""
from __future__ import annotations

import pytest

from tests.diagnostician.conftest import (
    make_assistant_turn,
    make_tool_result,
    make_tool_result_turn,
    make_tool_use,
    make_trace,
    make_user_turn,
)


def _edit_trace_with_retry(n_fails: int = 2):
    """Trace where Edit(src/foo.py) fails n_fails times, no fix between."""
    turns = [make_user_turn(1, "fix src/foo.py")]
    for i in range(n_fails):
        turn_num_a = 2 + i * 2
        turn_num_r = 3 + i * 2
        uid = f"toolu_{i:02d}"
        turns.append(make_assistant_turn(
            turn_num_a,
            tool_uses=[make_tool_use(uid, "Edit", {"file_path": "src/foo.py"})],
        ))
        turns.append(make_tool_result_turn(
            turn_num_r,
            tool_results=[make_tool_result(uid, "Edit", "Error: file not found", is_error=True)],
        ))
    return make_trace(turns)


def test_no_retry_on_single_call():
    from cctx.diagnostician.patterns.retry_loop import classify

    turns = [
        make_user_turn(1),
        make_assistant_turn(2, tool_uses=[make_tool_use("tu01", "Edit", {"file_path": "a.py"})]),
        make_tool_result_turn(3, tool_results=[make_tool_result("tu01", "Edit", "Error: oops", is_error=True)]),
    ]
    assert classify(make_trace(turns)) == []


def test_detects_retry_loop_two_failures():
    from cctx.diagnostician.patterns.retry_loop import classify
    from cctx.models import Confidence, FindingKind, Severity

    findings = classify(_edit_trace_with_retry(2))
    assert len(findings) == 1
    f = findings[0]
    assert f.kind is FindingKind.RETRY_LOOP
    assert f.confidence is Confidence.HIGH
    assert f.severity is Severity.MEDIUM
    assert f.first_turn == 4  # second failing call is turn 4
    assert f.evidence["loop_length"] == 2


def test_severity_high_at_four_failures():
    from cctx.diagnostician.patterns.retry_loop import classify
    from cctx.models import Severity

    findings = classify(_edit_trace_with_retry(4))
    assert findings[0].severity is Severity.HIGH


def test_no_retry_when_success_intervenes():
    from cctx.diagnostician.patterns.retry_loop import classify

    turns = [
        make_user_turn(1),
        make_assistant_turn(2, tool_uses=[make_tool_use("tu01", "Edit", {"file_path": "a.py"})]),
        make_tool_result_turn(3, tool_results=[make_tool_result("tu01", "Edit", "Error: oops", is_error=True)]),
        # Successful call in between
        make_assistant_turn(4, tool_uses=[make_tool_use("tu02", "Edit", {"file_path": "a.py"})]),
        make_tool_result_turn(5, tool_results=[make_tool_result("tu02", "Edit", "ok", is_error=False)]),
        # Another failure after the success
        make_assistant_turn(6, tool_uses=[make_tool_use("tu03", "Edit", {"file_path": "a.py"})]),
        make_tool_result_turn(7, tool_results=[make_tool_result("tu03", "Edit", "Error: oops", is_error=True)]),
    ]
    assert classify(make_trace(turns)) == []


def test_bash_key_uses_command():
    from cctx.diagnostician.patterns.retry_loop import classify
    from cctx.models import FindingKind

    turns = [
        make_user_turn(1),
        make_assistant_turn(2, tool_uses=[make_tool_use("tu01", "Bash", {"command": "ls -la"})]),
        make_tool_result_turn(3, tool_results=[make_tool_result("tu01", "Bash", "Error: permission denied", is_error=True)]),
        make_assistant_turn(4, tool_uses=[make_tool_use("tu02", "Bash", {"command": "ls -la"})]),
        make_tool_result_turn(5, tool_results=[make_tool_result("tu02", "Bash", "Error: permission denied", is_error=True)]),
    ]
    findings = classify(make_trace(turns))
    assert len(findings) == 1
    assert findings[0].kind is FindingKind.RETRY_LOOP


def test_different_keys_not_a_loop():
    from cctx.diagnostician.patterns.retry_loop import classify

    turns = [
        make_user_turn(1),
        make_assistant_turn(2, tool_uses=[make_tool_use("tu01", "Edit", {"file_path": "a.py"})]),
        make_tool_result_turn(3, tool_results=[make_tool_result("tu01", "Edit", "Error: oops", is_error=True)]),
        make_assistant_turn(4, tool_uses=[make_tool_use("tu02", "Edit", {"file_path": "b.py"})]),
        make_tool_result_turn(5, tool_results=[make_tool_result("tu02", "Edit", "Error: oops", is_error=True)]),
    ]
    assert classify(make_trace(turns)) == []


def test_empty_trace_returns_empty():
    from cctx.diagnostician.patterns.retry_loop import classify

    assert classify(make_trace([])) == []


def test_cost_usd_is_none():
    from cctx.diagnostician.patterns.retry_loop import classify

    findings = classify(_edit_trace_with_retry(2))
    assert findings[0].cost_usd is None


def test_summary_mentions_tool_and_turns():
    from cctx.diagnostician.patterns.retry_loop import classify

    findings = classify(_edit_trace_with_retry(2))
    assert "Edit" in findings[0].summary
    assert "fail" in findings[0].summary.lower() or "×" in findings[0].summary
```

- [ ] **Step 3: Run tests to confirm failure**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_retry_loop.py -v
```

Expected: `ModuleNotFoundError` — `retry_loop` doesn't exist yet.

- [ ] **Step 4: Implement**

Create `cctx/diagnostician/patterns/retry_loop.py`:

```python
"""Retry-loop classifier.

Detects repeated identical-failing tool calls with no intervening successful
fix. One Finding per session — all loops bundled into a single Finding with
all occurrences in evidence.
"""
from __future__ import annotations

import json
from collections import defaultdict
from typing import TYPE_CHECKING

from cctx.models import Confidence, Finding, FindingKind, Severity

if TYPE_CHECKING:
    from cctx.models import SessionTrace, ToolResult


def _similarity_key(tool_name: str, tool_input: dict) -> str:
    match tool_name:
        case "Bash":
            return tool_input.get("command", "").strip()
        case "Edit" | "Read" | "Write":
            return tool_input.get("file_path", "")
        case "Grep" | "Glob":
            return tool_input.get("pattern", "")
        case _:
            return json.dumps(tool_input, sort_keys=True)


def _is_error(result: ToolResult) -> bool:
    if result.is_error:
        return True
    c = result.content.lower()
    return c.startswith("error:") or c.startswith("failed") or "error:" in c[:80]


def classify(trace: SessionTrace) -> list[Finding]:
    # Build tool_use_id → (ToolResult, turn_number) map
    result_map: dict[str, tuple[ToolResult, int]] = {}
    for turn in trace.turns:
        for tr in turn.tool_results:
            result_map[tr.tool_use_id] = (tr, turn.turn_number)

    # Collect all tool calls with their error status
    # Each entry: (tool_name, key, turn_number, is_error, tool_use_id)
    Record = tuple[str, str, int, bool, str]
    records: list[Record] = []
    for turn in trace.turns:
        if turn.role != "assistant":
            continue
        for tu in turn.tool_uses:
            pair = result_map.get(tu.tool_use_id)
            if pair is None:
                continue
            result, _ = pair
            key = _similarity_key(tu.tool_name, tu.tool_input)
            records.append((tu.tool_name, key, turn.turn_number, _is_error(result), tu.tool_use_id))

    # Group by (tool_name, key)
    groups: dict[tuple[str, str], list[Record]] = defaultdict(list)
    for rec in records:
        groups[(rec[0], rec[1])].append(rec)

    loop_occurrences: list[dict] = []

    for (tool_name, key), group in groups.items():
        error_recs = [r for r in group if r[3]]
        if len(error_recs) < 2:
            continue

        first_err_turn = error_recs[0][2]
        last_err_turn = error_recs[-1][2]

        # Check for any successful call between the first and last error
        intervening_success = any(
            r for r in group
            if not r[3] and first_err_turn < r[2] < last_err_turn
        )
        if intervening_success:
            continue

        loop_occurrences.append({
            "tool_name": tool_name,
            "key": key,
            "error_recs": error_recs,
        })

    if not loop_occurrences:
        return []

    # Flatten all error records and build evidence
    all_errors: list[Record] = sorted(
        (r for occ in loop_occurrences for r in occ["error_recs"]),
        key=lambda r: r[2],
    )

    loop_length = len(all_errors)
    # first_turn = turn of the second failing call (loop established here)
    second_errors = []
    for occ in loop_occurrences:
        if len(occ["error_recs"]) >= 2:
            second_errors.append(occ["error_recs"][1][2])
    first_turn = min(second_errors)
    last_turn = max(r[2] for r in all_errors)

    severity = Severity.HIGH if loop_length >= 4 else Severity.MEDIUM

    evidence_occurrences = []
    for r in all_errors:
        result, _ = result_map[r[4]]
        evidence_occurrences.append({
            "turn": r[2],
            "key": r[1],
            "call": r[0],
            "error": result.content[:120],
        })

    # Summary: describe the first loop
    first_occ = loop_occurrences[0]
    first_err = first_occ["error_recs"]
    tool_label = f"{first_occ['tool_name']}({first_occ['key'][:40]})"
    summary = f"{tool_label} failed {loop_length}× between turns {first_err[0][2]}–{last_turn}"

    return [Finding(
        kind=FindingKind.RETRY_LOOP,
        severity=severity,
        confidence=Confidence.HIGH,
        first_turn=first_turn,
        last_turn=last_turn,
        evidence={"occurrences": evidence_occurrences, "loop_length": loop_length},
        cost_usd=None,
        summary=summary,
    )]
```

- [ ] **Step 5: Run tests**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_retry_loop.py -v
```

Expected: all tests pass.

- [ ] **Step 6: Commit**

```bash
git add cctx/diagnostician/patterns/retry_loop.py tests/diagnostician/test_retry_loop.py tests/diagnostician/conftest.py
git commit -m "feat(diagnostician): retry-loop classifier (#43)"
```

---

## Task 6: Scope-creep classifier (#44)

**Files:**
- Create: `cctx/diagnostician/patterns/scope_creep.py`
- Create: `tests/diagnostician/test_scope_creep.py`

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

Create `tests/diagnostician/test_scope_creep.py`:

```python
"""Tests for cctx/diagnostician/patterns/scope_creep.py."""
from __future__ import annotations

from tests.diagnostician.conftest import (
    make_assistant_turn,
    make_trace,
    make_user_turn,
)


def _trace_with_phrase(phrase: str, turn_number: int = 3):
    """Trace where an assistant turn contains a scope-creep phrase."""
    return make_trace([
        make_user_turn(1, "add a new function"),
        make_user_turn(2, ""),
        make_assistant_turn(turn_number, text=f"I'll implement that. {phrase} clean up the imports."),
    ])


def test_empty_trace_returns_empty():
    from cctx.diagnostician.patterns.scope_creep import classify

    assert classify(make_trace([])) == []


def test_clean_trace_returns_empty():
    from cctx.diagnostician.patterns.scope_creep import classify

    turns = [
        make_user_turn(1, "add function"),
        make_assistant_turn(2, text="Done, I added the function."),
    ]
    assert classify(make_trace(turns)) == []


def test_detects_while_im_here():
    from cctx.diagnostician.patterns.scope_creep import classify
    from cctx.models import Confidence, FindingKind, Severity

    findings = classify(_trace_with_phrase("While I'm here, let me also"))
    assert len(findings) == 1
    f = findings[0]
    assert f.kind is FindingKind.SCOPE_CREEP
    assert f.confidence is Confidence.MEDIUM
    assert f.severity is Severity.MEDIUM
    assert f.first_turn == 3


def test_detects_let_me_also():
    from cctx.diagnostician.patterns.scope_creep import classify

    findings = classify(_trace_with_phrase("Let me also"))
    assert len(findings) == 1


def test_detects_i_also_noticed():
    from cctx.diagnostician.patterns.scope_creep import classify

    findings = classify(_trace_with_phrase("I also noticed"))
    assert len(findings) == 1


def test_detects_ill_also_fix():
    from cctx.diagnostician.patterns.scope_creep import classify

    findings = classify(_trace_with_phrase("I'll also fix"))
    assert len(findings) == 1


def test_detects_while_were_at_it():
    from cctx.diagnostician.patterns.scope_creep import classify

    findings = classify(_trace_with_phrase("While we're at it"))
    assert len(findings) == 1


def test_phrase_in_user_turn_ignored():
    """Scope-creep phrases only count in assistant turns."""
    from cctx.diagnostician.patterns.scope_creep import classify

    turns = [
        make_user_turn(1, "fix it, and while I'm here let me also check"),
        make_assistant_turn(2, text="Done."),
    ]
    assert classify(make_trace(turns)) == []


def test_multiple_phrases_first_turn_wins():
    """One Finding total; first_turn = earliest phrase occurrence."""
    from cctx.diagnostician.patterns.scope_creep import classify

    turns = [
        make_user_turn(1),
        make_assistant_turn(2, text="While I'm here, I'll fix this."),
        make_assistant_turn(4, text="I also noticed the formatting."),
    ]
    findings = classify(make_trace(turns))
    assert len(findings) == 1
    assert findings[0].first_turn == 2
    assert len(findings[0].evidence["phrases"]) == 2


def test_cost_usd_is_none():
    from cctx.diagnostician.patterns.scope_creep import classify

    findings = classify(_trace_with_phrase("While I'm here"))
    assert findings[0].cost_usd is None


def test_summary_mentions_phrase_and_turn():
    from cctx.diagnostician.patterns.scope_creep import classify

    findings = classify(_trace_with_phrase("While I'm here"))
    assert "turn" in findings[0].summary.lower() or "3" in findings[0].summary
```

- [ ] **Step 2: Run to confirm failure**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_scope_creep.py -v
```

Expected: `ModuleNotFoundError`.

- [ ] **Step 3: Implement**

Create `cctx/diagnostician/patterns/scope_creep.py`:

```python
"""Scope-creep classifier.

Fires only on explicit re-scoping phrases in assistant turn text (conservative
v0). No structural heuristics. One Finding per session; all phrase matches
bundled into evidence.
"""
from __future__ import annotations

import re
from typing import TYPE_CHECKING

from cctx.models import Confidence, Finding, FindingKind, Severity

if TYPE_CHECKING:
    from cctx.models import SessionTrace

# Case-insensitive phrase list. "i noticed that" requires a following action verb.
_PLAIN_PHRASES = [
    "i'll also fix",
    "while i'm here",
    "let me also",
    "i also noticed",
    "while we're at it",
    "i should also",
    "additionally, i'll",
]

_ACTION_VERBS = r"(?:fix|add|update|change|remove|clean|refactor|improve|address)"
_NOTICED_THAT = re.compile(
    r"i noticed that.{0,20}" + _ACTION_VERBS,
    re.IGNORECASE,
)


def _matches(text: str) -> list[str]:
    """Return all matched phrases found in text (lowercased text)."""
    low = text.lower()
    found = [p for p in _PLAIN_PHRASES if p in low]
    if _NOTICED_THAT.search(text):
        found.append("i noticed that")
    return found


def classify(trace: SessionTrace) -> list[Finding]:
    phrases_found: list[dict] = []

    for turn in trace.turns:
        if turn.role != "assistant" or not turn.text:
            continue
        matched = _matches(turn.text)
        for phrase in matched:
            # Extract 80-char snippet around the match
            low = turn.text.lower()
            idx = low.find(phrase)
            start = max(0, idx - 20)
            snippet = turn.text[start : start + 80]
            phrases_found.append({
                "turn": turn.turn_number,
                "phrase": phrase,
                "snippet": snippet,
            })

    if not phrases_found:
        return []

    first_turn = min(p["turn"] for p in phrases_found)
    count = len(phrases_found)
    first_phrase = phrases_found[0]["phrase"]
    summary = f"'{first_phrase}' at turn {first_turn} ({count} scope expansion{'s' if count > 1 else ''} total)"

    return [Finding(
        kind=FindingKind.SCOPE_CREEP,
        severity=Severity.MEDIUM,
        confidence=Confidence.MEDIUM,
        first_turn=first_turn,
        last_turn=phrases_found[-1]["turn"] if len(phrases_found) > 1 else None,
        evidence={"phrases": phrases_found},
        cost_usd=None,
        summary=summary,
    )]
```

- [ ] **Step 4: Run tests**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_scope_creep.py -v
```

Expected: all pass.

- [ ] **Step 5: Commit**

```bash
git add cctx/diagnostician/patterns/scope_creep.py tests/diagnostician/test_scope_creep.py
git commit -m "feat(diagnostician): scope-creep classifier (#44)"
```

---

## Task 7: Stale-context classifier (#45)

**Files:**
- Create: `cctx/diagnostician/patterns/stale_context.py`
- Create: `tests/diagnostician/test_stale_context.py`

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

Create `tests/diagnostician/test_stale_context.py`:

```python
"""Tests for cctx/diagnostician/patterns/stale_context.py."""
from __future__ import annotations

from tests.diagnostician.conftest import (
    make_assistant_turn,
    make_tool_result,
    make_tool_result_turn,
    make_tool_use,
    make_trace,
    make_user_turn,
)

# A large content string: >2000 tokens. Using ~1600 words ≈ 2080 tokens at 1.3 factor.
_LARGE_CONTENT = ("The search results show many TODO items across the codebase. " * 120).strip()
# A 3-gram that appears in _LARGE_CONTENT, for reference detection tests
_LARGE_CONTENT_3GRAM = "search results show"


def _stale_trace(n_silent_turns: int = 6, content: str = _LARGE_CONTENT):
    """
    Trace where a large Bash result goes unreferenced for n_silent_turns.
    Turn 1: user prompt
    Turn 2: assistant Bash tool_use
    Turn 3: tool_result with large content (last reference is here implicitly)
    Turns 4..4+n_silent_turns: assistant+user turns with no reference to content
    """
    uid = "toolu_bash_01"
    turns = [
        make_user_turn(1, "find all TODOs"),
        make_assistant_turn(2, tool_uses=[make_tool_use(uid, "Bash", {"command": "grep -r TODO ."})]),
        make_tool_result_turn(3, tool_results=[make_tool_result(uid, "Bash", content)]),
    ]
    for i in range(n_silent_turns):
        t = 4 + i * 2
        uid2 = f"toolu_silent_{i}"
        turns.append(make_assistant_turn(t, tool_uses=[make_tool_use(uid2, "Read", {"file_path": "other.py"})]))
        turns.append(make_tool_result_turn(t + 1, tool_results=[make_tool_result(uid2, "Read", "some content")]))
    return make_trace(turns)


def test_empty_trace_returns_empty():
    from cctx.diagnostician.patterns.stale_context import classify

    assert classify(make_trace([])) == []


def test_small_result_ignored():
    """Tool results under T_size=2000 tokens are not candidates."""
    from cctx.diagnostician.patterns.stale_context import classify

    uid = "toolu_small"
    turns = [
        make_user_turn(1),
        make_assistant_turn(2, tool_uses=[make_tool_use(uid, "Bash", {"command": "ls"})]),
        make_tool_result_turn(3, tool_results=[make_tool_result(uid, "Bash", "file1.py\nfile2.py")]),
    ]
    assert classify(make_trace(turns)) == []


def test_large_result_stays_referenced_no_finding():
    """Large result referenced in every subsequent turn — not stale."""
    from cctx.diagnostician.patterns.stale_context import classify

    uid = "toolu_grep"
    turns = [
        make_user_turn(1),
        make_assistant_turn(2, tool_uses=[make_tool_use(uid, "Bash", {"command": "grep -r TODO ."})]),
        make_tool_result_turn(3, tool_results=[make_tool_result(uid, "Bash", _LARGE_CONTENT)]),
        # Reference the content in subsequent turns
        make_assistant_turn(4, text=f"Based on {_LARGE_CONTENT_3GRAM}, I see several items."),
        make_user_turn(5),
        make_assistant_turn(6, text=f"Continuing with {_LARGE_CONTENT_3GRAM} analysis."),
    ]
    assert classify(make_trace(turns)) == []


def test_detects_stale_large_result():
    from cctx.diagnostician.patterns.stale_context import classify
    from cctx.models import FindingKind

    findings = classify(_stale_trace(n_silent_turns=6))
    assert len(findings) == 1
    f = findings[0]
    assert f.kind is FindingKind.STALE_CONTEXT
    assert f.evidence["total_token_turns"] > 0
    assert len(f.evidence["stale_items"]) == 1


def test_first_turn_is_after_n_stale():
    """first_turn = last_referenced_turn + N_stale (= 5)."""
    from cctx.diagnostician.patterns.stale_context import classify

    findings = classify(_stale_trace(n_silent_turns=6))
    item = findings[0].evidence["stale_items"][0]
    expected_first_turn = item["last_referenced_turn"] + 5
    assert findings[0].first_turn == expected_first_turn


def test_confidence_medium_below_500k_token_turns():
    from cctx.diagnostician.patterns.stale_context import classify
    from cctx.models import Confidence, Severity

    findings = classify(_stale_trace(n_silent_turns=6))
    # With ~2000 tokens × 6 stale turns = ~12K token-turns → MEDIUM
    f = findings[0]
    assert f.confidence is Confidence.MEDIUM
    assert f.severity is Severity.MEDIUM


def test_confidence_high_above_500k_token_turns():
    from cctx.diagnostician.patterns.stale_context import classify
    from cctx.models import Confidence, Severity

    # Need >500K token-turns. 2K tokens × 300 stale turns. Build a big trace.
    # Use a very large content (~5K tokens) and 120 silent turns.
    big_content = (_LARGE_CONTENT * 2)[:12000]  # ~5K tokens at 1.3 factor
    findings = classify(_stale_trace(n_silent_turns=120, content=big_content))
    if findings:
        total_tt = findings[0].evidence["total_token_turns"]
        if total_tt > 500_000:
            assert findings[0].confidence is Confidence.HIGH
            assert findings[0].severity is Severity.HIGH


def test_cost_usd_is_none_from_classifier():
    """Classifier returns cost_usd=None; orchestrator patches it."""
    from cctx.diagnostician.patterns.stale_context import classify

    findings = classify(_stale_trace(n_silent_turns=6))
    assert findings[0].cost_usd is None


def test_compaction_resets_stale_count(tmp_path):
    """A compaction system turn resets staleness — item excluded from finding."""
    from cctx.models import Turn
    from datetime import timezone
    from tests.diagnostician.conftest import _dt

    from cctx.diagnostician.patterns.stale_context import classify

    uid = "toolu_grep_c"
    compaction_turn = Turn(
        turn_number=4,
        uuid="uuid-compact",
        parent_uuid=None,
        role="system",
        text="<compaction>context was compacted</compaction>",
        thinking="",
        tool_uses=[],
        tool_results=[],
        usage=None,
        model=None,
        stop_reason=None,
        timestamp=_dt(40),
        duration_ms=None,
    )
    turns = [
        make_user_turn(1),
        make_assistant_turn(2, tool_uses=[make_tool_use(uid, "Bash", {"command": "grep TODO ."})]),
        make_tool_result_turn(3, tool_results=[make_tool_result(uid, "Bash", _LARGE_CONTENT)]),
        compaction_turn,
        make_assistant_turn(5),
        make_assistant_turn(7),
        make_assistant_turn(9),
        make_assistant_turn(11),
        make_assistant_turn(13),
    ]
    # After compaction at turn 4, the large result is gone — no finding expected
    assert classify(make_trace(turns)) == []
```

- [ ] **Step 2: Run to confirm failure**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_stale_context.py -v
```

Expected: `ModuleNotFoundError`.

- [ ] **Step 3: Implement**

Create `cctx/diagnostician/patterns/stale_context.py`:

```python
"""Stale-context classifier.

Detects large tool results that remained in context well past their last
reference. Uses 3-gram overlap to detect references. Compaction-aware:
staleness resets to zero at compaction events.

Thresholds (per spec):
  T_size  = 2_000 tokens (minimum size to be a candidate)
  N_stale = 5 turns after last reference before "stale"
"""
from __future__ import annotations

from typing import TYPE_CHECKING

from cctx.models import Confidence, Finding, FindingKind, Severity

if TYPE_CHECKING:
    from cctx.models import SessionTrace

T_SIZE = 2_000   # token threshold
N_STALE = 5      # turns before officially stale
STALE_HIGH_THRESHOLD = 500_000  # token-turns above which → HIGH


def _estimate_tokens(text: str) -> int:
    return int(len(text.split()) * 1.3)


def _make_3grams(text: str) -> set[tuple[str, ...]]:
    words = text.lower().split()
    if len(words) < 3:
        return set()
    return {tuple(words[i : i + 3]) for i in range(len(words) - 2)}


def _is_compaction(turn) -> bool:
    return turn.role == "system" and "compact" in turn.text.lower()


def classify(trace: SessionTrace) -> list[Finding]:
    # Identify large tool results and their first_seen_turn
    candidates: list[dict] = []  # {uid, tool_name, content, tokens, first_seen_turn}

    for turn in trace.turns:
        for tr in turn.tool_results:
            if tr.token_count > 0:
                tokens = tr.token_count
            else:
                tokens = _estimate_tokens(tr.content)
            if tokens < T_SIZE:
                continue
            candidates.append({
                "uid": tr.tool_use_id,
                "tool_name": tr.tool_name,
                "content": tr.content,
                "tokens": tokens,
                "first_seen_turn": turn.turn_number,
                "content_3grams": _make_3grams(tr.content),
            })

    if not candidates:
        return []

    # Find the turn number of any compaction events
    compaction_turns: set[int] = {
        t.turn_number for t in trace.turns if _is_compaction(t)
    }

    last_turn_number = max((t.turn_number for t in trace.turns), default=0)

    stale_items: list[dict] = []

    for cand in candidates:
        first_seen = cand["first_seen_turn"]
        content_3grams = cand["content_3grams"]

        # Find last turn with a 3-gram reference to this content
        last_ref = first_seen  # at minimum, the turn it appeared in counts as a reference
        for turn in trace.turns:
            if turn.turn_number <= first_seen:
                continue
            if turn.role not in ("assistant", "user"):
                continue
            turn_3grams = _make_3grams(turn.text)
            if content_3grams & turn_3grams:
                last_ref = turn.turn_number

        # Check for compaction between first_seen and end: if any, skip this item
        if any(ct > first_seen for ct in compaction_turns):
            continue

        turns_stale = last_turn_number - last_ref
        if turns_stale <= N_STALE:
            continue

        token_turns = cand["tokens"] * turns_stale
        stale_items.append({
            "tool_name": cand["tool_name"],
            "content_tokens": cand["tokens"],
            "first_seen_turn": first_seen,
            "last_referenced_turn": last_ref,
            "turns_stale": turns_stale,
            "token_turns": token_turns,
        })

    if not stale_items:
        return []

    total_token_turns = sum(item["token_turns"] for item in stale_items)
    level = Confidence.HIGH if total_token_turns > STALE_HIGH_THRESHOLD else Confidence.MEDIUM
    severity = Severity.HIGH if total_token_turns > STALE_HIGH_THRESHOLD else Severity.MEDIUM

    # first_turn = when the first item became officially stale
    first_stale = min(
        item["last_referenced_turn"] + N_STALE for item in stale_items
    )

    # Summary describes the worst offender
    worst = max(stale_items, key=lambda i: i["token_turns"])
    tokens_k = worst["content_tokens"] // 1000
    summary = (
        f"{tokens_k}K-token {worst['tool_name']} result stale "
        f"{worst['turns_stale']} turns "
        f"(~{total_token_turns:,} token-turns)"
    )

    return [Finding(
        kind=FindingKind.STALE_CONTEXT,
        severity=severity,
        confidence=level,
        first_turn=first_stale,
        last_turn=last_turn_number,
        evidence={"stale_items": stale_items, "total_token_turns": total_token_turns},
        cost_usd=None,
        summary=summary,
    )]
```

- [ ] **Step 4: Run tests**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_stale_context.py -v
```

Expected: most tests pass. If the HIGH confidence test fails because the trace isn't large enough, that's fine — note it and move on (it's a best-effort fixture).

- [ ] **Step 5: Run full suite**

```bash
CCTX_OFFLINE=1 pytest -q
```

Expected: all existing tests still pass.

- [ ] **Step 6: Commit**

```bash
git add cctx/diagnostician/patterns/stale_context.py tests/diagnostician/test_stale_context.py
git commit -m "feat(diagnostician): stale-context classifier (#45)"
```

---

## Task 8: Orchestrator (#46)

**Files:**
- Modify: `cctx/diagnostician/__init__.py`
- Create: `tests/diagnostician/test_orchestrator.py`

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

Create `tests/diagnostician/test_orchestrator.py`:

```python
"""Tests for cctx/diagnostician/__init__.py — run(trace) -> Diagnosis."""
from __future__ import annotations

from tests.diagnostician.conftest import (
    make_assistant_turn,
    make_tool_result,
    make_tool_result_turn,
    make_tool_use,
    make_trace,
    make_user_turn,
)


def _retry_trace():
    """Edit(src/foo.py) fails twice — triggers retry_loop."""
    uid1, uid2 = "toolu_01", "toolu_02"
    return make_trace([
        make_user_turn(1),
        make_assistant_turn(2, tool_uses=[make_tool_use(uid1, "Edit", {"file_path": "src/foo.py"})]),
        make_tool_result_turn(3, tool_results=[make_tool_result(uid1, "Edit", "Error: file not found", is_error=True)]),
        make_assistant_turn(4, tool_uses=[make_tool_use(uid2, "Edit", {"file_path": "src/foo.py"})]),
        make_tool_result_turn(5, tool_results=[make_tool_result(uid2, "Edit", "Error: file not found", is_error=True)]),
    ])


def test_clean_trace_returns_no_findings():
    from cctx import diagnostician

    trace = make_trace([make_user_turn(1), make_assistant_turn(2, text="Done.")])
    diagnosis = diagnostician.run(trace)
    assert diagnosis.findings == []
    assert diagnosis.inflection_turn is None
    assert diagnosis.patches == []
    assert diagnosis.total_cost_usd == 0.0
    assert diagnosis.waste_cost_usd == 0.0


def test_retry_loop_finding_present():
    from cctx import diagnostician
    from cctx.models import FindingKind

    diagnosis = diagnostician.run(_retry_trace())
    kinds = [f.kind for f in diagnosis.findings]
    assert FindingKind.RETRY_LOOP in kinds


def test_inflection_turn_set_to_min_first_turn():
    from cctx import diagnostician

    diagnosis = diagnostician.run(_retry_trace())
    min_turn = min(f.first_turn for f in diagnosis.findings)
    assert diagnosis.inflection_turn == min_turn


def test_findings_sorted_by_first_turn():
    from cctx import diagnostician

    diagnosis = diagnostician.run(_retry_trace())
    turns = [f.first_turn for f in diagnosis.findings]
    assert turns == sorted(turns)


def test_patches_empty_at_orchestrator_exit():
    from cctx import diagnostician

    diagnosis = diagnostician.run(_retry_trace())
    assert diagnosis.patches == []


def test_stale_context_cost_usd_patched():
    """stale_context findings get cost_usd from the orchestrator."""
    from cctx import diagnostician
    from cctx.models import FindingKind

    # Build a trace with a large stale result
    _LARGE = ("grep output " * 200).strip()
    uid = "toolu_grep"
    turns = [
        make_user_turn(1),
        make_assistant_turn(2, tool_uses=[make_tool_use(uid, "Bash", {"command": "grep TODO ."})]),
        make_tool_result_turn(3, tool_results=[make_tool_result(uid, "Bash", _LARGE)]),
    ]
    for i in range(8):
        t = 4 + i * 2
        uid2 = f"toolu_s{i}"
        turns.append(make_assistant_turn(t, tool_uses=[make_tool_use(uid2, "Read", {"file_path": f"f{i}.py"})]))
        turns.append(make_tool_result_turn(t + 1, tool_results=[make_tool_result(uid2, "Read", "x")]))
    trace = make_trace(turns, model="claude-sonnet-4-6")

    diagnosis = diagnostician.run(trace)
    stale = [f for f in diagnosis.findings if f.kind is FindingKind.STALE_CONTEXT]
    if stale:
        assert stale[0].cost_usd is not None
        assert stale[0].cost_usd > 0


def test_retry_and_scope_both_detected():
    """Two classifiers fire; inflection_turn = min of both first_turns."""
    from cctx import diagnostician
    from cctx.models import FindingKind

    uid1, uid2 = "toolu_01", "toolu_02"
    turns = [
        make_user_turn(1),
        make_assistant_turn(2, text="While I'm here, let me also fix formatting.", tool_uses=[make_tool_use(uid1, "Edit", {"file_path": "a.py"})]),
        make_tool_result_turn(3, tool_results=[make_tool_result(uid1, "Edit", "Error: not found", is_error=True)]),
        make_assistant_turn(4, tool_uses=[make_tool_use(uid2, "Edit", {"file_path": "a.py"})]),
        make_tool_result_turn(5, tool_results=[make_tool_result(uid2, "Edit", "Error: not found", is_error=True)]),
    ]
    diagnosis = diagnostician.run(make_trace(turns))
    kinds = {f.kind for f in diagnosis.findings}
    assert FindingKind.RETRY_LOOP in kinds
    assert FindingKind.SCOPE_CREEP in kinds
    assert diagnosis.inflection_turn == min(f.first_turn for f in diagnosis.findings)
```

- [ ] **Step 2: Run to confirm failure**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_orchestrator.py -v
```

Expected: `ImportError` — `cctx.diagnostician` has no `run`.

- [ ] **Step 3: Implement**

Replace `cctx/diagnostician/__init__.py` with:

```python
"""Autopsy diagnostician — public entry point.

run(trace) -> Diagnosis
  Runs all three pattern classifiers, detects inflection turn,
  patches cost attribution for stale_context findings, and returns
  a Diagnosis with patches=[].

The Recommender (cctx.recommender.claude_md) populates patches.
"""
from __future__ import annotations

import dataclasses
from datetime import datetime, timezone

from cctx.diagnostician import inflection
from cctx.diagnostician.patterns import retry_loop, scope_creep, stale_context
from cctx.models import Diagnosis, Finding, FindingKind

UTC = timezone.utc

# Input token price per token by model prefix (USD)
_INPUT_PRICE_PER_MTOK: dict[str, float] = {
    "claude-opus-4":   15.0,
    "claude-sonnet-4":  3.0,
    "claude-haiku-4":   0.8,
}


def _price_per_tok(model: str | None) -> float:
    for prefix, mtok in _INPUT_PRICE_PER_MTOK.items():
        if model and model.startswith(prefix):
            return mtok / 1_000_000
    return 3.0 / 1_000_000  # default: Sonnet rate


def _patch_costs(findings: list[Finding], model: str | None) -> list[Finding]:
    price = _price_per_tok(model)
    result = []
    for f in findings:
        if f.kind is FindingKind.STALE_CONTEXT:
            tt = f.evidence.get("total_token_turns", 0)
            f = dataclasses.replace(f, cost_usd=round(tt * price, 4))
        result.append(f)
    return result


def _compute_total_cost(trace, model: str | None) -> float:
    price = _price_per_tok(model)
    total = 0.0
    for turn in trace.turns:
        if turn.usage is not None:
            total += turn.usage.input_tokens * price
    return round(total, 4)


def run(trace) -> Diagnosis:
    """Diagnose a single SessionTrace. Returns Diagnosis with patches=[]."""
    findings: list[Finding] = [
        *retry_loop.classify(trace),
        *scope_creep.classify(trace),
        *stale_context.classify(trace),
    ]
    findings.sort(key=lambda f: f.first_turn)

    inflection_turn = inflection.detect(findings)
    findings = _patch_costs(findings, trace.primary_model)

    total_cost = _compute_total_cost(trace, trace.primary_model)
    waste_cost = sum(f.cost_usd for f in findings if f.cost_usd is not None)

    return Diagnosis(
        session_id=trace.session_id,
        findings=findings,
        inflection_turn=inflection_turn,
        patches=[],
        total_cost_usd=total_cost,
        waste_cost_usd=round(waste_cost, 4),
        analysed_at=datetime.now(UTC),
    )
```

- [ ] **Step 4: Run tests**

```bash
CCTX_OFFLINE=1 pytest tests/diagnostician/test_orchestrator.py -v
```

Expected: all pass.

- [ ] **Step 5: Run full suite**

```bash
CCTX_OFFLINE=1 pytest -q
```

Expected: all pass.

- [ ] **Step 6: Commit**

```bash
git add cctx/diagnostician/__init__.py tests/diagnostician/test_orchestrator.py
git commit -m "feat(diagnostician): autopsy orchestrator run(trace) -> Diagnosis (#46)"
```

---

## Task 9: Recommender — patch generator (#9)

**Files:**
- Create: `cctx/recommender/__init__.py`
- Create: `cctx/recommender/claude_md.py`
- Create: `tests/recommender/__init__.py`
- Create: `tests/recommender/test_claude_md.py`

- [ ] **Step 1: Create recommender package**

```bash
mkdir -p cctx/recommender
touch cctx/recommender/__init__.py
mkdir -p tests/recommender
touch tests/recommender/__init__.py
```

- [ ] **Step 2: Write failing tests**

Create `tests/recommender/test_claude_md.py`:

```python
"""Tests for cctx/recommender/claude_md.py."""
from __future__ import annotations

import dataclasses
from datetime import datetime, timezone

import pytest


def _make_finding(kind_str: str, first_turn: int = 5, evidence: dict | None = None):
    from cctx.models import Confidence, Finding, FindingKind, Severity

    kind_map = {
        "retry_loop": FindingKind.RETRY_LOOP,
        "scope_creep": FindingKind.SCOPE_CREEP,
        "stale_context": FindingKind.STALE_CONTEXT,
    }
    return Finding(
        kind=kind_map[kind_str],
        severity=Severity.MEDIUM,
        confidence=Confidence.MEDIUM,
        first_turn=first_turn,
        last_turn=None,
        evidence=evidence or {},
        cost_usd=0.50 if kind_str == "stale_context" else None,
        summary=f"test {kind_str}",
    )


def _make_diagnosis(findings):
    from cctx.models import Diagnosis

    return Diagnosis(
        session_id="test-session",
        findings=findings,
        inflection_turn=findings[0].first_turn if findings else None,
        patches=[],
        total_cost_usd=2.14,
        waste_cost_usd=sum(f.cost_usd or 0 for f in findings),
        analysed_at=datetime(2026, 5, 14, 10, 0, 0, tzinfo=timezone.utc),
    )


def test_generate_returns_new_diagnosis():
    from cctx.recommender.claude_md import generate

    diagnosis = _make_diagnosis([_make_finding("retry_loop")])
    result = generate(diagnosis)
    assert result is not diagnosis  # new object
    assert result.session_id == diagnosis.session_id


def test_generate_produces_one_patch_per_finding():
    from cctx.recommender.claude_md import generate

    findings = [_make_finding("retry_loop"), _make_finding("scope_creep")]
    result = generate(_make_diagnosis(findings))
    assert len(result.patches) == 2


def test_retry_loop_patch_targets_claude_md():
    from cctx.recommender.claude_md import generate
    from cctx.models import FindingKind

    result = generate(_make_diagnosis([_make_finding("retry_loop")]))
    patch = result.patches[0]
    assert patch.target_file == "CLAUDE.md"
    assert patch.finding_kind is FindingKind.RETRY_LOOP
    assert "+## Retry discipline" in patch.unified_diff


def test_scope_creep_patch_has_discipline_content():
    from cctx.recommender.claude_md import generate

    result = generate(_make_diagnosis([_make_finding("scope_creep")]))
    patch = result.patches[0]
    assert "Scope discipline" in patch.unified_diff


def test_stale_context_patch_has_hygiene_content():
    from cctx.recommender.claude_md import generate

    result = generate(_make_diagnosis([_make_finding("stale_context")]))
    patch = result.patches[0]
    assert "Context hygiene" in patch.unified_diff


def test_evidence_summary_populated():
    from cctx.recommender.claude_md import generate

    evidence = {"occurrences": [{"turn": 12, "key": "src/foo.py", "call": "Edit", "error": "not found"}], "loop_length": 2}
    result = generate(_make_diagnosis([_make_finding("retry_loop", evidence=evidence)]))
    assert result.patches[0].evidence_summary != ""


def test_original_diagnosis_not_mutated():
    from cctx.recommender.claude_md import generate

    diagnosis = _make_diagnosis([_make_finding("retry_loop")])
    generate(diagnosis)
    assert diagnosis.patches == []  # original unchanged


def test_generate_from_evidence_single_session():
    """With session_count=1, no evidence line appended."""
    from cctx.models import FindingKind, KindEvidence
    from cctx.recommender.claude_md import generate_from_evidence

    evidence = {
        FindingKind.RETRY_LOOP: KindEvidence(
            kind=FindingKind.RETRY_LOOP,
            session_count=1,
            total_waste_usd=0.0,
            example_summaries=["Edit(foo.py) failed 2× between turns 5–8"],
        )
    }
    patches = generate_from_evidence(evidence)
    assert len(patches) == 1
    assert "Evidence:" not in patches[0].unified_diff


def test_generate_from_evidence_cross_session_appends_evidence_line():
    """With session_count>=2, evidence line is appended."""
    from cctx.models import FindingKind, KindEvidence
    from cctx.recommender.claude_md import generate_from_evidence

    evidence = {
        FindingKind.STALE_CONTEXT: KindEvidence(
            kind=FindingKind.STALE_CONTEXT,
            session_count=8,
            total_waste_usd=4.30,
            example_summaries=["22K-token Bash result stale 14 turns"],
        )
    }
    patches = generate_from_evidence(evidence)
    assert len(patches) == 1
    assert "Evidence:" in patches[0].unified_diff
    assert "8" in patches[0].unified_diff
    assert "4.30" in patches[0].unified_diff
```

- [ ] **Step 3: Run to confirm failure**

```bash
CCTX_OFFLINE=1 pytest tests/recommender/test_claude_md.py -v
```

Expected: `ModuleNotFoundError`.

- [ ] **Step 4: Implement**

Create `cctx/recommender/claude_md.py`:

```python
"""Patch generator — turns Findings into copy-pasteable CLAUDE.md diffs.

generate(diagnosis) -> Diagnosis   (single-session path)
generate_from_evidence(evidence) -> list[Patch]   (cross-session path)
"""
from __future__ import annotations

import dataclasses
from typing import TYPE_CHECKING

from cctx.models import FindingKind, Patch

if TYPE_CHECKING:
    from cctx.models import Diagnosis, Finding, KindEvidence

# ---------------------------------------------------------------------------
# Patch templates (append-style unified diffs, v0)
# ---------------------------------------------------------------------------

_RETRY_LOOP_DIFF = """\
+## Retry discipline
+
+If the same command or file operation fails twice with the same error, stop and
+diagnose before retrying. Read the relevant file, check the full error message,
+confirm paths exist. Try a meaningfully different approach — never repeat the
+exact failing call a third time."""

_SCOPE_CREEP_DIFF = """\
+## Scope discipline
+
+Finish the stated task before picking up anything else. If you notice an adjacent
+issue while working, note it as a TODO comment but do not fix it unless explicitly
+asked. One task at a time."""

_STALE_CONTEXT_DIFF = """\
+## Context hygiene
+
+Large tool outputs (grep results, file reads over ~2K tokens) go stale quickly.
+After a result has served its purpose, do not carry it through 5+ additional turns
+without re-referencing it. Prefer re-running the tool over dragging stale context
+forward — the compaction system handles removal."""

_TEMPLATES: dict[FindingKind, tuple[str, str, str]] = {
    # kind → (description, diff_body, target_file)
    FindingKind.RETRY_LOOP:    ("Add retry discipline rule",    _RETRY_LOOP_DIFF,    "CLAUDE.md"),
    FindingKind.SCOPE_CREEP:   ("Add scope discipline rule",    _SCOPE_CREEP_DIFF,   "CLAUDE.md"),
    FindingKind.STALE_CONTEXT: ("Add context hygiene rule",     _STALE_CONTEXT_DIFF, "CLAUDE.md"),
}


def _summarize(finding: Finding) -> str:
    ev = finding.evidence
    match finding.kind:
        case FindingKind.RETRY_LOOP:
            occs = ev.get("occurrences", [])
            if occs:
                first = occs[0]
                return f"{first['call']}({first['key'][:40]}) failed {ev.get('loop_length', '?')}× between turns {first['turn']}–{occs[-1]['turn']}"
            return finding.summary
        case FindingKind.SCOPE_CREEP:
            phrases = ev.get("phrases", [])
            if phrases:
                return f"'{phrases[0]['phrase']}' at turn {phrases[0]['turn']}"
            return finding.summary
        case FindingKind.STALE_CONTEXT:
            items = ev.get("stale_items", [])
            if items:
                worst = max(items, key=lambda i: i["token_turns"])
                tokens_k = worst["content_tokens"] // 1000
                cost_str = f", ~${finding.cost_usd:.2f}" if finding.cost_usd else ""
                return (
                    f"{tokens_k}K-token {worst['tool_name']} result stale "
                    f"{worst['turns_stale']} turns "
                    f"(~{ev.get('total_token_turns', 0):,} token-turns{cost_str})"
                )
            return finding.summary
        case _:
            return finding.summary


def _make_patch(finding: Finding) -> Patch:
    description, diff_body, target_file = _TEMPLATES[finding.kind]
    return Patch(
        target_file=target_file,
        description=description,
        unified_diff=diff_body,
        finding_kind=finding.kind,
        evidence_summary=_summarize(finding),
    )


def generate(diagnosis: Diagnosis) -> Diagnosis:
    """Populate patches on a Diagnosis. Returns a new Diagnosis; original unchanged."""
    patches = [_make_patch(f) for f in diagnosis.findings]
    return dataclasses.replace(diagnosis, patches=patches)


def generate_from_evidence(
    evidence: dict[FindingKind, KindEvidence],
) -> list[Patch]:
    """Cross-session patch generation.

    Like generate(), but appends an Evidence line when session_count >= 2:
        Evidence: appeared in N of M sessions in the past window (~$X.XX wasted).
    """
    patches = []
    for kind, ev in evidence.items():
        if kind not in _TEMPLATES:
            continue
        description, diff_body, target_file = _TEMPLATES[kind]

        if ev.session_count >= 2:
            evidence_line = (
                f"+\n+Evidence: appeared in {ev.session_count} sessions "
                f"(~${ev.total_waste_usd:.2f} wasted)."
            )
            diff_body = diff_body + evidence_line

        example = ev.example_summaries[0] if ev.example_summaries else ""
        patches.append(Patch(
            target_file=target_file,
            description=description,
            unified_diff=diff_body,
            finding_kind=kind,
            evidence_summary=example,
        ))
    return patches
```

- [ ] **Step 5: Run tests**

```bash
CCTX_OFFLINE=1 pytest tests/recommender/test_claude_md.py -v
```

Expected: all pass.

- [ ] **Step 6: Commit**

```bash
git add cctx/recommender/ tests/recommender/test_claude_md.py tests/recommender/__init__.py
git commit -m "feat(recommender): patch generator — generate() and generate_from_evidence() (#9)"
```

---

## Task 10: Evidence accumulator + cross-session aggregator (#49)

**Files:**
- Create: `cctx/recommender/evidence.py`
- Create: `cctx/diagnostician/aggregate.py`
- Create: `tests/recommender/test_evidence.py`
- Create: `tests/test_aggregate.py`

- [ ] **Step 1: Write failing tests for evidence accumulator**

Create `tests/recommender/test_evidence.py`:

```python
"""Tests for cctx/recommender/evidence.py."""
from __future__ import annotations

from datetime import datetime, timezone


def _make_diagnosis(finding_kinds: list[str], waste: float = 0.0):
    from cctx.models import (
        Confidence, Diagnosis, Finding, FindingKind, Severity,
    )

    kind_map = {
        "retry_loop": FindingKind.RETRY_LOOP,
        "scope_creep": FindingKind.SCOPE_CREEP,
        "stale_context": FindingKind.STALE_CONTEXT,
    }
    findings = [
        Finding(
            kind=kind_map[k],
            severity=Severity.MEDIUM,
            confidence=Confidence.MEDIUM,
            first_turn=1,
            last_turn=None,
            evidence={},
            cost_usd=waste if k == "stale_context" else None,
            summary=f"test {k}",
        )
        for k in finding_kinds
    ]
    return Diagnosis(
        session_id=f"session-{id(findings)}",
        findings=findings,
        inflection_turn=1 if findings else None,
        patches=[],
        total_cost_usd=2.0,
        waste_cost_usd=waste,
        analysed_at=datetime(2026, 5, 14, 10, 0, 0, tzinfo=timezone.utc),
    )


def test_accumulate_empty():
    from cctx.recommender.evidence import accumulate

    assert accumulate([]) == {}


def test_accumulate_single_session_retry_loop():
    from cctx.models import FindingKind
    from cctx.recommender.evidence import accumulate

    result = accumulate([_make_diagnosis(["retry_loop"])])
    assert FindingKind.RETRY_LOOP in result
    assert result[FindingKind.RETRY_LOOP].session_count == 1


def test_accumulate_counts_sessions_not_findings():
    """Two diagnoses each with retry_loop → session_count=2, not 2."""
    from cctx.models import FindingKind
    from cctx.recommender.evidence import accumulate

    diagnoses = [
        _make_diagnosis(["retry_loop"]),
        _make_diagnosis(["retry_loop"]),
    ]
    result = accumulate(diagnoses)
    assert result[FindingKind.RETRY_LOOP].session_count == 2


def test_accumulate_sums_waste_usd():
    from cctx.models import FindingKind
    from cctx.recommender.evidence import accumulate

    diagnoses = [
        _make_diagnosis(["stale_context"], waste=1.50),
        _make_diagnosis(["stale_context"], waste=2.80),
    ]
    result = accumulate(diagnoses)
    assert abs(result[FindingKind.STALE_CONTEXT].total_waste_usd - 4.30) < 0.01


def test_accumulate_stores_up_to_3_summaries():
    from cctx.models import FindingKind
    from cctx.recommender.evidence import accumulate

    diagnoses = [_make_diagnosis(["retry_loop"]) for _ in range(10)]
    result = accumulate(diagnoses)
    assert len(result[FindingKind.RETRY_LOOP].example_summaries) <= 3


def test_accumulate_handles_no_findings_in_diagnosis():
    from cctx.recommender.evidence import accumulate

    diagnoses = [_make_diagnosis([])]
    result = accumulate(diagnoses)
    assert result == {}
```

- [ ] **Step 2: Write failing tests for aggregator**

Create `tests/test_aggregate.py`:

```python
"""Tests for cctx/diagnostician/aggregate.py."""
from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest

UTC = timezone.utc


def _write_session(tmp_path: Path, session_id: str, model: str = "claude-sonnet-4-6") -> Path:
    """Write a minimal valid session JSONL to tmp_path."""
    lines = [
        {
            "type": "user",
            "uuid": f"{session_id}-u1",
            "parentUuid": None,
            "isSidechain": False,
            "timestamp": "2026-05-14T10:00:00.000Z",
            "sessionId": session_id,
            "version": "2.1.138",
            "cwd": "/Users/test/Projects/demo",
            "gitBranch": "main",
            "userType": "external",
            "entrypoint": "cli",
            "message": {"role": "user", "content": "do the thing"},
        }
    ]
    path = tmp_path / f"{session_id}.jsonl"
    with path.open("w") as f:
        for line in lines:
            f.write(json.dumps(line) + "\n")
    return path


def test_run_returns_diagnoses_for_sessions_in_window(tmp_path):
    from cctx.diagnostician.aggregate import run

    _write_session(tmp_path, "session-a")
    _write_session(tmp_path, "session-b")

    diagnoses = run(tmp_path, window=timedelta(days=7))
    assert len(diagnoses) == 2
    session_ids = {d.session_id for d in diagnoses}
    assert "session-a" in session_ids
    assert "session-b" in session_ids


def test_run_excludes_old_sessions(tmp_path):
    from cctx.diagnostician.aggregate import run

    path = _write_session(tmp_path, "old-session")
    # Backdate mtime by 10 days
    import os, time
    old_time = time.time() - 10 * 86400
    os.utime(path, (old_time, old_time))

    _write_session(tmp_path, "new-session")

    diagnoses = run(tmp_path, window=timedelta(days=7))
    assert len(diagnoses) == 1
    assert diagnoses[0].session_id == "new-session"


def test_run_empty_dir(tmp_path):
    from cctx.diagnostician.aggregate import run

    assert run(tmp_path, window=timedelta(days=7)) == []
```

- [ ] **Step 3: Run to confirm failure**

```bash
CCTX_OFFLINE=1 pytest tests/recommender/test_evidence.py tests/test_aggregate.py -v
```

Expected: `ModuleNotFoundError` for both.

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

Create `cctx/recommender/evidence.py`:

```python
"""Cross-session evidence accumulation.

accumulate(diagnoses) -> dict[FindingKind, KindEvidence]

Counts how many sessions triggered each finding kind and sums waste cost.
Stores up to 3 example_summaries for the renderer.
"""
from __future__ import annotations

from cctx.models import Diagnosis, FindingKind, KindEvidence


def _summarize_finding(finding) -> str:
    from cctx.recommender.claude_md import _summarize  # shared helper
    return _summarize(finding)


def accumulate(diagnoses: list[Diagnosis]) -> dict[FindingKind, KindEvidence]:
    result: dict[FindingKind, KindEvidence] = {}
    for diagnosis in diagnoses:
        for finding in diagnosis.findings:
            if finding.kind not in result:
                result[finding.kind] = KindEvidence(
                    kind=finding.kind,
                    session_count=0,
                    total_waste_usd=0.0,
                    example_summaries=[],
                )
            ev = result[finding.kind]
            ev.session_count += 1
            ev.total_waste_usd += finding.cost_usd or 0.0
            if len(ev.example_summaries) < 3:
                ev.example_summaries.append(_summarize_finding(finding))
    return result
```

- [ ] **Step 5: Implement aggregate.py**

Create `cctx/diagnostician/aggregate.py`:

```python
"""Cross-session aggregator.

run(project_dir, window) -> list[Diagnosis]

Discovers session JSONL files in project_dir modified within the time window,
parses each one, runs the per-session diagnostician, and returns the list of
Diagnoses. The CLI orchestrates the recommender call separately.
"""
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import TYPE_CHECKING

from cctx import diagnostician
from cctx.parsers.claude_code import parse_session

if TYPE_CHECKING:
    from cctx.models import Diagnosis

UTC = timezone.utc


def run(project_dir: Path, window: timedelta) -> list[Diagnosis]:
    cutoff = datetime.now(UTC) - window
    paths = sorted(project_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)

    result = []
    for path in paths:
        mtime = datetime.fromtimestamp(path.stat().st_mtime, UTC)
        if mtime < cutoff:
            continue
        try:
            trace = parse_session(path)
            diagnosis = diagnostician.run(trace)
            result.append(diagnosis)
        except Exception:
            continue  # skip corrupt sessions; don't fail the whole run
    return result
```

- [ ] **Step 6: Run tests**

```bash
CCTX_OFFLINE=1 pytest tests/recommender/test_evidence.py tests/test_aggregate.py -v
```

Expected: all pass.

- [ ] **Step 7: Full suite**

```bash
CCTX_OFFLINE=1 pytest -q
```

Expected: all pass.

- [ ] **Step 8: Commit**

```bash
git add cctx/recommender/evidence.py cctx/diagnostician/aggregate.py \
        tests/recommender/test_evidence.py tests/test_aggregate.py
git commit -m "feat: evidence accumulator + cross-session aggregator (#49)"
```

---

## Task 11: Terminal renderer (#47)

**Files:**
- Create: `cctx/renderers/__init__.py`
- Create: `cctx/renderers/terminal.py`
- Create: `tests/test_terminal_renderer.py`

- [ ] **Step 1: Create renderers package**

```bash
mkdir -p cctx/renderers
touch cctx/renderers/__init__.py
```

- [ ] **Step 2: Write failing tests**

Create `tests/test_terminal_renderer.py`:

```python
"""Tests for cctx/renderers/terminal.py.

Tests capture Console output as text; they don't check for exact ANSI codes,
only that key information is present in the output.
"""
from __future__ import annotations

from datetime import datetime, timezone
from io import StringIO


def _make_finding(kind_str: str, cost: float | None = None):
    from cctx.models import Confidence, Finding, FindingKind, Severity

    kind_map = {
        "retry_loop": FindingKind.RETRY_LOOP,
        "scope_creep": FindingKind.SCOPE_CREEP,
        "stale_context": FindingKind.STALE_CONTEXT,
    }
    return Finding(
        kind=kind_map[kind_str],
        severity=Severity.MEDIUM,
        confidence=Confidence.MEDIUM,
        first_turn=5,
        last_turn=10,
        evidence={},
        cost_usd=cost,
        summary=f"test {kind_str} finding",
    )


def _make_patch(kind_str: str):
    from cctx.models import FindingKind, Patch

    kind_map = {
        "retry_loop": FindingKind.RETRY_LOOP,
        "scope_creep": FindingKind.SCOPE_CREEP,
        "stale_context": FindingKind.STALE_CONTEXT,
    }
    return Patch(
        target_file="CLAUDE.md",
        description="Add discipline rule",
        unified_diff="+## Retry discipline\n+Stop after two failures.",
        finding_kind=kind_map[kind_str],
        evidence_summary="test evidence",
    )


def _make_diagnosis(findings=None, patches=None):
    from cctx.models import Diagnosis

    return Diagnosis(
        session_id="abc123",
        findings=findings or [],
        inflection_turn=5 if findings else None,
        patches=patches or [],
        total_cost_usd=2.14,
        waste_cost_usd=sum(f.cost_usd or 0 for f in (findings or [])),
        analysed_at=datetime(2026, 5, 14, 10, 0, 0, tzinfo=timezone.utc),
    )


def _render_to_string(diagnosis):
    from rich.console import Console
    from cctx.renderers.terminal import render_diagnosis

    buf = StringIO()
    console = Console(file=buf, width=120, highlight=False, markup=False)
    render_diagnosis(diagnosis, console=console)
    return buf.getvalue()


def test_clean_session_shows_no_findings():
    output = _render_to_string(_make_diagnosis())
    assert "No findings" in output or "clean" in output.lower()


def test_session_id_shown():
    output = _render_to_string(_make_diagnosis([_make_finding("retry_loop")]))
    assert "abc123" in output


def test_retry_loop_finding_shown():
    output = _render_to_string(_make_diagnosis([_make_finding("retry_loop")]))
    assert "retry" in output.lower() or "loop" in output.lower()


def test_inflection_turn_shown():
    output = _render_to_string(_make_diagnosis([_make_finding("retry_loop")]))
    assert "5" in output  # inflection_turn=5


def test_cost_shown():
    diag = _make_diagnosis([_make_finding("stale_context", cost=0.88)])
    output = _render_to_string(diag)
    assert "2.14" in output or "2" in output


def test_patch_diff_shown():
    diag = _make_diagnosis(
        findings=[_make_finding("retry_loop")],
        patches=[_make_patch("retry_loop")],
    )
    output = _render_to_string(diag)
    assert "Retry discipline" in output or "CLAUDE.md" in output
```

**Note:** remove `total=None` argument — `_make_diagnosis` doesn't take `total`. Fix the `test_cost_shown` test:

```python
def test_cost_shown():
    diag = _make_diagnosis([_make_finding("stale_context", cost=0.88)])
    output = _render_to_string(diag)
    assert "2.14" in output or "2" in output
```

- [ ] **Step 3: Run to confirm failure**

```bash
CCTX_OFFLINE=1 pytest tests/test_terminal_renderer.py -v
```

Expected: `ModuleNotFoundError` for `cctx.renderers.terminal`.

- [ ] **Step 4: Implement**

Create `cctx/renderers/terminal.py`:

```python
"""Terminal renderer for autopsy Diagnosis output.

render_diagnosis(diagnosis, console=None) -> None
render_aggregate(report, console=None) -> None

Uses rich for formatting. Accepts an optional Console for testing.
"""
from __future__ import annotations

from typing import TYPE_CHECKING

from rich.console import Console
from rich.panel import Panel
from rich.rule import Rule
from rich.syntax import Syntax
from rich.table import Table
from rich.text import Text

from cctx.models import FindingKind, Severity

if TYPE_CHECKING:
    from cctx.models import AggregateReport, Diagnosis

_SEVERITY_STYLE = {
    Severity.HIGH:   "bold red",
    Severity.MEDIUM: "bold yellow",
    Severity.LOW:    "bold green",
}

_KIND_LABEL = {
    FindingKind.RETRY_LOOP:    "RETRY LOOP",
    FindingKind.SCOPE_CREEP:   "SCOPE CREEP",
    FindingKind.STALE_CONTEXT: "STALE CONTEXT",
}


def _default_console() -> Console:
    return Console()


def render_diagnosis(diagnosis: Diagnosis, *, console: Console | None = None) -> None:
    con = console or _default_console()

    # Header
    con.print(Rule(f"cctx autopsy — session {diagnosis.session_id}"))
    cost_line = f"Session cost: ${diagnosis.total_cost_usd:.2f}"
    if diagnosis.waste_cost_usd > 0:
        pct = diagnosis.waste_cost_usd / diagnosis.total_cost_usd * 100 if diagnosis.total_cost_usd else 0
        cost_line += f" | Attributed waste: ${diagnosis.waste_cost_usd:.2f} ({pct:.0f}%)"
    con.print(cost_line)

    if not diagnosis.findings:
        con.print("\n[bold green]No findings[/bold green] — session looks clean.")
        return

    con.print(f"Inflection turn: {diagnosis.inflection_turn}")
    con.print()

    # Findings
    for finding in diagnosis.findings:
        style = _SEVERITY_STYLE.get(finding.severity, "")
        label = _KIND_LABEL.get(finding.kind, finding.kind.value.upper())
        badge = Text(f" {label} ", style=style)
        conf_note = f"({finding.confidence.value} confidence)"
        con.print(badge, conf_note, "—", finding.summary)

    # Patches
    if diagnosis.patches:
        con.print()
        con.print(Rule("CLAUDE.md patches"))
        for patch in diagnosis.patches:
            con.print(f"\n[bold]{patch.description}[/bold]  [{patch.target_file}]")
            con.print(f"Evidence: {patch.evidence_summary}")
            syntax = Syntax(patch.unified_diff, "diff", theme="monokai", word_wrap=True)
            con.print(syntax)


def render_aggregate(report: AggregateReport, *, console: Console | None = None) -> None:
    con = console or _default_console()

    days = int(report.window.total_seconds() / 86400)
    con.print(Rule(f"cctx autopsy — last {days} days"))
    con.print(
        f"Sessions: {report.sessions_analysed} analysed, "
        f"{report.sessions_with_findings} with findings"
    )
    con.print(
        f"Total cost: ${report.total_cost_usd:.2f} | "
        f"Waste: ${report.waste_cost_usd:.2f}"
    )

    if not report.by_kind:
        con.print("\nNo findings across sessions.")
        return

    # Summary table
    table = Table(title="Finding frequency")
    table.add_column("Pattern")
    table.add_column("Sessions", justify="right")
    table.add_column("Waste ($)", justify="right")
    for kind, ev in report.by_kind.items():
        table.add_row(
            _KIND_LABEL.get(kind, kind.value),
            str(ev.session_count),
            f"${ev.total_waste_usd:.2f}",
        )
    con.print(table)

    # Patches
    if report.patches:
        con.print(Rule("Recommended CLAUDE.md patches"))
        for patch in report.patches:
            con.print(f"\n[bold]{patch.description}[/bold]")
            syntax = Syntax(patch.unified_diff, "diff", theme="monokai", word_wrap=True)
            con.print(syntax)
```

- [ ] **Step 5: Run tests**

```bash
CCTX_OFFLINE=1 pytest tests/test_terminal_renderer.py -v
```

Expected: all pass.

- [ ] **Step 6: Run full suite**

```bash
CCTX_OFFLINE=1 pytest -q
```

Expected: all pass.

- [ ] **Step 7: Commit**

```bash
git add cctx/renderers/ tests/test_terminal_renderer.py
git commit -m "feat(renderers): terminal renderer for autopsy diagnosis (#47)"
```

---

## Task 12: CLI wiring (#10)

**Files:**
- Create: `cctx/cli.py`
- Create: `tests/test_cli.py`
- Modify: `pyproject.toml` (add `[project.scripts]` entry)

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

Create `tests/test_cli.py`:

```python
"""Tests for cctx/cli.py — autopsy command."""
from __future__ import annotations

import json
from pathlib import Path

import pytest
from click.testing import CliRunner


@pytest.fixture
def runner():
    return CliRunner()


@pytest.fixture
def session_jsonl(tmp_path):
    """Minimal valid session JSONL."""
    session_id = "test-sess-01"
    line = {
        "type": "user",
        "uuid": f"{session_id}-u1",
        "parentUuid": None,
        "isSidechain": False,
        "timestamp": "2026-05-14T10:00:00.000Z",
        "sessionId": session_id,
        "version": "2.1.138",
        "cwd": "/Users/test/Projects/demo",
        "gitBranch": "main",
        "userType": "external",
        "entrypoint": "cli",
        "message": {"role": "user", "content": "hello"},
    }
    path = tmp_path / f"{session_id}.jsonl"
    path.write_text(json.dumps(line) + "\n")
    return path


def test_autopsy_help(runner):
    from cctx.cli import cli

    result = runner.invoke(cli, ["autopsy", "--help"])
    assert result.exit_code == 0
    assert "autopsy" in result.output.lower() or "session" in result.output.lower()


def test_autopsy_runs_on_session_file(runner, session_jsonl):
    from cctx.cli import cli

    result = runner.invoke(cli, ["autopsy", str(session_jsonl)], catch_exceptions=False)
    assert result.exit_code == 0
    # Should show session id or "No findings"
    assert "test-sess-01" in result.output or "No findings" in result.output


def test_autopsy_missing_file(runner, tmp_path):
    from cctx.cli import cli

    result = runner.invoke(cli, ["autopsy", str(tmp_path / "nonexistent.jsonl")])
    assert result.exit_code != 0


def test_cli_entrypoint_exists(runner):
    from cctx.cli import cli

    result = runner.invoke(cli, ["--help"])
    assert result.exit_code == 0
```

- [ ] **Step 2: Run to confirm failure**

```bash
CCTX_OFFLINE=1 pytest tests/test_cli.py -v
```

Expected: `ModuleNotFoundError` for `cctx.cli`.

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

Create `cctx/cli.py`:

```python
"""cctx CLI — click + rich-click entry point.

Commands:
  cctx autopsy <session>           Single-session diagnosis
  cctx autopsy <project> --since   Cross-session aggregation
"""
from __future__ import annotations

from datetime import timedelta
from pathlib import Path

import rich_click as click

from cctx import diagnostician
from cctx.diagnostician import aggregate
from cctx.models import AggregateReport
from cctx.parsers.claude_code import parse_session
from cctx.recommender import claude_md, evidence as evidence_mod
from cctx.renderers.terminal import render_aggregate, render_diagnosis

click.rich_click.USE_RICH_MARKUP = True
click.rich_click.SHOW_ARGUMENTS = True


@click.group()
def cli() -> None:
    """cctx — find out why your Claude Code session went sideways."""


@cli.command()
@click.argument("target", type=click.Path(exists=True, path_type=Path))
@click.option(
    "--since",
    default=None,
    metavar="DAYS",
    type=int,
    help="Cross-session mode: analyse all sessions modified within the last N days.",
)
def autopsy(target: Path, since: int | None) -> None:
    """Diagnose a session or project directory.

    TARGET is a session JSONL file for single-session diagnosis,
    or a project directory for --since cross-session aggregation.
    """
    if since is not None:
        # Cross-session path
        project_dir = target if target.is_dir() else target.parent
        window = timedelta(days=since)
        diagnoses = aggregate.run(project_dir, window=window)
        ev = evidence_mod.accumulate(diagnoses)
        patches = claude_md.generate_from_evidence(ev)
        report = AggregateReport(
            window=window,
            sessions_analysed=len(diagnoses),
            sessions_with_findings=sum(1 for d in diagnoses if d.findings),
            total_cost_usd=sum(d.total_cost_usd for d in diagnoses),
            waste_cost_usd=sum(d.waste_cost_usd for d in diagnoses),
            by_kind=ev,
            patches=patches,
        )
        render_aggregate(report)
    else:
        # Single-session path
        if target.is_dir():
            raise click.UsageError(
                "TARGET is a directory. Use --since N for cross-session mode, "
                "or pass a .jsonl file directly."
            )
        trace = parse_session(target)
        diagnosis = diagnostician.run(trace)
        diagnosis = claude_md.generate(diagnosis)
        render_diagnosis(diagnosis)
```

- [ ] **Step 4: Add CLI entry point to pyproject.toml**

In `pyproject.toml`, add after the `[tool.hatch.build.targets.wheel]` section:

```toml
[project.scripts]
cctx = "cctx.cli:cli"
```

- [ ] **Step 5: Reinstall to register the entry point**

```bash
pip install -e ".[dev]"
```

- [ ] **Step 6: Smoke-test the CLI**

```bash
cctx --help
cctx autopsy --help
```

Expected: rich-formatted help text appears.

- [ ] **Step 7: Run CLI tests**

```bash
CCTX_OFFLINE=1 pytest tests/test_cli.py -v
```

Expected: all pass.

- [ ] **Step 8: Run full suite**

```bash
CCTX_OFFLINE=1 pytest -q
```

Expected: all pass, no regressions.

- [ ] **Step 9: Ruff check**

```bash
ruff check cctx tests
```

Fix any issues before committing.

- [ ] **Step 10: Commit**

```bash
git add cctx/cli.py tests/test_cli.py pyproject.toml
git commit -m "feat(cli): autopsy command — single-session and --since cross-session (#10)"
```

---

## Final verification

- [ ] **Run full test suite one last time**

```bash
CCTX_OFFLINE=1 pytest -v
```

Expected: all tests pass.

- [ ] **Run ruff across all files**

```bash
ruff check cctx tests
```

Expected: no errors.

- [ ] **Smoke-test the autopsy command on a real session**

```bash
# Find a real session in ~/.claude/projects/
ls ~/.claude/projects/
# Pick a session JSONL and run autopsy
cctx autopsy ~/.claude/projects/<your-project>/<session-id>.jsonl
```

Expected: formatted output with cost line and any detected findings.
