# `--group-by` Implementation Plan

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

**Goal:** Add `--group-by` flag that interleaves multi-file results by time buckets and/or project grouping using a memory-efficient two-pass file cursor algorithm.

**Architecture:** New `grouping.py` module handles all grouping logic: parsing the `--group-by` spec, scouting files (pass 1), and rendering with interleaving (pass 2). The CLI wires it in for directory mode and search-stream mode when `--group-by` is set. Existing code paths remain untouched when `--group-by` is absent.

**Tech Stack:** Python 3.10+, Pydantic 2.0+, pytest

**Spec:** `docs/superpowers/specs/2026-03-18-group-by-design.md`

---

### Task 1: GroupByConfig + parse_group_by_spec

**Files:**
- Create: `src/claude_stream/grouping.py`
- Create: `tests/test_grouping_parse.py`

- [ ] **Step 1: Write failing tests for GroupByConfig parsing**

```python
"""Tests for --group-by spec parsing."""

import pytest

from claude_stream.grouping import parse_group_by_spec
from claude_stream.models import GroupByConfig


class TestParseGroupBySpec:
    def test_project_only(self):
        result = parse_group_by_spec("project")
        assert result.by_project is True
        assert result.time_format is None
        assert result.project_first is True

    def test_time_only(self):
        result = parse_group_by_spec("time:%Y%m%d%H")
        assert result.by_project is False
        assert result.time_format == "%Y%m%d%H"

    def test_project_then_time(self):
        result = parse_group_by_spec("project,time:%Y%m%d")
        assert result.by_project is True
        assert result.time_format == "%Y%m%d"
        assert result.project_first is True

    def test_time_then_project_raises(self):
        """time-then-project ordering is not yet supported in v0.4.0."""
        with pytest.raises(ValueError, match="not yet supported"):
            parse_group_by_spec("time:%H,project")

    def test_duplicate_project_deduplicated(self):
        result = parse_group_by_spec("project,project")
        assert result.by_project is True
        assert result.time_format is None

    def test_invalid_key_raises(self):
        with pytest.raises(ValueError, match="invalid group-by key"):
            parse_group_by_spec("foo")

    def test_empty_time_pattern_raises(self):
        with pytest.raises(ValueError, match="invalid group-by key"):
            parse_group_by_spec("time:")

    def test_multiple_time_keys_raises(self):
        with pytest.raises(ValueError, match="only one time:"):
            parse_group_by_spec("time:%H,time:%Y")

    def test_pure_literal_strftime_raises(self):
        """A pattern that produces no formatting (e.g., just 'hello') is invalid."""
        with pytest.raises(ValueError, match="invalid strftime"):
            parse_group_by_spec("time:hello")
```

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

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/test_grouping_parse.py -v`
Expected: ImportError — `claude_stream.grouping` does not exist yet.

- [ ] **Step 3: Add GroupByConfig to models.py**

In `src/claude_stream/models.py`, add `GroupByConfig` just before the `RenderConfig` class (around line 144). It's defined here to avoid circular imports since `grouping.py` also imports from `models.py`:

```python
@dataclass
class GroupByConfig:
    """Parsed --group-by configuration."""

    by_project: bool = False
    time_format: str | None = None
    project_first: bool = True
```

Also add `group_by: GroupByConfig | None = None` to `RenderConfig`.

- [ ] **Step 4: Write parse_group_by_spec in grouping.py**

Create `src/claude_stream/grouping.py`:

```python
"""Grouping logic for multi-file rendering.

This module handles --group-by parsing, file scouting (pass 1),
and interleaved rendering (pass 2).
"""

from __future__ import annotations

from datetime import datetime

from .models import GroupByConfig


def parse_group_by_spec(spec: str) -> GroupByConfig:
    """Parse a --group-by spec string into GroupByConfig.

    Args:
        spec: Comma-separated grouping keys (e.g., "project,time:%Y%m%d%H")

    Returns:
        Parsed GroupByConfig.

    Raises:
        ValueError: If the spec is invalid.
    """
    config = GroupByConfig()
    seen_project = False
    seen_time = False
    project_index = -1
    time_index = -1

    keys = [k.strip() for k in spec.split(",")]

    for i, key in enumerate(keys):
        if key == "project":
            if not seen_project:
                seen_project = True
                project_index = i
            config.by_project = True

        elif key.startswith("time:"):
            if seen_time:
                raise ValueError("only one time: group-by key is allowed")
            pattern = key[5:]  # strip "time:" prefix
            if not pattern:
                raise ValueError(
                    f"invalid group-by key: {key!r}. "
                    "Expected 'project' or 'time:<strftime>'"
                )
            # Validate strftime pattern: must contain at least one
            # directive that actually formats (output != input).
            # CPython on Linux silently passes unknown directives like %Q.
            test_result = datetime.now().strftime(pattern)
            if test_result == pattern:
                raise ValueError(
                    f"invalid strftime pattern in group-by: {pattern!r}"
                )
            seen_time = True
            time_index = i
            config.time_format = pattern

        else:
            raise ValueError(
                f"invalid group-by key: {key!r}. "
                "Expected 'project' or 'time:<strftime>'"
            )

    # Determine ordering
    if seen_project and seen_time:
        config.project_first = project_index < time_index
        if not config.project_first:
            raise ValueError(
                "time-then-project ordering is not yet supported. "
                "Use 'project,time:<fmt>' instead."
            )

    return config
```

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

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/test_grouping_parse.py -v`
Expected: All tests PASS.

- [ ] **Step 6: Commit**

```bash
git add src/claude_stream/models.py src/claude_stream/grouping.py tests/test_grouping_parse.py
git commit -m "feat: add GroupByConfig and parse_group_by_spec"
```

---

### Task 2: FileHandle + Scout Pass

**Files:**
- Modify: `src/claude_stream/grouping.py`
- Create: `tests/test_grouping_scout.py`

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

```python
"""Tests for file scouting (pass 1)."""

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

from claude_stream.grouping import FileHandle, scout_files
from claude_stream.models import RenderConfig


def _write_jsonl(path: Path, messages: list[dict]) -> Path:
    """Write messages as JSONL to a file."""
    path.write_text("".join(json.dumps(m) + "\n" for m in messages))
    return path


class TestScoutFiles:
    def test_finds_files_with_no_time_filter(self, tmp_path):
        f = _write_jsonl(
            tmp_path / "session.jsonl",
            [{"type": "user", "timestamp": "2026-03-17T14:00:00Z", "message": {"content": "hi"}}],
        )
        config = RenderConfig()
        handles = scout_files([f], config)
        assert len(handles) == 1
        assert handles[0].path == f
        assert handles[0].offset == 0

    def test_skips_to_after_offset(self, tmp_path):
        msgs = [
            {"type": "user", "timestamp": "2026-03-17T10:00:00Z", "message": {"content": "old"}},
            {"type": "user", "timestamp": "2026-03-17T14:00:00Z", "message": {"content": "new"}},
        ]
        f = _write_jsonl(tmp_path / "session.jsonl", msgs)
        config = RenderConfig(after=datetime(2026, 3, 17, 12, 0, 0, tzinfo=timezone.utc))
        handles = scout_files([f], config)
        assert len(handles) == 1
        # Offset should be past the first line
        assert handles[0].offset > 0

    def test_discards_file_with_no_matching_messages(self, tmp_path):
        f = _write_jsonl(
            tmp_path / "session.jsonl",
            [{"type": "user", "timestamp": "2026-03-17T10:00:00Z", "message": {"content": "old"}}],
        )
        config = RenderConfig(after=datetime(2026, 3, 17, 12, 0, 0, tzinfo=timezone.utc))
        handles = scout_files([f], config)
        assert len(handles) == 0

    def test_extracts_project_name(self, tmp_path):
        project_dir = tmp_path / "-home-guy-myproject"
        project_dir.mkdir()
        f = _write_jsonl(
            project_dir / "session.jsonl",
            [{"type": "user", "timestamp": "2026-03-17T14:00:00Z", "message": {"content": "hi"}}],
        )
        config = RenderConfig()
        handles = scout_files([f], config)
        assert handles[0].project == "-home-guy-myproject"

    def test_tail_lines(self, tmp_path):
        msgs = [
            {"type": "user", "timestamp": f"2026-03-17T{h:02d}:00:00Z", "message": {"content": f"msg{h}"}}
            for h in range(10, 15)
        ]
        f = _write_jsonl(tmp_path / "session.jsonl", msgs)
        config = RenderConfig()
        handles = scout_files([f], config, tail_lines=2)
        assert len(handles) == 1
        # Should start at the 4th message (index 3, hour 13)
        assert handles[0].offset > 0
```

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

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/test_grouping_scout.py -v`

- [ ] **Step 3: Implement FileHandle and scout_files**

Add to `src/claude_stream/grouping.py`:

```python
import json
import sys
from datetime import timezone
from pathlib import Path
from typing import TextIO

from .models import RenderConfig, GroupByConfig


@dataclass
class FileHandle:
    """A JSONL file with a starting byte offset and project name."""

    path: Path
    offset: int = 0
    project: str = ""


def _parse_timestamp(ts_str: str) -> datetime | None:
    """Parse an ISO 8601 timestamp string to a timezone-aware datetime."""
    if not ts_str:
        return None
    try:
        dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    except (ValueError, OSError):
        return None


def _extract_project(path: Path) -> str:
    """Extract project name from a JSONL file path.

    For files under ~/.claude/projects/<project>/, returns <project>.
    For other files, returns the immediate parent directory name.
    """
    return path.parent.name


def scout_files(
    jsonl_files: list[Path],
    config: RenderConfig,
    tail_lines: int = 0,
) -> list[FileHandle]:
    """Pass 1: Scout files to find starting offsets.

    For each file, finds the byte offset where rendering should begin,
    based on --after/--before time filters and -n tail lines.
    Discards files with no matching messages.

    Args:
        jsonl_files: List of JSONL file paths to scout.
        config: Render configuration (uses before/after for time filtering).
        tail_lines: If > 0, start from the last N lines of each file.

    Returns:
        List of FileHandle objects for files that have matching content.
    """
    has_time_filter = config.before is not None or config.after is not None
    handles: list[FileHandle] = []

    for path in jsonl_files:
        try:
            offset = _scout_single_file(path, config, has_time_filter, tail_lines)
            if offset is not None:
                handles.append(
                    FileHandle(path=path, offset=offset, project=_extract_project(path))
                )
        except (IOError, OSError) as e:
            print(f"warning: cannot read {path}: {e}", file=sys.stderr)

    return handles


def _scout_single_file(
    path: Path,
    config: RenderConfig,
    has_time_filter: bool,
    tail_lines: int,
) -> int | None:
    """Scout a single file, returning the byte offset to start from, or None to skip.

    The offset is max(time_filter_offset, tail_lines_offset).
    """
    time_offset: int = 0
    tail_offset: int = 0

    with open(path, "r") as f:
        if has_time_filter:
            # Scan forward to find first in-range message
            found = False
            while True:
                line_start = f.tell()
                line = f.readline()
                if not line:
                    break  # EOF
                line = line.strip()
                if not line:
                    continue
                try:
                    data = json.loads(line)
                    ts = _parse_timestamp(data.get("timestamp", ""))
                    if ts is None:
                        continue  # No timestamp, keep scanning
                    if config.after and ts < config.after:
                        continue  # Before the --after cutoff
                    if config.before and ts > config.before:
                        break  # Past the --before cutoff, stop
                    # This message is in range
                    time_offset = line_start
                    found = True
                    break
                except json.JSONDecodeError:
                    continue

            if not found:
                return None  # No matching messages

        if tail_lines > 0:
            # Count total lines and compute offset for last N
            f.seek(0)
            all_positions: list[int] = []
            while True:
                pos = f.tell()
                line = f.readline()
                if not line:
                    break
                if line.strip():
                    all_positions.append(pos)

            if not all_positions:
                return None

            start_idx = max(0, len(all_positions) - tail_lines)
            tail_offset = all_positions[start_idx]

    # Use the later of the two offsets
    offset = max(time_offset, tail_offset)

    # If no time filter, check file isn't empty
    if not has_time_filter and tail_lines <= 0:
        if path.stat().st_size == 0:
            return None

    return offset
```

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

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/test_grouping_scout.py -v`
Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/claude_stream/grouping.py tests/test_grouping_scout.py
git commit -m "feat: add FileHandle and scout_files for pass 1"
```

---

### Task 3: Bucket Key Computation + Render Utilities

**Files:**
- Modify: `src/claude_stream/grouping.py`
- Create: `tests/test_grouping_bucket.py`

- [ ] **Step 1: Write failing tests for bucket key computation**

```python
"""Tests for bucket key computation."""

from datetime import datetime, timezone

from claude_stream.grouping import compute_bucket_key


class TestComputeBucketKey:
    def test_hourly(self):
        dt = datetime(2026, 3, 17, 14, 23, 5, tzinfo=timezone.utc)
        # Result is in local time, so use astimezone for expected
        local_dt = dt.astimezone()
        expected = local_dt.strftime("%Y%m%d%H")
        assert compute_bucket_key(dt, "%Y%m%d%H") == expected

    def test_daily(self):
        dt = datetime(2026, 3, 17, 14, 23, 5, tzinfo=timezone.utc)
        local_dt = dt.astimezone()
        expected = local_dt.strftime("%Y%m%d")
        assert compute_bucket_key(dt, "%Y%m%d") == expected

    def test_hour_of_day(self):
        dt = datetime(2026, 3, 17, 14, 23, 5, tzinfo=timezone.utc)
        local_dt = dt.astimezone()
        expected = local_dt.strftime("%H")
        assert compute_bucket_key(dt, "%H") == expected
```

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

- [ ] **Step 3: Implement compute_bucket_key**

Add to `src/claude_stream/grouping.py`:

```python
def compute_bucket_key(dt: datetime, time_format: str) -> str:
    """Compute the bucket key for a datetime using the strftime pattern.

    Converts to local timezone before formatting, so bucket boundaries
    align with the user's local time.
    """
    return dt.astimezone().strftime(time_format)
```

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

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/test_grouping_bucket.py -v`

- [ ] **Step 5: Commit**

```bash
git add src/claude_stream/grouping.py tests/test_grouping_bucket.py
git commit -m "feat: add compute_bucket_key for time-based grouping"
```

---

### Task 4: Render Pass — Project-Only Grouping

**Files:**
- Modify: `src/claude_stream/grouping.py`
- Create: `tests/test_grouping_render.py`

- [ ] **Step 1: Write failing tests for project-only rendering**

```python
"""Tests for grouped rendering (pass 2)."""

import json
from pathlib import Path

from claude_stream.grouping import (
    FileHandle,
    GroupByConfig,
    render_grouped,
)
from claude_stream.formatters import PlainFormatter
from claude_stream.models import RenderConfig


def _write_jsonl(path: Path, messages: list[dict]) -> Path:
    path.write_text("".join(json.dumps(m) + "\n" for m in messages))
    return path


class TestRenderGroupedProjectOnly:
    def test_groups_files_by_project(self, tmp_path, capsys):
        proj_a = tmp_path / "proj-a"
        proj_b = tmp_path / "proj-b"
        proj_a.mkdir()
        proj_b.mkdir()

        _write_jsonl(proj_a / "s1.jsonl", [
            {"type": "user", "uuid": "1", "timestamp": "2026-03-17T14:00:00Z", "message": {"content": "proj-a msg"}},
        ])
        _write_jsonl(proj_b / "s2.jsonl", [
            {"type": "user", "uuid": "2", "timestamp": "2026-03-17T15:00:00Z", "message": {"content": "proj-b msg"}},
        ])

        handles = [
            FileHandle(path=proj_a / "s1.jsonl", offset=0, project="proj-a"),
            FileHandle(path=proj_b / "s2.jsonl", offset=0, project="proj-b"),
        ]
        config = RenderConfig(show_timestamps=False)
        group_config = GroupByConfig(by_project=True)
        formatter = PlainFormatter()

        render_grouped(handles, config, group_config, formatter)

        out = capsys.readouterr().out
        # Both projects should appear
        assert "proj-a" in out
        assert "proj-b" in out
        assert "proj-a msg" in out
        assert "proj-b msg" in out
        # Project A should appear before Project B (sorted by name or mtime)
        assert out.index("proj-a") < out.index("proj-b")


class TestRenderGroupedNoGrouping:
    def test_renders_files_sequentially(self, tmp_path, capsys):
        _write_jsonl(tmp_path / "s1.jsonl", [
            {"type": "user", "uuid": "1", "timestamp": "2026-03-17T14:00:00Z", "message": {"content": "file1 msg"}},
        ])
        _write_jsonl(tmp_path / "s2.jsonl", [
            {"type": "user", "uuid": "2", "timestamp": "2026-03-17T15:00:00Z", "message": {"content": "file2 msg"}},
        ])

        handles = [
            FileHandle(path=tmp_path / "s1.jsonl", offset=0, project="proj"),
            FileHandle(path=tmp_path / "s2.jsonl", offset=0, project="proj"),
        ]
        config = RenderConfig(show_timestamps=False)
        group_config = GroupByConfig()  # No grouping
        formatter = PlainFormatter()

        render_grouped(handles, config, group_config, formatter)

        out = capsys.readouterr().out
        assert "file1 msg" in out
        assert "file2 msg" in out
```

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

- [ ] **Step 3: Implement render_grouped (project-only and no-grouping paths)**

Add to `src/claude_stream/grouping.py`:

```python
from itertools import groupby as itertools_groupby

from .blocks import DividerBlock, HeaderBlock, Style
from .formatters import Formatter
from .stream import process_stream, should_show_message


def render_grouped(
    handles: list[FileHandle],
    config: RenderConfig,
    group_config: GroupByConfig,
    formatter: Formatter,
) -> None:
    """Pass 2: Render files with grouping.

    Routes to the appropriate rendering strategy based on the GroupByConfig.
    Callers must pre-scout files via scout_files() which handles tail_lines
    and time-filter offsets.
    """
    if group_config.time_format is not None:
        _render_time_interleaved(handles, config, group_config, formatter)
    elif group_config.by_project:
        _render_project_grouped(handles, config, formatter)
    else:
        _render_sequential(handles, config, formatter)


def _render_sequential(
    handles: list[FileHandle],
    config: RenderConfig,
    formatter: Formatter,
) -> None:
    """Render files sequentially with file headers (current behavior)."""
    show_headers = len(handles) > 1

    for handle in handles:
        if show_headers:
            print(
                formatter.format(
                    [
                        DividerBlock(char="─", width=60),
                        HeaderBlock(
                            text=str(handle.path),
                            icon="📄",
                            level=2,
                            styles={Style.INFO},
                        ),
                    ]
                )
            )
        with open(handle.path, "r") as f:
            f.seek(handle.offset)
            process_stream(f, config, formatter)


def _render_project_grouped(
    handles: list[FileHandle],
    config: RenderConfig,
    formatter: Formatter,
) -> None:
    """Render files grouped by project, each file rendered start-to-finish."""
    # Sort handles by project name
    sorted_handles = sorted(handles, key=lambda h: h.project)

    for project, group in itertools_groupby(sorted_handles, key=lambda h: h.project):
        # Print project header
        print(
            formatter.format(
                [
                    DividerBlock(char="─", width=60),
                    HeaderBlock(
                        text=f"Project: {project}",
                        icon="──",
                        level=2,
                        styles={Style.SYSTEM},
                    ),
                ]
            )
        )

        for handle in group:
            print(
                formatter.format(
                    [
                        HeaderBlock(
                            text=str(handle.path),
                            icon="📄",
                            level=2,
                            styles={Style.INFO},
                        ),
                    ]
                )
            )
            with open(handle.path, "r") as f:
                f.seek(handle.offset)
                process_stream(f, config, formatter)
```

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

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/test_grouping_render.py -v`

- [ ] **Step 5: Commit**

```bash
git add src/claude_stream/grouping.py tests/test_grouping_render.py
git commit -m "feat: add render_grouped with project-only and sequential paths"
```

---

### Task 5: Render Pass — Time Interleaving

**Files:**
- Modify: `src/claude_stream/grouping.py`
- Modify: `tests/test_grouping_render.py`

- [ ] **Step 1: Write failing tests for time-interleaved rendering**

Add to `tests/test_grouping_render.py`:

```python
class TestRenderGroupedTimeInterleaved:
    def test_interleaves_by_hourly_bucket(self, tmp_path, capsys):
        """Two files with overlapping hours get interleaved."""
        _write_jsonl(tmp_path / "a.jsonl", [
            {"type": "user", "uuid": "a1", "timestamp": "2026-03-17T14:01:00Z", "message": {"content": "A hour14"}},
            {"type": "user", "uuid": "a2", "timestamp": "2026-03-17T14:15:00Z", "message": {"content": "A hour14 again"}},
        ])
        _write_jsonl(tmp_path / "b.jsonl", [
            {"type": "user", "uuid": "b1", "timestamp": "2026-03-17T14:02:00Z", "message": {"content": "B hour14"}},
            {"type": "user", "uuid": "b2", "timestamp": "2026-03-17T15:05:00Z", "message": {"content": "B hour15"}},
        ])

        handles = [
            FileHandle(path=tmp_path / "a.jsonl", offset=0, project="proj"),
            FileHandle(path=tmp_path / "b.jsonl", offset=0, project="proj"),
        ]
        config = RenderConfig(show_timestamps=False)
        group_config = GroupByConfig(time_format="%Y%m%d%H")
        formatter = PlainFormatter()

        render_grouped(handles, config, group_config, formatter)

        out = capsys.readouterr().out
        # All messages should appear
        assert "A hour14" in out
        assert "A hour14 again" in out
        assert "B hour14" in out
        assert "B hour15" in out
        # File A's hour-14 messages should come before File B's hour-14
        # (A starts at :01, B starts at :02)
        a_14_pos = out.index("A hour14")
        b_14_pos = out.index("B hour14")
        b_15_pos = out.index("B hour15")
        assert a_14_pos < b_14_pos  # A before B in same bucket
        assert b_14_pos < b_15_pos  # hour14 before hour15

    def test_single_file_still_shows_bucket_headers(self, tmp_path, capsys):
        """A single file with time grouping shows bucket headers."""
        _write_jsonl(tmp_path / "a.jsonl", [
            {"type": "user", "uuid": "a1", "timestamp": "2026-03-17T14:01:00Z", "message": {"content": "hour14"}},
            {"type": "user", "uuid": "a2", "timestamp": "2026-03-17T15:05:00Z", "message": {"content": "hour15"}},
        ])

        handles = [
            FileHandle(path=tmp_path / "a.jsonl", offset=0, project="proj"),
        ]
        config = RenderConfig(show_timestamps=False)
        group_config = GroupByConfig(time_format="%Y%m%d%H")
        formatter = PlainFormatter()

        render_grouped(handles, config, group_config, formatter)

        out = capsys.readouterr().out
        assert "hour14" in out
        assert "hour15" in out
        # Should have bucket-related file headers
        assert "a.jsonl" in out

    def test_messages_without_timestamp_inherit_bucket(self, tmp_path, capsys):
        """Messages without timestamps inherit preceding message's bucket."""
        _write_jsonl(tmp_path / "a.jsonl", [
            {"type": "user", "uuid": "a1", "timestamp": "2026-03-17T14:01:00Z", "message": {"content": "timestamped"}},
            {"type": "user", "uuid": "a2", "message": {"content": "no timestamp"}},
        ])

        handles = [
            FileHandle(path=tmp_path / "a.jsonl", offset=0, project="proj"),
        ]
        config = RenderConfig(show_timestamps=False)
        group_config = GroupByConfig(time_format="%Y%m%d%H")
        formatter = PlainFormatter()

        render_grouped(handles, config, group_config, formatter)

        out = capsys.readouterr().out
        assert "timestamped" in out
        assert "no timestamp" in out
```

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

- [ ] **Step 3: Implement _render_time_interleaved**

Add to `src/claude_stream/grouping.py`:

```python
def _render_time_interleaved(
    handles: list[FileHandle],
    config: RenderConfig,
    group_config: GroupByConfig,
    formatter: Formatter,
) -> None:
    """Render files interleaved by time buckets.

    Opens all files, peeks at each to determine bucket key,
    then renders bucket-by-bucket.
    """
    if not handles:
        return

    if len(handles) > 500:
        print(
            f"warning: opening {len(handles)} files simultaneously",
            file=sys.stderr,
        )

    time_format = group_config.time_format
    assert time_format is not None

    # Group by project first if needed
    if group_config.by_project and group_config.project_first:
        sorted_handles = sorted(handles, key=lambda h: h.project)
        for project, group in itertools_groupby(sorted_handles, key=lambda h: h.project):
            print(
                formatter.format(
                    [
                        DividerBlock(char="─", width=60),
                        HeaderBlock(
                            text=f"Project: {project}",
                            icon="──",
                            level=2,
                            styles={Style.SYSTEM},
                        ),
                    ]
                )
            )
            project_handles = list(group)
            _interleave_by_time(project_handles, config, time_format, formatter)
    else:
        _interleave_by_time(handles, config, time_format, formatter)


@dataclass
class _OpenFile:
    """An open file with a peeked line and current bucket key."""

    handle: FileHandle
    file: TextIO
    peeked_line: str | None = None
    peeked_data: dict | None = None
    peeked_timestamp: datetime | None = None
    current_bucket: str | None = None
    exhausted: bool = False


def _interleave_by_time(
    handles: list[FileHandle],
    config: RenderConfig,
    time_format: str,
    formatter: Formatter,
) -> None:
    """Core interleaving loop for time-bucketed rendering."""
    open_files: list[_OpenFile] = []

    try:
        # Open all files and peek at first message
        for handle in handles:
            f = open(handle.path, "r")
            f.seek(handle.offset)
            of = _OpenFile(handle=handle, file=f)
            _peek_next(of, config, time_format)
            if not of.exhausted:
                open_files.append(of)

        # Process bucket by bucket
        while open_files:
            # Find the lowest bucket key
            active = [of for of in open_files if not of.exhausted]
            if not active:
                break

            current_bucket = min(of.current_bucket for of in active)

            # Collect files that have messages in this bucket
            bucket_files = [
                of for of in active if of.current_bucket == current_bucket
            ]

            # Sort by first message timestamp in this bucket
            bucket_files.sort(
                key=lambda of: of.peeked_timestamp or datetime.min.replace(tzinfo=timezone.utc)
            )

            # Render each file's messages for this bucket
            for of in bucket_files:
                # Print bucket header for this file
                print(
                    formatter.format(
                        [
                            HeaderBlock(
                                text=str(of.handle.path),
                                icon="📄",
                                level=2,
                                styles={Style.INFO},
                                suffix=f"[{current_bucket}]",
                            ),
                        ]
                    )
                )

                # Render messages until bucket changes
                while not of.exhausted and of.current_bucket == current_bucket:
                    # Process the peeked line
                    if of.peeked_data is not None:
                        msg = parse_message(of.peeked_data)
                        if should_show_message(msg, of.peeked_data, config):
                            blocks = msg.render(config)
                            output = formatter.format(blocks)
                            print(output)

                    # Peek at next
                    _peek_next(of, config, time_format)

            # Close and remove exhausted files
            for of in open_files:
                if of.exhausted:
                    of.file.close()
            open_files = [of for of in open_files if not of.exhausted]

    finally:
        # Close any remaining file handles
        for of in open_files:
            try:
                of.file.close()
            except Exception:
                pass


def _peek_next(of: _OpenFile, config: RenderConfig, time_format: str) -> None:
    """Read the next valid JSONL line from an open file and update bucket info."""
    while True:
        line = of.file.readline()
        if not line:
            of.exhausted = True
            of.peeked_line = None
            of.peeked_data = None
            of.peeked_timestamp = None
            return

        line = line.strip()
        if not line:
            continue

        try:
            data = json.loads(line)
        except json.JSONDecodeError:
            continue

        # Check --before cutoff
        ts = _parse_timestamp(data.get("timestamp", ""))
        if ts and config.before and ts > config.before:
            of.exhausted = True
            return

        of.peeked_line = line
        of.peeked_data = data
        of.peeked_timestamp = ts

        # Compute bucket key
        if ts:
            of.current_bucket = compute_bucket_key(ts, time_format)
        # else: inherit previous bucket (no change to current_bucket)

        return
```

Note: Import `parse_message` at the top of `grouping.py`:
```python
from .models import RenderConfig, parse_message
```

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

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/test_grouping_render.py -v`

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

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/ -v`

- [ ] **Step 6: Commit**

```bash
git add src/claude_stream/grouping.py tests/test_grouping_render.py
git commit -m "feat: add time-interleaved rendering for --group-by time:<strftime>"
```

---

### Task 6: RenderConfig.group_by + CLI Flag + Integration

**Files:**
- Modify: `src/claude_stream/models.py`
- Modify: `src/claude_stream/cli.py`
- Create: `tests/test_cli_group_by.py`

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

```python
"""Tests for --group-by CLI flag and integration."""

import json
import sys
from pathlib import Path
from unittest.mock import patch

from claude_stream.cli import main, parse_args
from conftest import create_session_file


class TestGroupByFlagParsing:
    def test_group_by_default_is_none(self):
        with patch.object(sys, "argv", ["claude-stream", "--latest"]):
            args = parse_args()
        assert args.group_by is None

    def test_group_by_project(self):
        with patch.object(sys, "argv", ["claude-stream", "--latest", "--group-by", "project"]):
            args = parse_args()
        assert args.group_by == "project"

    def test_group_by_time(self):
        with patch.object(sys, "argv", ["claude-stream", "--latest", "--group-by", "time:%Y%m%d%H"]):
            args = parse_args()
        assert args.group_by == "time:%Y%m%d%H"


class TestGroupByWithWatch:
    def test_group_by_with_watch_is_error(self, tmp_path, capsys):
        with patch.object(sys, "argv", [
            "claude-stream", "--watch", str(tmp_path), "--group-by", "project"
        ]):
            code = main()
        assert code != 0
        err = capsys.readouterr().err
        assert "cannot combine" in err.lower()


class TestGroupByDirectoryMode:
    def test_project_grouping_shows_headers(self, tmp_path, capsys):
        proj_a = tmp_path / "proj-a"
        proj_b = tmp_path / "proj-b"
        proj_a.mkdir()
        proj_b.mkdir()

        create_session_file(proj_a, "s1", [
            {"type": "user", "uuid": "1", "timestamp": "2026-03-17T14:00:00Z", "message": {"content": "from A"}},
        ])
        create_session_file(proj_b, "s2", [
            {"type": "user", "uuid": "2", "timestamp": "2026-03-17T15:00:00Z", "message": {"content": "from B"}},
        ])

        with patch.object(sys, "argv", [
            "claude-stream", "--group-by", "project", "--hide-timestamps", str(tmp_path),
        ]):
            code = main()
        assert code == 0
        out = capsys.readouterr().out
        assert "proj-a" in out
        assert "proj-b" in out
        assert "from A" in out
        assert "from B" in out

    def test_time_grouping_interleaves(self, tmp_path, capsys):
        create_session_file(tmp_path, "s1", [
            {"type": "user", "uuid": "a1", "timestamp": "2026-03-17T14:01:00Z", "message": {"content": "s1 hour14"}},
        ])
        create_session_file(tmp_path, "s2", [
            {"type": "user", "uuid": "b1", "timestamp": "2026-03-17T14:02:00Z", "message": {"content": "s2 hour14"}},
            {"type": "user", "uuid": "b2", "timestamp": "2026-03-17T15:05:00Z", "message": {"content": "s2 hour15"}},
        ])

        with patch.object(sys, "argv", [
            "claude-stream", "--group-by", "time:%Y%m%d%H", "--hide-timestamps", str(tmp_path),
        ]):
            code = main()
        assert code == 0
        out = capsys.readouterr().out
        assert "s1 hour14" in out
        assert "s2 hour14" in out
        assert "s2 hour15" in out
```

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

- [ ] **Step 3: Add group_by field to RenderConfig**

In `src/claude_stream/models.py`, add the import and field. First import `GroupByConfig` — to avoid circular imports, define it here or import conditionally. Since `GroupByConfig` is a simple dataclass in `grouping.py`, the cleanest approach is to define it in `models.py` and import it from there in `grouping.py`. However, to keep `grouping.py` self-contained per the spec, use a forward reference:

```python
# At the top of models.py, add:
from __future__ import annotations  # already present
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .grouping import GroupByConfig
```

Then add to `RenderConfig`:
```python
    group_by: GroupByConfig | None = None
```

To avoid circular imports (`grouping.py` imports from `models.py`), define `GroupByConfig` in `models.py` alongside `RenderConfig` (it has zero dependencies on `grouping.py`). Then `grouping.py` imports it from `models.py`.

Add to `models.py` just before `RenderConfig`:

```python
@dataclass
class GroupByConfig:
    """Parsed --group-by configuration."""
    by_project: bool = False
    time_format: str | None = None
    project_first: bool = True
```

Then add to `RenderConfig`:
```python
    group_by: GroupByConfig | None = None
```

In `grouping.py`, import `GroupByConfig` from `models.py` instead of defining it locally:
```python
from .models import RenderConfig, GroupByConfig, parse_message
```

**Important:** Remove the `GroupByConfig` class from `grouping.py` — it lives in `models.py` only. The `grouping.py` module re-exports it for convenience.

- [ ] **Step 4: Add --group-by flag to parse_args() in cli.py**

```python
# Grouping
parser.add_argument(
    "--group-by",
    dest="group_by",
    metavar="SPEC",
    help="Group results by 'project' and/or 'time:<strftime>' (comma-separated)",
)
```

Update the epilog examples:
```
    %(prog)s --after "today" . --group-by time:%%Y%%m%%d%%H  # Interleave by hour
    %(prog)s --search "error" --group-by project            # Group by project
```

- [ ] **Step 5: Wire --group-by into main()**

In `cli.py`, add import:
```python
from .grouping import parse_group_by_spec, scout_files, render_grouped
```

After the `--before`/`--after` parsing block, add:

```python
# Parse --group-by
group_config = None
if args.group_by:
    try:
        group_config = parse_group_by_spec(args.group_by)
        config.group_by = group_config
    except ValueError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
```

In the watch mode handler (`if args.watch:` block), add the mutual exclusivity check as the **first statement** inside the block, before `resolve_project_path` or any other logic:
```python
if args.watch:
    if args.group_by:
        print("error: cannot combine --group-by with --watch", file=sys.stderr)
        return 1
    # ... rest of existing watch handling unchanged
```

In the directory mode block (lines 487-530), replace the inline loop with:
```python
if file_path:
    resolved_path = resolve_project_path(file_path)
    if resolved_path.is_dir():
        jsonl_files = sorted(
            resolved_path.rglob("*.jsonl"),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )

        if group_config:
            # Use the grouping module
            handles = scout_files(jsonl_files, config, tail_lines=args.lines)
            render_grouped(handles, config, group_config, formatter)
        else:
            # Current behavior: inline loop (unchanged)
            for jf in jsonl_files:
                # ... existing code ...
```

Similarly, in the search `--stream` block, add grouping support:
```python
if not args.stream:
    for mf in matching_files:
        print(mf)
    return 0
else:
    if group_config:
        handles = scout_files(matching_files, config, tail_lines=args.lines)
        render_grouped(handles, config, group_config, formatter)
    else:
        # Current behavior
        for mf in matching_files:
            # ... existing code ...
    return 0
```

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

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/test_cli_group_by.py -v && pytest tests/ -v`

- [ ] **Step 7: Commit**

```bash
git add src/claude_stream/models.py src/claude_stream/cli.py tests/test_cli_group_by.py
git commit -m "feat: add --group-by flag with project and time grouping support"
```

---

### Task 7: Exports + Version Bump

**Files:**
- Modify: `src/claude_stream/__init__.py`
- Modify: `pyproject.toml`

- [ ] **Step 1: Update __init__.py exports**

Add to `src/claude_stream/__init__.py`:
```python
from .grouping import GroupByConfig, parse_group_by_spec, scout_files, render_grouped
```

Add to `__all__`:
```python
"GroupByConfig",
"parse_group_by_spec",
"scout_files",
"render_grouped",
```

- [ ] **Step 2: Bump version in pyproject.toml**

Change `version = "0.3.0"` to `version = "0.4.0"`.

- [ ] **Step 3: Run full test suite**

Run: `cd /home/guy/git/github.com/shitchell/claude-stream && pytest tests/ -v`

- [ ] **Step 4: Commit**

```bash
git add src/claude_stream/__init__.py pyproject.toml
git commit -m "chore: export grouping types, bump version to 0.4.0"
```

---

### Task 8: Integration Smoke Test

**Files:**
- No files created — manual verification

- [ ] **Step 1: Test --group-by project with a real directory**

Run: `python -m claude_stream --after "today" ~/.claude/projects/ --group-by project | head -40`
Expected: Messages grouped by project directory with project headers.

- [ ] **Step 2: Test --group-by time with real sessions**

Run: `python -m claude_stream --after "today" ~/.claude/projects/-home-guy-git-github-com-shitchell-claude-stream/ --group-by time:%Y%m%d%H | head -40`
Expected: Messages interleaved by hour with bucket headers showing strftime keys.

- [ ] **Step 3: Test --group-by with --search --stream**

Run: `python -m claude_stream --search "group-by" --stream --group-by project | head -30`
Expected: Matching sessions grouped by project.

- [ ] **Step 4: Test --group-by with --watch produces error**

Run: `python -m claude_stream --watch . --group-by project 2>&1`
Expected: Error message about mutual exclusivity.

- [ ] **Step 5: Test invalid --group-by spec**

Run: `python -m claude_stream --latest --group-by "invalid" 2>&1`
Expected: Error message about invalid group-by key.
