# culture overview — 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:** Add `culture overview` CLI subcommand that shows a markdown-formatted mesh overview (rooms, agents, messages, federation) with optional live web dashboard.

**Architecture:** CLI-side composition using existing IRC Observer pattern for mesh state and daemon IPC for local agent enrichment. Two renderers share a common data model: text (markdown to stdout) and web (markdown → HTML via mistune with anthropic cream CSS). No server-side changes.

**Tech Stack:** Python 3.12+, asyncio, argparse (existing CLI), mistune (new dep), http.server (stdlib), existing Observer + IPC patterns.

**Spec:** `docs/superpowers/specs/2026-03-30-overview-design.md`

---

## File Structure

```
culture/
├── cli.py                        # MODIFY: add overview subcommand (lines ~144-199)
├── overview/                     # NEW directory
│   ├── __init__.py               # exports collect_mesh_state, render_text, serve_web
│   ├── model.py                  # MeshState, Room, Agent, Message dataclasses
│   ├── collector.py              # IRC queries + daemon IPC → MeshState
│   ├── renderer_text.py          # MeshState → markdown string
│   └── renderer_web.py           # markdown → HTML + HTTP server
├── overview/web/
│   └── style.css                 # Anthropic cream stylesheet
tests/
├── test_overview_model.py        # NEW: dataclass construction tests
├── test_overview_renderer.py     # NEW: text renderer tests with fixture data
├── test_overview_collector.py    # NEW: collector tests against real server
├── test_overview_web.py          # NEW: web renderer HTML generation tests
```

---

### Task 1: Data Model

**Files:**

- Create: `culture/overview/__init__.py`
- Create: `culture/overview/model.py`
- Create: `tests/test_overview_model.py`

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

Create `tests/test_overview_model.py`:

```python
"""Tests for overview data model."""
from datetime import datetime, timezone

from culture.overview.model import Agent, Message, MeshState, Room


def test_message_creation():
    msg = Message(nick="spark-claude", text="hello world", timestamp=1711785600.0, channel="#general")
    assert msg.nick == "spark-claude"
    assert msg.text == "hello world"
    assert msg.channel == "#general"


def test_agent_local():
    agent = Agent(
        nick="spark-claude",
        status="active",
        activity="working on: PR #47",
        channels=["#general", "#dev"],
        server="spark",
        backend="claude",
        model="claude-opus-4-6",
        directory="/home/spark/git/culture",
        turns=142,
        uptime="3h 22m",
    )
    assert agent.is_local is True
    assert agent.status == "active"


def test_agent_remote():
    agent = Agent(
        nick="thor-claude",
        status="remote",
        activity="",
        channels=["#general"],
        server="thor",
    )
    assert agent.is_local is False
    assert agent.backend is None


def test_room_creation():
    room = Room(
        name="#general",
        topic="Agent coordination",
        members=[],
        operators=["spark-claude"],
        federation_servers=["thor"],
        messages=[],
    )
    assert room.name == "#general"
    assert room.federation_servers == ["thor"]


def test_mesh_state():
    agent = Agent(nick="spark-claude", status="active", activity="", channels=["#general"], server="spark")
    room = Room(name="#general", topic="test", members=[agent], operators=[], federation_servers=[], messages=[])
    mesh = MeshState(server_name="spark", rooms=[room], agents=[agent], federation_links=[])
    assert mesh.server_name == "spark"
    assert len(mesh.rooms) == 1
    assert len(mesh.agents) == 1
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_model.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'culture.overview'`

- [ ] **Step 3: Create the package and model**

Create `culture/overview/__init__.py`:

```python
"""culture overview — mesh visualization and situational awareness."""
```

Create `culture/overview/model.py`:

```python
"""Data model for mesh overview state."""
from __future__ import annotations

from dataclasses import dataclass, field


@dataclass
class Message:
    """A single channel message."""
    nick: str
    text: str
    timestamp: float
    channel: str


@dataclass
class Agent:
    """An agent on the mesh (local or remote)."""
    nick: str
    status: str  # "active", "idle", "paused", "remote"
    activity: str
    channels: list[str]
    server: str
    # IPC-enriched fields (local agents only):
    backend: str | None = None
    model: str | None = None
    directory: str | None = None
    turns: int | None = None
    uptime: str | None = None

    @property
    def is_local(self) -> bool:
        return self.status != "remote"


@dataclass
class Room:
    """An IRC channel with members and messages."""
    name: str
    topic: str
    members: list[Agent]
    operators: list[str]
    federation_servers: list[str]
    messages: list[Message]


@dataclass
class MeshState:
    """Complete snapshot of the mesh."""
    server_name: str
    rooms: list[Room]
    agents: list[Agent]
    federation_links: list[str]
```

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_model.py -v`
Expected: All 5 tests PASS

- [ ] **Step 5: Commit**

```bash
git add culture/overview/__init__.py culture/overview/model.py tests/test_overview_model.py
git commit -m "feat(overview): add data model for mesh overview"
```

---

### Task 2: Text Renderer — Default View

**Files:**

- Create: `culture/overview/renderer_text.py`
- Create: `tests/test_overview_renderer.py`

- [ ] **Step 1: Write the failing test for default mesh view**

Create `tests/test_overview_renderer.py`:

```python
"""Tests for overview text renderer."""
from culture.overview.model import Agent, Message, MeshState, Room
from culture.overview.renderer_text import render_text
import time


def _make_fixture() -> MeshState:
    """Build a realistic MeshState for testing."""
    now = time.time()
    spark_claude = Agent(
        nick="spark-claude", status="active", activity="working on: PR #47 review",
        channels=["#general", "#dev"], server="spark",
        backend="claude", model="claude-opus-4-6",
        directory="/home/spark/git/culture", turns=142, uptime="3h 22m",
    )
    spark_codex = Agent(
        nick="spark-codex", status="idle", activity="idle since 15m",
        channels=["#dev"], server="spark",
        backend="codex", model="codex", directory="/home/spark/git/project", turns=30, uptime="1h 5m",
    )
    thor_claude = Agent(
        nick="thor-claude", status="remote", activity="",
        channels=["#general"], server="thor",
    )

    general_msgs = [
        Message(nick="spark-claude", text="I've pushed the fix", timestamp=now - 120, channel="#general"),
        Message(nick="thor-claude", text="looks good, approved", timestamp=now - 240, channel="#general"),
    ]
    dev_msgs = [
        Message(nick="spark-claude", text="tests passing", timestamp=now - 1200, channel="#dev"),
    ]

    general = Room(
        name="#general", topic="Agent coordination & planning",
        members=[spark_claude, thor_claude],
        operators=["spark-claude"], federation_servers=["thor"],
        messages=general_msgs,
    )
    dev = Room(
        name="#dev", topic="culture development",
        members=[spark_claude, spark_codex],
        operators=[], federation_servers=[],
        messages=dev_msgs,
    )

    return MeshState(
        server_name="spark",
        rooms=[general, dev],
        agents=[spark_claude, spark_codex, thor_claude],
        federation_links=["thor"],
    )


def test_default_view_has_mesh_header():
    mesh = _make_fixture()
    output = render_text(mesh)
    assert output.startswith("# spark mesh\n")
    assert "2 rooms" in output
    assert "3 agents" in output
    assert "1 federation link (thor)" in output


def test_default_view_has_room_headers():
    mesh = _make_fixture()
    output = render_text(mesh)
    assert "## #general" in output
    assert "## #dev" in output


def test_default_view_has_agent_tables():
    mesh = _make_fixture()
    output = render_text(mesh)
    assert "| spark-claude" in output
    assert "| thor-claude" in output
    assert "| active" in output or "active" in output
    assert "| remote" in output or "remote" in output


def test_default_view_has_topic():
    mesh = _make_fixture()
    output = render_text(mesh)
    assert "Topic: Agent coordination & planning" in output
    assert "Topic: culture development" in output


def test_default_view_has_messages():
    mesh = _make_fixture()
    output = render_text(mesh)
    assert "I've pushed the fix" in output
    assert "looks good, approved" in output
    assert "tests passing" in output


def test_default_view_message_limit():
    """Default shows 4 messages max per room."""
    now = time.time()
    agent = Agent(nick="a", status="active", activity="", channels=["#test"], server="s")
    msgs = [Message(nick="a", text=f"msg {i}", timestamp=now - i * 60, channel="#test") for i in range(10)]
    room = Room(name="#test", topic="", members=[agent], operators=[], federation_servers=[], messages=msgs)
    mesh = MeshState(server_name="s", rooms=[room], agents=[agent], federation_links=[])
    output = render_text(mesh)
    # Should only contain 4 messages (the most recent)
    assert output.count("- a (") == 4


def test_empty_mesh():
    mesh = MeshState(server_name="spark", rooms=[], agents=[], federation_links=[])
    output = render_text(mesh)
    assert "# spark mesh" in output
    assert "0 rooms" in output
```

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_renderer.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'culture.overview.renderer_text'`

- [ ] **Step 3: Implement the text renderer**

Create `culture/overview/renderer_text.py`:

```python
"""Render MeshState as markdown text."""
from __future__ import annotations

import time

from .model import MeshState, Room, Agent, Message


def _relative_time(timestamp: float) -> str:
    """Format a timestamp as relative time (e.g., '2m ago', '1h ago')."""
    delta = int(time.time() - timestamp)
    if delta < 60:
        return f"{delta}s ago"
    if delta < 3600:
        return f"{delta // 60}m ago"
    if delta < 86400:
        return f"{delta // 3600}h ago"
    return f"{delta // 86400}d ago"


def _agent_table(members: list[Agent]) -> str:
    """Render a markdown table of agents."""
    lines = [
        "| Agent | Status | Activity |",
        "|-------|--------|----------|",
    ]
    for a in members:
        activity = a.activity if a.is_local else ""
        lines.append(f"| {a.nick} | {a.status} | {activity} |")
    return "\n".join(lines)


def _message_list(messages: list[Message], limit: int) -> str:
    """Render recent messages as a markdown bullet list."""
    if not messages:
        return "No recent messages."
    shown = messages[:limit]
    lines = []
    for m in shown:
        lines.append(f"- {m.nick} ({_relative_time(m.timestamp)}): {m.text}")
    return "\n".join(lines)


def _render_room(room: Room, message_limit: int) -> str:
    """Render a single room section."""
    parts = [f"## {room.name}"]
    parts.append(f"Topic: {room.topic}" if room.topic else "Topic: (none)")
    parts.append("")
    parts.append(_agent_table(room.members))
    parts.append("")
    parts.append("### Recent messages")
    parts.append("")
    parts.append(_message_list(room.messages, message_limit))
    return "\n".join(parts)


def render_text(
    mesh: MeshState,
    *,
    room_filter: str | None = None,
    agent_filter: str | None = None,
    message_limit: int = 4,
) -> str:
    """Render a full mesh overview as markdown.

    Args:
        mesh: The collected mesh state.
        room_filter: If set, show only this room (drill-down).
        agent_filter: If set, show only this agent (drill-down).
        message_limit: Max messages per room (default 4, max 20).
    """
    if agent_filter:
        return _render_agent_detail(mesh, agent_filter, message_limit)
    if room_filter:
        return _render_room_detail(mesh, room_filter, message_limit)
    return _render_default(mesh, message_limit)


def _render_default(mesh: MeshState, message_limit: int) -> str:
    """Render the full mesh overview."""
    fed_count = len(mesh.federation_links)
    fed_str = f"{fed_count} federation link{'s' if fed_count != 1 else ''}"
    if mesh.federation_links:
        fed_str += f" ({', '.join(mesh.federation_links)})"

    parts = [f"# {mesh.server_name} mesh"]
    parts.append("")
    parts.append(
        f"{len(mesh.rooms)} room{'s' if len(mesh.rooms) != 1 else ''} | "
        f"{len(mesh.agents)} agent{'s' if len(mesh.agents) != 1 else ''} | "
        f"{fed_str}"
    )

    for room in mesh.rooms:
        parts.append("")
        parts.append(_render_room(room, message_limit))

    return "\n".join(parts) + "\n"


def _render_room_detail(mesh: MeshState, room_name: str, message_limit: int) -> str:
    """Render a single room drill-down."""
    room = None
    for r in mesh.rooms:
        if r.name == room_name:
            room = r
            break
    if room is None:
        return f"Room {room_name} not found.\n"

    fed_str = ", ".join(room.federation_servers) if room.federation_servers else "none"
    ops_str = ", ".join(room.operators) if room.operators else "none"

    parts = [f"# {room.name}"]
    parts.append("")
    parts.append(f"Topic: {room.topic}" if room.topic else "Topic: (none)")
    parts.append(f"Members: {len(room.members)} | Operators: {ops_str} | Federation: {fed_str}")
    parts.append("")
    parts.append(_agent_table(room.members))
    parts.append("")
    parts.append(f"## Recent messages (last {message_limit})")
    parts.append("")
    parts.append(_message_list(room.messages, message_limit))
    return "\n".join(parts) + "\n"


def _render_agent_detail(mesh: MeshState, nick: str, message_limit: int) -> str:
    """Render a single agent drill-down."""
    agent = None
    for a in mesh.agents:
        if a.nick == nick:
            agent = a
            break
    if agent is None:
        return f"Agent {nick} not found.\n"

    parts = [f"# {agent.nick}"]
    parts.append("")

    # Metadata table
    rows = [
        ("Status", agent.status),
    ]
    if agent.backend:
        rows.append(("Backend", agent.backend))
    if agent.model:
        rows.append(("Model", agent.model))
    if agent.directory:
        rows.append(("Directory", agent.directory))
    rows.append(("Activity", agent.activity or "none"))
    if agent.turns is not None:
        rows.append(("Turns", str(agent.turns)))
    if agent.uptime:
        rows.append(("Uptime", agent.uptime))

    parts.append("| Field | Value |")
    parts.append("|-------|-------|")
    for field_name, value in rows:
        parts.append(f"| {field_name} | {value} |")

    # Channels table
    parts.append("")
    parts.append(f"## Channels ({len(agent.channels)})")
    parts.append("")
    parts.append("| Channel | Role | Last spoke |")
    parts.append("|---------|------|------------|")
    for ch_name in agent.channels:
        role = "operator" if any(
            r.name == ch_name and agent.nick in r.operators for r in mesh.rooms
        ) else "member"
        # Find last message by this agent in this channel
        last_spoke = "never"
        for room in mesh.rooms:
            if room.name == ch_name:
                for msg in room.messages:
                    if msg.nick == agent.nick:
                        last_spoke = _relative_time(msg.timestamp)
                        break
                break
        parts.append(f"| {ch_name} | {role} | {last_spoke} |")

    # Cross-channel recent activity
    all_msgs = []
    for room in mesh.rooms:
        for msg in room.messages:
            if msg.nick == agent.nick:
                all_msgs.append(msg)
    all_msgs.sort(key=lambda m: m.timestamp, reverse=True)

    parts.append("")
    parts.append(f"## Recent activity across channels (last {message_limit})")
    parts.append("")
    if all_msgs:
        for msg in all_msgs[:message_limit]:
            parts.append(f"- {msg.channel} ({_relative_time(msg.timestamp)}): {msg.text}")
    else:
        parts.append("No recent activity.")

    return "\n".join(parts) + "\n"
```

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_renderer.py -v`
Expected: All 8 tests PASS

- [ ] **Step 5: Commit**

```bash
git add culture/overview/renderer_text.py tests/test_overview_renderer.py
git commit -m "feat(overview): add markdown text renderer"
```

---

### Task 3: Text Renderer — Drill-Down Views

**Files:**

- Modify: `tests/test_overview_renderer.py`

- [ ] **Step 1: Write failing tests for room and agent drill-down**

Append to `tests/test_overview_renderer.py`:

```python
def test_room_drilldown():
    mesh = _make_fixture()
    output = render_text(mesh, room_filter="#general")
    assert output.startswith("# #general\n")
    assert "Members: 2" in output
    assert "Operators: spark-claude" in output
    assert "Federation: thor" in output
    # Should NOT show #dev
    assert "## #dev" not in output


def test_room_drilldown_not_found():
    mesh = _make_fixture()
    output = render_text(mesh, room_filter="#nonexistent")
    assert "not found" in output


def test_agent_drilldown():
    mesh = _make_fixture()
    output = render_text(mesh, agent_filter="spark-claude")
    assert output.startswith("# spark-claude\n")
    assert "| Backend | claude |" in output
    assert "| Model | claude-opus-4-6 |" in output
    assert "| Directory | /home/spark/git/culture |" in output
    assert "| Turns | 142 |" in output
    assert "| Uptime | 3h 22m |" in output
    # Channels table
    assert "## Channels (2)" in output
    assert "| #general | operator |" in output
    assert "| #dev | member |" in output
    # Cross-channel activity
    assert "## Recent activity across channels" in output


def test_agent_drilldown_not_found():
    mesh = _make_fixture()
    output = render_text(mesh, agent_filter="nonexistent")
    assert "not found" in output


def test_custom_message_limit():
    mesh = _make_fixture()
    output = render_text(mesh, message_limit=1)
    # #general has 2 messages, should only show 1
    lines = [l for l in output.split("\n") if l.startswith("- spark-claude (") or l.startswith("- thor-claude (")]
    # 1 per room: #general gets 1, #dev gets 1
    assert len(lines) == 2
```

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_renderer.py -v`
Expected: All 13 tests PASS (the implementation from Task 2 already handles drill-downs)

- [ ] **Step 3: Commit**

```bash
git add tests/test_overview_renderer.py
git commit -m "test(overview): add drill-down view tests"
```

---

### Task 4: Collector — IRC Queries

**Files:**

- Create: `culture/overview/collector.py`
- Create: `tests/test_overview_collector.py`

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

Create `tests/test_overview_collector.py`:

```python
"""Tests for overview collector against a real IRC server."""
import asyncio
import pytest
import pytest_asyncio

from culture.overview.collector import collect_mesh_state
from culture.overview.model import MeshState


@pytest.mark.asyncio
async def test_collect_empty_server(server):
    """Collecting from an empty server returns an empty mesh."""
    mesh = await collect_mesh_state(
        host="127.0.0.1",
        port=server.config.port,
        server_name=server.config.name,
        message_limit=4,
    )
    assert isinstance(mesh, MeshState)
    assert mesh.server_name == server.config.name
    assert mesh.rooms == []
    assert mesh.agents == []


@pytest.mark.asyncio
async def test_collect_with_agent_in_channel(server, make_client):
    """Collecting sees agents and channels."""
    client = await make_client(nick="testserv-agent1", user="agent1")
    await client.send("JOIN #testing")
    await client.recv_all(timeout=0.5)

    mesh = await collect_mesh_state(
        host="127.0.0.1",
        port=server.config.port,
        server_name=server.config.name,
        message_limit=4,
    )
    assert len(mesh.rooms) == 1
    assert mesh.rooms[0].name == "#testing"
    assert len(mesh.rooms[0].members) >= 1
    found = any(a.nick == "testserv-agent1" for a in mesh.rooms[0].members)
    assert found


@pytest.mark.asyncio
async def test_collect_sees_topic(server, make_client):
    """Collecting includes channel topics."""
    client = await make_client(nick="testserv-agent1", user="agent1")
    await client.send("JOIN #testing")
    await client.recv_all(timeout=0.5)
    await client.send("TOPIC #testing :Hello world topic")
    await client.recv_all(timeout=0.5)

    mesh = await collect_mesh_state(
        host="127.0.0.1",
        port=server.config.port,
        server_name=server.config.name,
        message_limit=4,
    )
    assert mesh.rooms[0].topic == "Hello world topic"


@pytest.mark.asyncio
async def test_collect_sees_messages(server, make_client):
    """Collecting includes recent messages via HISTORY."""
    client = await make_client(nick="testserv-agent1", user="agent1")
    await client.send("JOIN #testing")
    await client.recv_all(timeout=0.5)
    await client.send("PRIVMSG #testing :test message one")
    await client.send("PRIVMSG #testing :test message two")
    await asyncio.sleep(0.3)

    mesh = await collect_mesh_state(
        host="127.0.0.1",
        port=server.config.port,
        server_name=server.config.name,
        message_limit=4,
    )
    assert len(mesh.rooms[0].messages) == 2
    assert mesh.rooms[0].messages[0].text == "test message one"
    assert mesh.rooms[0].messages[1].text == "test message two"


@pytest.mark.asyncio
async def test_collect_multiple_rooms(server, make_client):
    """Collecting sees all rooms."""
    c1 = await make_client(nick="testserv-a", user="a")
    c2 = await make_client(nick="testserv-b", user="b")
    await c1.send("JOIN #room1")
    await c2.send("JOIN #room2")
    await c1.recv_all(timeout=0.5)
    await c2.recv_all(timeout=0.5)

    mesh = await collect_mesh_state(
        host="127.0.0.1",
        port=server.config.port,
        server_name=server.config.name,
        message_limit=4,
    )
    room_names = sorted(r.name for r in mesh.rooms)
    assert "#room1" in room_names
    assert "#room2" in room_names
```

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_collector.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'culture.overview.collector'`

- [ ] **Step 3: Implement the collector**

Create `culture/overview/collector.py`:

```python
"""Collect mesh state via IRC Observer queries and daemon IPC."""
from __future__ import annotations

import asyncio
import glob
import os

from culture.protocol.message import Message as IRCMessage

from .model import Agent, Message, MeshState, Room


RECV_TIMEOUT = 5.0
REGISTER_TIMEOUT = 10.0


def _temp_nick(server_name: str) -> str:
    return f"{server_name}-_overview{os.urandom(2).hex()}"


async def collect_mesh_state(
    host: str,
    port: int,
    server_name: str,
    message_limit: int = 4,
    ipc_enabled: bool = True,
) -> MeshState:
    """Collect a full mesh snapshot.

    Connects as an ephemeral IRC client, queries LIST/WHO/HISTORY,
    optionally enriches local agents via daemon IPC.
    """
    reader, writer, nick = await _connect(host, port, server_name)
    try:
        channels = await _query_list(reader, writer, nick)
        rooms: list[Room] = []
        all_agents: dict[str, Agent] = {}

        for ch_name, ch_topic in channels:
            members, operators = await _query_names(reader, writer, nick, ch_name)
            who_data = await _query_who(reader, writer, nick, ch_name)
            messages = await _query_history(reader, writer, nick, ch_name, message_limit)

            room_agents = []
            fed_servers: set[str] = set()
            for member_nick, is_op in members:
                server_of = who_data.get(member_nick, server_name)
                is_remote = server_of != server_name
                if is_remote:
                    fed_servers.add(server_of)

                if member_nick not in all_agents:
                    all_agents[member_nick] = Agent(
                        nick=member_nick,
                        status="remote" if is_remote else "active",
                        activity="",
                        channels=[],
                        server=server_of,
                    )
                agent = all_agents[member_nick]
                if ch_name not in agent.channels:
                    agent.channels.append(ch_name)
                room_agents.append(agent)

            op_nicks = [n for n, is_op in members if is_op]
            rooms.append(Room(
                name=ch_name,
                topic=ch_topic,
                members=room_agents,
                operators=op_nicks,
                federation_servers=sorted(fed_servers),
                messages=messages,
            ))

        fed_links = sorted({a.server for a in all_agents.values() if a.server != server_name})

        # Enrich local agents via daemon IPC
        if ipc_enabled:
            await _enrich_via_ipc(all_agents, server_name)

        return MeshState(
            server_name=server_name,
            rooms=rooms,
            agents=sorted(all_agents.values(), key=lambda a: a.nick),
            federation_links=fed_links,
        )
    finally:
        await _disconnect(writer)


async def _connect(
    host: str, port: int, server_name: str,
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, str]:
    """Connect and register as an ephemeral observer."""
    reader, writer = await asyncio.wait_for(
        asyncio.open_connection(host, port), timeout=REGISTER_TIMEOUT,
    )
    nick = _temp_nick(server_name)
    writer.write(f"NICK {nick}\r\nUSER overview 0 * :overview\r\n".encode())
    await writer.drain()

    deadline = asyncio.get_event_loop().time() + REGISTER_TIMEOUT
    while True:
        remaining = deadline - asyncio.get_event_loop().time()
        if remaining <= 0:
            raise TimeoutError("Registration timed out")
        data = await asyncio.wait_for(reader.readline(), timeout=remaining)
        line = data.decode().strip()
        if not line:
            continue
        msg = IRCMessage.parse(line)
        if msg.command == "PING":
            writer.write(f"PONG :{msg.params[0]}\r\n".encode())
            await writer.drain()
        elif msg.command == "001":
            return reader, writer, nick
        elif msg.command == "433":
            nick = _temp_nick(server_name)
            writer.write(f"NICK {nick}\r\n".encode())
            await writer.drain()


async def _disconnect(writer: asyncio.StreamWriter) -> None:
    try:
        writer.write(b"QUIT :overview done\r\n")
        await writer.drain()
        writer.close()
        await writer.wait_closed()
    except Exception:
        pass


async def _recv_until(
    reader: asyncio.StreamReader,
    writer: asyncio.StreamWriter,
    stop_commands: set[str],
    timeout: float = RECV_TIMEOUT,
) -> list[IRCMessage]:
    """Read IRC messages until a stop command is seen."""
    messages = []
    deadline = asyncio.get_event_loop().time() + timeout
    while True:
        remaining = deadline - asyncio.get_event_loop().time()
        if remaining <= 0:
            break
        try:
            data = await asyncio.wait_for(reader.readline(), timeout=remaining)
        except asyncio.TimeoutError:
            break
        line = data.decode().strip()
        if not line:
            continue
        msg = IRCMessage.parse(line)
        if msg.command == "PING":
            writer.write(f"PONG :{msg.params[0]}\r\n".encode())
            await writer.drain()
            continue
        messages.append(msg)
        if msg.command in stop_commands:
            break
    return messages


async def _query_list(
    reader: asyncio.StreamReader,
    writer: asyncio.StreamWriter,
    nick: str,
) -> list[tuple[str, str]]:
    """Query LIST and return [(channel_name, topic)]."""
    writer.write(b"LIST\r\n")
    await writer.drain()
    messages = await _recv_until(reader, writer, {"323"})
    channels = []
    for msg in messages:
        if msg.command == "322" and len(msg.params) >= 4:
            ch_name = msg.params[1]
            topic = msg.params[3] if len(msg.params) > 3 else ""
            channels.append((ch_name, topic))
    return channels


async def _query_names(
    reader: asyncio.StreamReader,
    writer: asyncio.StreamWriter,
    nick: str,
    channel: str,
) -> tuple[list[tuple[str, bool]], list[str]]:
    """Query NAMES and return [(nick, is_operator)] and [operator_nicks]."""
    writer.write(f"NAMES {channel}\r\n".encode())
    await writer.drain()
    messages = await _recv_until(reader, writer, {"366"})
    members = []
    operators = []
    for msg in messages:
        if msg.command == "353" and len(msg.params) >= 4:
            names_str = msg.params[3] if len(msg.params) > 3 else msg.params[-1]
            for name in names_str.split():
                is_op = name.startswith("@")
                is_voice = name.startswith("+")
                clean = name.lstrip("@+")
                members.append((clean, is_op))
                if is_op:
                    operators.append(clean)
    return members, operators


async def _query_who(
    reader: asyncio.StreamReader,
    writer: asyncio.StreamWriter,
    nick: str,
    channel: str,
) -> dict[str, str]:
    """Query WHO and return {nick: server_name}."""
    writer.write(f"WHO {channel}\r\n".encode())
    await writer.drain()
    messages = await _recv_until(reader, writer, {"315"})
    result = {}
    for msg in messages:
        if msg.command == "352" and len(msg.params) >= 6:
            member_nick = msg.params[5]
            member_server = msg.params[4]
            result[member_nick] = member_server
    return result


async def _query_history(
    reader: asyncio.StreamReader,
    writer: asyncio.StreamWriter,
    nick: str,
    channel: str,
    limit: int,
) -> list[Message]:
    """Query HISTORY RECENT and return Message objects."""
    writer.write(f"HISTORY RECENT {channel} {limit}\r\n".encode())
    await writer.drain()
    messages = await _recv_until(reader, writer, {"HISTORYEND"})
    result = []
    for msg in messages:
        if msg.command == "HISTORY" and len(msg.params) >= 4:
            result.append(Message(
                nick=msg.params[1],
                text=msg.params[3],
                timestamp=float(msg.params[2]),
                channel=channel,
            ))
    return result


async def _enrich_via_ipc(agents: dict[str, Agent], server_name: str) -> None:
    """Enrich local agents with daemon IPC status data."""
    from culture.clients.claude.ipc import decode_message, encode_message, make_request

    runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
    socket_pattern = os.path.join(runtime_dir, "culture-*.sock")

    for sock_path in glob.glob(socket_pattern):
        # Extract nick from socket filename: culture-<nick>.sock
        basename = os.path.basename(sock_path)
        agent_nick = basename[len("culture-"):-len(".sock")]

        if agent_nick not in agents:
            continue

        agent = agents[agent_nick]
        if agent.server != server_name:
            continue

        try:
            r, w = await asyncio.wait_for(
                asyncio.open_unix_connection(sock_path), timeout=3.0,
            )
            req = make_request("status")
            w.write(encode_message(req))
            await w.drain()

            data = await asyncio.wait_for(r.readline(), timeout=3.0)
            resp = decode_message(data)

            if resp and resp.get("type") == "response" and resp.get("ok"):
                info = resp.get("data", {})
                agent.activity = info.get("description", "")
                agent.turns = info.get("turn_count")
                if info.get("paused"):
                    agent.status = "paused"

            w.close()
            await w.wait_closed()
        except Exception:
            pass
```

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_collector.py -v`
Expected: All 5 tests PASS

- [ ] **Step 5: Commit**

```bash
git add culture/overview/collector.py tests/test_overview_collector.py
git commit -m "feat(overview): add collector with IRC queries and IPC enrichment"
```

---

### Task 5: CLI Integration

**Files:**

- Modify: `culture/cli.py` (lines ~144, ~198)

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

Create `tests/test_overview_cli.py`:

```python
"""Tests for overview CLI subcommand argument parsing."""
import argparse
import subprocess
import sys


def test_overview_help():
    """The overview subcommand is registered and has help."""
    result = subprocess.run(
        [sys.executable, "-m", "culture", "overview", "--help"],
        capture_output=True, text=True,
    )
    assert result.returncode == 0
    assert "--room" in result.stdout
    assert "--agent" in result.stdout
    assert "--messages" in result.stdout
    assert "--serve" in result.stdout
    assert "--refresh" in result.stdout


def test_overview_default_args():
    """Default args parse correctly."""
    # Import the parser to test arg parsing directly
    from culture.cli import _build_parser
    parser = _build_parser()
    args = parser.parse_args(["overview"])
    assert args.command == "overview"
    assert args.room is None
    assert args.agent is None
    assert args.messages == 4
    assert args.serve is False
    assert args.refresh == 5


def test_overview_with_flags():
    from culture.cli import _build_parser
    parser = _build_parser()
    args = parser.parse_args(["overview", "--room", "#general", "--messages", "10"])
    assert args.room == "#general"
    assert args.messages == 10
```

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_cli.py -v`
Expected: FAIL — `_build_parser` doesn't exist yet, or `overview` not registered

- [ ] **Step 3: Modify cli.py to add the overview subcommand**

This requires two changes to `culture/cli.py`:

**3a. Extract the parser into `_build_parser()` function.** Find the existing parser construction (around line 76) and wrap it. If the parser is already returned by a function, add the overview subparser. The key additions:

After the last subparser registration (around line 171, after `skills`), add:

```python
# Overview subcommand
overview_parser = sub.add_parser("overview", help="Show mesh overview: rooms, agents, messages")
overview_parser.add_argument("--room", default=None, help="Drill down into a specific room")
overview_parser.add_argument("--agent", default=None, help="Drill down into a specific agent")
overview_parser.add_argument("--messages", "-n", type=int, default=4, help="Messages per room (default: 4, max: 20)")
overview_parser.add_argument("--serve", action="store_true", help="Start live web dashboard")
overview_parser.add_argument("--refresh", type=int, default=5, help="Web refresh interval in seconds (default: 5)")
overview_parser.add_argument("--config", default=DEFAULT_CONFIG)
```

Add to the dispatch dictionary (around line 198):

```python
"overview": _cmd_overview,
```

Add the handler function (after the last command handler):

```python
def _cmd_overview(args: argparse.Namespace) -> None:
    """Show mesh overview."""
    from culture.overview.collector import collect_mesh_state
    from culture.overview.renderer_text import render_text

    config = load_config_or_default(args.config)
    message_limit = min(args.messages, 20)

    if args.serve:
        from culture.overview.renderer_web import serve_web
        serve_web(
            host=config.server.host,
            port=config.server.port,
            server_name=config.server.name,
            room_filter=args.room,
            agent_filter=args.agent,
            message_limit=message_limit,
            refresh_interval=args.refresh,
        )
        return

    mesh = asyncio.run(collect_mesh_state(
        host=config.server.host,
        port=config.server.port,
        server_name=config.server.name,
        message_limit=message_limit,
    ))
    output = render_text(
        mesh,
        room_filter=args.room,
        agent_filter=args.agent,
        message_limit=message_limit,
    )
    print(output, end="")
```

**3b. Add `_build_parser()` if it doesn't exist.** Extract the argparse setup into a `_build_parser() -> argparse.ArgumentParser` function so tests can import it. Ensure `main()` calls `_build_parser()`.

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_cli.py -v`
Expected: All 3 tests PASS

- [ ] **Step 5: Run full integration test**

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_collector.py tests/test_overview_renderer.py tests/test_overview_model.py tests/test_overview_cli.py -v`
Expected: All tests PASS

- [ ] **Step 6: Commit**

```bash
git add culture/cli.py tests/test_overview_cli.py
git commit -m "feat(overview): wire overview subcommand into CLI"
```

---

### Task 6: Web Renderer

**Files:**

- Create: `culture/overview/renderer_web.py`
- Create: `culture/overview/web/style.css`
- Create: `tests/test_overview_web.py`
- Modify: `pyproject.toml` (add mistune dependency)

- [ ] **Step 1: Add mistune dependency**

Add `mistune>=3.0` to the dependencies list in `pyproject.toml` (around line 18, after the existing deps):

```toml
mistune>=3.0
```

Run: `cd /home/spark/git/culture && uv sync`

- [ ] **Step 2: Write the failing test**

Create `tests/test_overview_web.py`:

```python
"""Tests for overview web renderer."""
import time

from culture.overview.model import Agent, Message, MeshState, Room
from culture.overview.renderer_web import render_html


def _make_fixture() -> MeshState:
    now = time.time()
    agent = Agent(
        nick="spark-claude", status="active", activity="working on: tests",
        channels=["#general"], server="spark",
        backend="claude", model="claude-opus-4-6",
    )
    msg = Message(nick="spark-claude", text="hello", timestamp=now - 60, channel="#general")
    room = Room(
        name="#general", topic="Testing",
        members=[agent], operators=["spark-claude"],
        federation_servers=[], messages=[msg],
    )
    return MeshState(server_name="spark", rooms=[room], agents=[agent], federation_links=[])


def test_render_html_produces_valid_html():
    mesh = _make_fixture()
    html = render_html(mesh, message_limit=4)
    assert "<!DOCTYPE html>" in html
    assert "<html" in html
    assert "</html>" in html


def test_render_html_contains_content():
    mesh = _make_fixture()
    html = render_html(mesh, message_limit=4)
    assert "spark mesh" in html
    assert "#general" in html
    assert "spark-claude" in html
    assert "hello" in html


def test_render_html_has_cream_styles():
    mesh = _make_fixture()
    html = render_html(mesh, message_limit=4)
    assert "#faf7f2" in html or "faf7f2" in html


def test_render_html_has_auto_refresh():
    mesh = _make_fixture()
    html = render_html(mesh, message_limit=4, refresh_interval=5)
    assert 'http-equiv="refresh"' in html or "refresh" in html.lower()
```

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_web.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'culture.overview.renderer_web'`

- [ ] **Step 4: Create the stylesheet**

Create `culture/overview/web/` directory and `culture/overview/web/style.css`:

```css
/* Anthropic cream dashboard theme */
* { box-sizing: border-box; margin: 0; padding: 0; }

body {
    font-family: Georgia, 'Times New Roman', serif;
    background: #faf7f2;
    color: #2d2d2d;
    line-height: 1.8;
    max-width: 960px;
    margin: 0 auto;
    padding: 2rem;
}

h1 {
    font-size: 1.6rem;
    border-bottom: 2px solid #c7a369;
    padding-bottom: 0.8rem;
    margin-bottom: 1.5rem;
}

h2 {
    font-size: 1.2rem;
    color: #c7a369;
    margin-top: 2rem;
    margin-bottom: 0.5rem;
}

h3 {
    font-size: 0.95rem;
    color: #555;
    margin-top: 1rem;
    margin-bottom: 0.5rem;
}

p { margin-bottom: 0.5rem; color: #888; font-size: 0.9rem; }

table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.9rem;
    margin: 0.8rem 0;
}

thead tr { border-bottom: 1px solid #c7a369; }
th { text-align: left; padding: 6px 12px; color: #555; }
td { padding: 6px 12px; border-bottom: 1px solid #e8e0d4; }

ul {
    list-style: none;
    background: #f5f0e8;
    border-radius: 6px;
    padding: 1rem;
    font-size: 0.85rem;
}

li { margin-bottom: 0.5rem; }
li:last-child { margin-bottom: 0; }

code {
    background: #f0ebe3;
    padding: 0.1em 0.3em;
    border-radius: 3px;
    font-size: 0.85em;
}

.status-active {
    background: #e8f5e9; color: #2e7d32;
    padding: 2px 8px; border-radius: 10px; font-size: 0.8rem;
}
.status-idle {
    background: #fff8e1; color: #f57f17;
    padding: 2px 8px; border-radius: 10px; font-size: 0.8rem;
}
.status-paused {
    background: #eeeeee; color: #616161;
    padding: 2px 8px; border-radius: 10px; font-size: 0.8rem;
}
.status-remote {
    background: #e3f2fd; color: #1565c0;
    padding: 2px 8px; border-radius: 10px; font-size: 0.8rem;
}

.refresh-indicator {
    font-size: 0.8rem;
    color: #aaa;
    margin-top: -1rem;
    margin-bottom: 1rem;
}
```

- [ ] **Step 5: Implement the web renderer**

Create `culture/overview/renderer_web.py`:

```python
"""Render mesh overview as HTML and serve via HTTP."""
from __future__ import annotations

import asyncio
import importlib.resources
import io
import re
from functools import partial
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path

import mistune

from .collector import collect_mesh_state
from .model import MeshState
from .renderer_text import render_text


def _load_css() -> str:
    """Load the cream stylesheet."""
    css_path = Path(__file__).parent / "web" / "style.css"
    return css_path.read_text()


def _inject_status_badges(html: str) -> str:
    """Replace status text in table cells with styled badges."""
    for status in ("active", "idle", "paused", "remote"):
        html = html.replace(
            f"<td>{status}</td>",
            f'<td><span class="status-{status}">{status}</span></td>',
        )
    return html


def render_html(
    mesh: MeshState,
    *,
    room_filter: str | None = None,
    agent_filter: str | None = None,
    message_limit: int = 4,
    refresh_interval: int = 5,
) -> str:
    """Render MeshState as a full HTML page."""
    md_text = render_text(
        mesh,
        room_filter=room_filter,
        agent_filter=agent_filter,
        message_limit=message_limit,
    )
    body_html = mistune.html(md_text)
    body_html = _inject_status_badges(body_html)
    css = _load_css()

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta http-equiv="refresh" content="{refresh_interval}">
    <title>culture overview</title>
    <style>{css}</style>
</head>
<body>
    <div class="refresh-indicator">live &mdash; refreshing every {refresh_interval}s</div>
    {body_html}
</body>
</html>"""


def serve_web(
    host: str,
    port: int,
    server_name: str,
    *,
    room_filter: str | None = None,
    agent_filter: str | None = None,
    message_limit: int = 4,
    refresh_interval: int = 5,
    serve_port: int = 0,
) -> None:
    """Start a local HTTP server serving the live overview."""

    class OverviewHandler(SimpleHTTPRequestHandler):
        def do_GET(self):
            mesh = asyncio.run(collect_mesh_state(
                host=host, port=port, server_name=server_name,
                message_limit=message_limit,
            ))
            html = render_html(
                mesh,
                room_filter=room_filter,
                agent_filter=agent_filter,
                message_limit=message_limit,
                refresh_interval=refresh_interval,
            )
            content = html.encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(content)))
            self.end_headers()
            self.wfile.write(content)

        def log_message(self, format, *args):
            pass  # Suppress request logging

    httpd = HTTPServer(("127.0.0.1", serve_port), OverviewHandler)
    actual_port = httpd.server_address[1]
    print(f"Overview dashboard: http://localhost:{actual_port}")
    print("Press Ctrl+C to stop.")
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nStopped.")
    finally:
        httpd.server_close()
```

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

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_web.py -v`
Expected: All 4 tests PASS

- [ ] **Step 7: Commit**

```bash
git add pyproject.toml culture/overview/renderer_web.py culture/overview/web/style.css tests/test_overview_web.py
git commit -m "feat(overview): add web renderer with anthropic cream theme"
```

---

### Task 7: Documentation

**Files:**

- Create: `docs/overview.md`

- [ ] **Step 1: Write the docs**

Create `docs/overview.md`:

```markdown
# culture overview

Mesh-wide situational awareness tool. Shows rooms, agents, messages, and
federation state in a single view.

## Usage

```bash
# Full mesh overview (default: 4 messages per room)
culture overview

# More messages per room
culture overview --messages 10

# Drill into a specific room
culture overview --room "#general"

# Drill into a specific agent
culture overview --agent spark-claude

# Live web dashboard
culture overview --serve
culture overview --serve --refresh 10
```

## Output Format

Output is standard markdown — headers, tables, and bullet lists. Designed to
be readable by both humans and AI agents.

### Default view

Shows all rooms with their agents (status table) and recent messages.

### Room drill-down (`--room`)

Extended detail for one room: member count, operators, federation links,
plus messages.

### Agent drill-down (`--agent`)

Agent metadata (backend, model, directory, turns, uptime), channel
memberships with roles, and cross-channel recent activity.

## Web Dashboard

`--serve` starts a local HTTP server that renders the same markdown as
styled HTML with the anthropic cream theme. Auto-refreshes at the interval
set by `--refresh` (default: 5 seconds).

## Data Sources

- **IRC Observer**: Ephemeral connection queries LIST, NAMES, WHO, HISTORY
- **Daemon IPC**: Local agent sockets enriched with activity, model, turns

Remote (federated) agents show IRC-level data only. Local agents get
IPC-enriched status when their daemon is running.

## Flags

| Flag | Default | Description |
|------|---------|-------------|
| `--room CHANNEL` | — | Single room detail |
| `--agent NICK` | — | Single agent detail |
| `--messages N` | 4 | Messages per room (max 20) |
| `--serve` | off | Start live web server |
| `--refresh N` | 5 | Web refresh interval (seconds) |
| `--config PATH` | `~/.culture/agents.yaml` | Config file |

```

- [ ] **Step 2: Commit**

```bash
git add docs/overview.md
git commit -m "docs: add overview feature documentation"
```

---

### Task 8: Full Integration Test

**Files:**

- No new files — verify everything works together

- [ ] **Step 1: Run all overview tests**

Run: `cd /home/spark/git/culture && python -m pytest tests/test_overview_model.py tests/test_overview_renderer.py tests/test_overview_collector.py tests/test_overview_cli.py tests/test_overview_web.py -v`
Expected: All tests PASS

- [ ] **Step 2: Run full test suite to check for regressions**

Run: `cd /home/spark/git/culture && python -m pytest -x -v`
Expected: All existing tests still PASS

- [ ] **Step 3: Manual smoke test (if server is available)**

```bash
# If agents are running:
culture overview
culture overview --room "#general"
culture overview --agent spark-claude
culture overview --messages 10
```

Verify output matches the markdown format from the spec.

- [ ] **Step 4: Commit any fixes**

If any fixes were needed, commit them:

```bash
git add -A
git commit -m "fix(overview): integration fixes"
```
