# Cognithor Trace-UI v0.95.0 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:** Ship the five Work-Packages (WP1-WP5) from `docs/superpowers/specs/2026-04-26-trace-ui-v095-design.md` as Cognithor v0.95.0 — an in-process `TraceBus` pub/sub that hooks `cognithor.crew.compiler.append_audit()` to live-broadcast crew audit events, three REST endpoints for replay/history, two WebSocket message types (lifecycle + topic-subscribe), and a Flutter master-detail Trace screen (TraceListScreen + TraceDetailScreen) with vertical timeline-log + per-agent stats sidebar — all owner-token gated.

**Architecture:** A new `cognithor.crew.trace_bus.TraceBus` singleton receives every `append_audit` call via a one-line hook in `compiler.py`, fans out to per-session `asyncio.Queue` subscribers (drop-oldest on overflow), and routes lifecycle-only events (`crew_kickoff_started/completed/failed`) to all owner WebSocket sessions while routing per-trace events to topic-subscribers. The existing `~/.cognithor/logs/audit.jsonl` Hashline-Guard chain is unchanged — it serves as the source for REST endpoints (`GET /api/crew/traces`, `GET /api/crew/trace/{id}`, `GET /api/crew/trace/{id}/stats`). Flutter `TraceProvider` (ChangeNotifier) hydrates `TraceListScreen` from REST + auto-updates via WS lifecycle stream; `TraceDetailScreen` REST-fetches the full trace, then WS-subscribes to receive live updates. Owner identification reuses the bootstrap-token claim against `COGNITHOR_OWNER_USER_ID` env (fallback: `pyproject.toml [project] authors[0].name`).

**Tech Stack:** Python 3.12, Pydantic v2, FastAPI (already a `[web]` extra dep), pytest + pytest-asyncio, ruff, mypy strict. Flutter 3.41.4, Material 3 dark theme, Provider state management. No new mandatory runtime deps; `fastapi`, `structlog`, `provider` (Flutter) already present.

---

## Sequencing and Dependencies

Per spec §11:

```
v0.94.1 (released 2026-04-26)
    ↓
[Pre-WP1 Gate: 24h v0.94.1 stability without v0.94.2-hotfix — Task 0]
    ↓
PR 1 (WP1 TraceBus + Owner)        — Tasks 1-8     (8 tasks,  1.5 days)
    ↓
PR 2 (WP2 REST API)                — Tasks 9-18    (10 tasks, 2 days)
    ↓
PR 3 (WP3 WebSocket Live-Stream)   — Tasks 19-26   (8 tasks,  1.5 days)
    ↓
PR 4 (WP4 Flutter + WP5 AutoGen)   — Tasks 27-46   (20 tasks, 5 days)
    ↓
Direct-Commit on main (v0.95.0)    — Tasks 47-51   (5 tasks,  1 day)
    ↓
Tag v0.95.0 + push  →  Release-Workflows  →  v0.95.0 LIVE
```

**Hard dependencies:**

- **PR 2 needs PR 1's `require_owner`** — REST endpoints depend on the owner-gating dependency from WP1.
- **PR 3 needs PR 1's `TraceBus`** — WS handlers subscribe to the bus added in WP1.
- **PR 4 needs PR 2 + PR 3 merged** — Flutter consumes both REST and WS surfaces.
- **WP5 (AutoGen-shim verification) bundles into PR 4** — additive tests; no cross-dep with WP4 production code.
- **Direct-Commit needs all 4 PRs merged + main CI green** — version bump references features that must be in `main`.

**Soft dependencies (parallel-OK):**

- PR 2 + PR 3 could theoretically run in parallel after PR 1 merges (different file paths), but reviewer bandwidth favours sequential.

---

## PR Strategy (4 PRs + Direct-Commit + Tag)

| PR        | Work-Package                              | Tasks   | Branch                                     | Merge Target | Release? |
|-----------|-------------------------------------------|---------|--------------------------------------------|--------------|----------|
| **PR 1**  | WP1 TraceBus + Owner-Gating               | 1-8     | `feat/cognithor-trace-v1-bus`              | `main`       | No       |
| **PR 2**  | WP2 REST API for Traces                   | 9-18    | `feat/cognithor-trace-v2-api`              | `main`       | No       |
| **PR 3**  | WP3 WebSocket Live-Stream                 | 19-26   | `feat/cognithor-trace-v3-ws`               | `main`       | No       |
| **PR 4**  | WP4 Flutter Trace-Screen + WP5 AutoGen    | 27-46   | `feat/cognithor-trace-v4-flutter`          | `main`       | No       |
| **DC**    | v0.95.0 Release-Bundle (Direct on main)   | 47-51   | `main` (no PR — direct commit + tag)       | `main`       | **Yes**  |

**Per-PR closeout sequence (applies to PR 1, 2, 3, 4):**

1. Full regression on the feature branch:
   - `pytest tests/ -x -q --cov=src/cognithor --cov-fail-under=89`
   - PR 1: also `pytest tests/test_crew/test_trace_bus.py tests/test_security/test_owner.py -x -q --cov-fail-under=85`
   - PR 2: also `pytest tests/test_api/test_crew_traces.py -x -q --cov-fail-under=85`
   - PR 3: also `pytest tests/test_channels/test_trace_ws.py -x -q --cov-fail-under=85`
   - PR 4: also `flutter test` + `flutter test integration_test/` from `flutter_app/`
2. `ruff check .` clean
3. `ruff format --check .` clean (per memory `feedback_ruff_format_before_commit.md`)
4. `mypy --strict src/cognithor/{crew,api,security,channels}` clean (PR 1, 2, 3)
5. CHANGELOG `[Unreleased]` shows the WP's entries (no version bump until DC)
6. Push branch, open PR, wait all CI green, squash-merge into `main`
7. **Cleanup in a SEPARATE turn** — never chain `&& git branch -d ...` after merge (per memory `feedback_pr_merge_never_chain_cleanup.md`).

**Direct-Commit on main (DC) sequence:**

1. Verify PRs 1-4 merged + main CI green
2. Bump version in 5 files (Task 47)
3. Append v0.95.0 highlights to `README.md` (Task 48)
4. Convert CHANGELOG `[Unreleased]` → `[0.95.0]` (Task 49)
5. Commit + tag `v0.95.0` + push → release workflows fire (Task 50)
6. Dispatch `publish.yml` workflow_dispatch (loop-prevention bypass, per v0.94.x lesson) (Task 51)

---

## File Structure

### New: WP1 Backend (PR 1)

- `src/cognithor/crew/trace_bus.py` — `TraceBus` singleton, `SubscriptionHandle`, `get_trace_bus()`
- `src/cognithor/security/owner.py` — `require_owner()`, `is_owner_token()`, owner identification
- `tests/test_crew/test_trace_bus.py`
- `tests/test_security/test_owner.py`
- `tests/test_security/__init__.py` (if missing)

### Modified: WP1 Backend (PR 1)

- `src/cognithor/crew/compiler.py` — one line in `append_audit()` (around line 117) to call `get_trace_bus().publish(record)` after `record_event()` returns

### New: WP2 Backend (PR 2)

- `src/cognithor/api/__init__.py` (if missing)
- `src/cognithor/api/crew_traces.py` — FastAPI router with 3 endpoints + JSONL reader helpers
- `tests/test_api/__init__.py` (if missing)
- `tests/test_api/test_crew_traces.py`
- `tests/test_api/fixtures/sample_audit.jsonl` — synthetic JSONL for integration tests
- `docs/api/crew-traces.md` — endpoint reference

### Modified: WP2 Backend (PR 2)

- `src/cognithor/api/__init__.py` (or `gateway.py`) — mount `crew_traces.router`

### New: WP3 Backend (PR 3)

- `tests/test_channels/test_trace_ws.py` — WS subscribe/unsubscribe/cleanup integration tests

### Modified: WP3 Backend (PR 3)

- `src/cognithor/channels/webui.py` — handle `crew_lifecycle_subscribe`, `crew_subscribe`, `crew_unsubscribe`; auto-cleanup on disconnect

### New: WP4 Frontend (PR 4)

- `flutter_app/lib/models/crew_trace.dart` — `CrewTraceMeta`, `CrewEvent`, `CrewTraceStats`
- `flutter_app/lib/services/trace_service.dart` — REST + WS client wrapper
- `flutter_app/lib/providers/trace_provider.dart` — ChangeNotifier
- `flutter_app/lib/screens/trace/trace_list_screen.dart`
- `flutter_app/lib/screens/trace/trace_detail_screen.dart`
- `flutter_app/lib/screens/trace/widgets/trace_card.dart`
- `flutter_app/lib/screens/trace/widgets/event_row.dart`
- `flutter_app/lib/screens/trace/widgets/stats_sidebar.dart`
- `flutter_app/test/screens/trace/trace_list_screen_test.dart`
- `flutter_app/test/screens/trace/trace_detail_screen_test.dart`
- `flutter_app/test/widgets/event_row_test.dart`
- `flutter_app/integration_test/trace_flow_test.dart`

### Modified: WP4 Frontend (PR 4)

- `flutter_app/lib/services/websocket_service.dart` — add `WsType.crewLifecycle`, `WsType.crewEvent`
- `flutter_app/lib/screens/main_shell.dart` — owner-conditional Trace nav entry
- `flutter_app/lib/i18n/app_en.arb` — new keys: `traces_title`, `trace_running`, etc.
- `flutter_app/lib/i18n/app_de.arb` — German translations

### New: WP5 Backend (PR 4)

- `tests/test_compat/test_autogen/test_trace_emission.py` — verify AutoGen-shim emits crew_* events

### Modified: Direct-Commit (5 files)

- `pyproject.toml` — `version = "0.95.0"`
- `src/cognithor/__init__.py` — `__version__ = "0.95.0"`
- `flutter_app/pubspec.yaml` — `version: 0.95.0+1`
- `flutter_app/lib/providers/connection_provider.dart` — `kFrontendVersion = '0.95.0'`
- `CHANGELOG.md` — `[Unreleased]` → `[0.95.0]`
- `README.md` — Highlights bullets for v0.95.0
- `docs/superpowers/plans/2026-04-26-trace-ui-v095.md` — already on disk, gets committed during PR 1

---

## Scope Clarifications

- **No new mandatory runtime deps.** `fastapi`, `pydantic`, `structlog` are already in core/optional deps. Flutter `provider` is already used.
- **Apache 2.0 only.** No new third-party concept attribution needed.
- **Single pin-point reused:** v0.94.0's `[autogen]` extra still anchors the AutoGen tests.
- **Backward compat:** Zero changes to public `cognithor.crew` API. WP1's hook into `append_audit` is purely additive.
- **Test coverage floors:** Branch CI guards ≥89% total. WP1-3 module-level ≥85%. WP4 widgets ≥80%.
- **Pre-WP1 Gate (per spec §11):** 24h v0.94.1 stability without v0.94.2-hotfix needed before PR 1 starts. Verified in Task 0.
- **Branching:** Each PR cuts its branch from latest `main` after the previous PR is merged. Cleanup is a separate turn.
- **Conventional Commits:** `feat:`, `docs:`, `test:`, `refactor:`, `chore:`, `fix:`, `style:`.
- **Pre-existing dirty state on `main`:** 11 modified `skills/*` files (user's local WIP) + untracked `docs/superpowers/plans/2026-04-25-cognithor-autogen-strategy.md` + `results/` directory. Every targeted `git add` must avoid these — never use `-A` or `.`.

---

## Working Directory

All commands assume working directory `D:/Jarvis/jarvis complete v20`. Bash shell. Forward slashes in paths.

---

## Spec Branch Note

The spec was committed on branch `spec/trace-ui-v095` (commit `18a8d011`). The implementation plan (this file) gets added on the same branch + that branch becomes PR 1's base. Or: cut `feat/cognithor-trace-v1-bus` from `main` (which lacks the spec) and let the spec ride along in PR 1. The plan commits below assume the **second** path: cut PR-branches from `main`, the spec ships in PR 1's first commit alongside Task 1 work.

Actually — simpler: since the spec is already on `spec/trace-ui-v095` and that branch can become PR 1's branch directly (rename), Task 1 step 1 will rename `spec/trace-ui-v095` to `feat/cognithor-trace-v1-bus`. The spec commit remains as commit 1 of PR 1.

---

## Task 0: Pre-WP1 Gate — verify v0.94.1 stability (24h, no hotfix)

**Why this exists (spec §6, F5-pattern):** Starting WP work while a `v0.94.2` hotfix is brewing on `main` would force constant rebases on every PR-branch and risk a hotfix landing inside a WP-PR by accident.

**Files:** none (operational gate, no code).

- [ ] **Step 1: Confirm `pip install cognithor==0.94.1` resolves on PyPI**

```bash
curl -sI https://pypi.org/project/cognithor/0.94.1/ | head -1
```

Expected: `HTTP/1.1 200 OK` (or `HTTP/2 200`).

- [ ] **Step 2: Read GitHub issue tracker for v0.94.1 regression reports**

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/repos/Alex8791-cyber/cognithor/issues?state=open&labels=regression" | python -c "import sys,json; d=json.load(sys.stdin); print('regression issues:', len(d))"
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/search/issues?q=repo:Alex8791-cyber/cognithor+is:open+0.94.1" | python -c "import sys,json; d=json.load(sys.stdin); print('0.94.1 mentions:', d.get('total_count', 0))"
```

Expected: zero issues labelled `regression` for v0.94.1 + zero open mentions of `0.94.1`. If any exist, classify with the user before proceeding.

- [ ] **Step 3: Confirm 24h since v0.94.1 tag-push without a `v0.94.2` PR opened**

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/repos/Alex8791-cyber/cognithor/releases/tags/v0.94.1" | python -c "import sys,json; d=json.load(sys.stdin); print('published:', d.get('published_at'))"
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/search/issues?q=repo:Alex8791-cyber/cognithor+is:pr+0.94.2" | python -c "import sys,json; d=json.load(sys.stdin); print('v0.94.2 PRs:', d.get('total_count', 0))"
```

Expected: `published_at` ≥24h in the past; v0.94.2 PR count = 0 (or merged-and-stable if any).

- [ ] **Step 4: Confirm main CI is green on the latest 5 runs**

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/repos/Alex8791-cyber/cognithor/actions/runs?branch=main&per_page=5" | python -c "
import sys,json
d = json.load(sys.stdin)
for r in d['workflow_runs']:
    print(f\"  {r['name']:30s} {r['status']}/{r.get('conclusion')}\")"
```

Expected: latest 5 runs on `main` show `completed/success`.

- [ ] **Step 5: Decision gate**

If Steps 1-4 all pass → proceed to Task 1. If any step fails (issues open, hotfix needed, CI red) → **STOP and report to user**; do not start PR 1 work.

No commit on this task — verification gate only.

---

# PR 1 — WP1 TraceBus + Owner-Gating (Tasks 1-8)

Implements spec §8.1 — in-process pub/sub bus that hooks `compiler.append_audit()` to live-broadcast crew audit events, plus owner identification (`require_owner()`) for downstream WP2/WP3 gating.

**Branch:** `feat/cognithor-trace-v1-bus` (renamed from `spec/trace-ui-v095`).

**PR-1 closeout target:** `TraceBus.publish` <1ms hot-path; `compiler.append_audit()` calls bus after JSONL write; lifecycle/topic routing correct; `require_owner` raises 403 for non-owner; tests ≥85% on new modules; `mypy --strict` clean.

---

### Task 1: Branch rename + first audit-bus skeleton

**Files:**
- Create: `src/cognithor/crew/trace_bus.py`
- Create: `tests/test_crew/test_trace_bus.py`

- [ ] **Step 1: Switch to spec branch + rename to PR 1 branch**

```bash
cd "D:/Jarvis/jarvis complete v20"
git checkout spec/trace-ui-v095
git branch -m spec/trace-ui-v095 feat/cognithor-trace-v1-bus
```

Expected: branch is now `feat/cognithor-trace-v1-bus` with the spec commit `18a8d011` as its first commit.

- [ ] **Step 2: Verify branch state**

```bash
git branch --show-current
git log main..HEAD --oneline
```

Expected: branch `feat/cognithor-trace-v1-bus`; one commit `18a8d011 docs(spec): Trace-UI v0.95.0 design spec`.

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

Create `tests/test_crew/test_trace_bus.py`:

```python
"""TraceBus — in-process pub/sub for crew audit events."""

from __future__ import annotations

import asyncio

import pytest

from cognithor.crew.trace_bus import TraceBus, get_trace_bus


def test_get_trace_bus_returns_singleton() -> None:
    bus1 = get_trace_bus()
    bus2 = get_trace_bus()
    assert bus1 is bus2


def test_publish_does_not_raise_with_no_subscribers() -> None:
    bus = TraceBus()
    bus.publish({"event_type": "crew_kickoff_started", "trace_id": "abc"})
```

- [ ] **Step 4: Run test — expect failure**

```bash
pytest tests/test_crew/test_trace_bus.py -v 2>&1 | tail -10
```

Expected: `ModuleNotFoundError: No module named 'cognithor.crew.trace_bus'`.

- [ ] **Step 5: Implement minimal TraceBus skeleton**

Create `src/cognithor/crew/trace_bus.py`:

```python
"""TraceBus — in-process pub/sub for cognithor.crew audit events.

Hooks `compiler.append_audit()` to live-broadcast crew_* events to
WebSocket subscribers without changing the JSONL persistence path.

Lifecycle events (`crew_kickoff_started`, `crew_kickoff_completed`,
`crew_kickoff_failed`) fan out to ALL lifecycle-subscribers (Dashboard
view). Per-trace events fan out to topic-subscribers keyed by `trace_id`.

Backpressure: each subscriber gets a bounded `asyncio.Queue(maxsize=1000)`.
On overflow → drop oldest, increment dropped-counter, rate-limited warn-log.
JSONL persistence is independent and lossless.
"""

from __future__ import annotations

import threading
from typing import Any


class TraceBus:
    """In-process pub/sub for crew audit events."""

    def __init__(self) -> None:
        self._lock = threading.Lock()

    def publish(self, record: dict[str, Any]) -> None:
        """Broadcast an audit record. Currently a no-op stub."""
        # Real fan-out lands in Task 3.
        return None


_singleton: TraceBus | None = None
_singleton_lock = threading.Lock()


def get_trace_bus() -> TraceBus:
    """Process-wide singleton accessor."""
    global _singleton
    if _singleton is None:
        with _singleton_lock:
            if _singleton is None:
                _singleton = TraceBus()
    return _singleton
```

- [ ] **Step 6: Run test — expect pass**

```bash
pytest tests/test_crew/test_trace_bus.py -v 2>&1 | tail -5
```

Expected: 2 passed.

- [ ] **Step 7: Commit**

```bash
git add src/cognithor/crew/trace_bus.py tests/test_crew/test_trace_bus.py
git commit -m "feat(trace): scaffold TraceBus singleton + first publish-no-op test"
```

Expected: 2 files changed, ~50 insertions.

---

### Task 2: SubscriptionHandle + lifecycle-subscribe

**Files:**
- Modify: `src/cognithor/crew/trace_bus.py`
- Modify: `tests/test_crew/test_trace_bus.py`

- [ ] **Step 1: Append failing test**

Append to `tests/test_crew/test_trace_bus.py`:

```python
@pytest.mark.asyncio
async def test_subscribe_lifecycle_returns_handle() -> None:
    bus = TraceBus()
    queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=10)
    handle = bus.subscribe_lifecycle(queue)
    assert handle is not None
    assert handle.topic == "__lifecycle__"
    bus.unsubscribe(handle)


@pytest.mark.asyncio
async def test_lifecycle_event_routes_to_lifecycle_subscriber() -> None:
    bus = TraceBus()
    queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=10)
    bus.subscribe_lifecycle(queue)
    bus.publish({"event_type": "crew_kickoff_started", "trace_id": "abc", "n_tasks": 4})
    received = await asyncio.wait_for(queue.get(), timeout=0.5)
    assert received["event_type"] == "crew_kickoff_started"
    assert received["trace_id"] == "abc"


@pytest.mark.asyncio
async def test_non_lifecycle_event_does_not_reach_lifecycle_subscriber() -> None:
    bus = TraceBus()
    queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=10)
    bus.subscribe_lifecycle(queue)
    bus.publish({"event_type": "crew_task_started", "trace_id": "abc", "task_id": "t1"})
    with pytest.raises(asyncio.TimeoutError):
        await asyncio.wait_for(queue.get(), timeout=0.1)
```

(Add `from typing import Any` to test imports if not already present.)

- [ ] **Step 2: Run tests — expect failures**

```bash
pytest tests/test_crew/test_trace_bus.py -v 2>&1 | tail -10
```

Expected: 3 new tests fail with `AttributeError: 'TraceBus' object has no attribute 'subscribe_lifecycle'`.

- [ ] **Step 3: Implement subscribe_lifecycle + SubscriptionHandle**

Replace the body of `src/cognithor/crew/trace_bus.py` with:

```python
"""TraceBus — in-process pub/sub for cognithor.crew audit events.

Hooks `compiler.append_audit()` to live-broadcast crew_* events to
WebSocket subscribers without changing the JSONL persistence path.

Lifecycle events (`crew_kickoff_started`, `crew_kickoff_completed`,
`crew_kickoff_failed`) fan out to ALL lifecycle-subscribers (Dashboard
view). Per-trace events fan out to topic-subscribers keyed by `trace_id`.

Backpressure: each subscriber gets a bounded `asyncio.Queue(maxsize=1000)`.
On overflow → drop oldest, increment dropped-counter, rate-limited warn-log.
JSONL persistence is independent and lossless.
"""

from __future__ import annotations

import asyncio
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Any

log = logging.getLogger(__name__)

LIFECYCLE_EVENTS: frozenset[str] = frozenset(
    {"crew_kickoff_started", "crew_kickoff_completed", "crew_kickoff_failed"}
)
LIFECYCLE_TOPIC = "__lifecycle__"
DEFAULT_QUEUE_MAXSIZE = 1000
_DROP_LOG_INTERVAL_SEC = 60.0


@dataclass
class SubscriptionHandle:
    """Opaque handle returned by subscribe(); pass to unsubscribe()."""

    topic: str
    queue: asyncio.Queue[dict[str, Any]] = field(repr=False)
    dropped_count: int = 0
    last_drop_log_ts: float = 0.0


class TraceBus:
    """In-process pub/sub for crew audit events."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        # topic → list of handles
        self._subscribers: dict[str, list[SubscriptionHandle]] = {}

    def subscribe_lifecycle(
        self, queue: asyncio.Queue[dict[str, Any]]
    ) -> SubscriptionHandle:
        """Subscribe to lifecycle-only events (crew_kickoff_*)."""
        handle = SubscriptionHandle(topic=LIFECYCLE_TOPIC, queue=queue)
        with self._lock:
            self._subscribers.setdefault(LIFECYCLE_TOPIC, []).append(handle)
        return handle

    def unsubscribe(self, handle: SubscriptionHandle) -> None:
        """Remove a subscription."""
        with self._lock:
            subs = self._subscribers.get(handle.topic, [])
            try:
                subs.remove(handle)
            except ValueError:
                return
            if not subs:
                self._subscribers.pop(handle.topic, None)

    def publish(self, record: dict[str, Any]) -> None:
        """Broadcast an audit record. Hot-path; must be <1ms."""
        event_type = record.get("event_type") or record.get("event")
        if event_type in LIFECYCLE_EVENTS:
            self._fanout(LIFECYCLE_TOPIC, record)
        # Per-trace topic fan-out lands in Task 3.

    def _fanout(self, topic: str, record: dict[str, Any]) -> None:
        with self._lock:
            subs = list(self._subscribers.get(topic, ()))
        for handle in subs:
            self._enqueue(handle, record)

    def _enqueue(
        self, handle: SubscriptionHandle, record: dict[str, Any]
    ) -> None:
        try:
            handle.queue.put_nowait(record)
        except asyncio.QueueFull:
            try:
                handle.queue.get_nowait()
            except asyncio.QueueEmpty:
                pass
            try:
                handle.queue.put_nowait(record)
            except asyncio.QueueFull:
                pass
            handle.dropped_count += 1
            now = time.monotonic()
            if now - handle.last_drop_log_ts > _DROP_LOG_INTERVAL_SEC:
                log.warning(
                    "trace_bus_drop topic=%s dropped_total=%d",
                    handle.topic,
                    handle.dropped_count,
                )
                handle.last_drop_log_ts = now


_singleton: TraceBus | None = None
_singleton_lock = threading.Lock()


def get_trace_bus() -> TraceBus:
    """Process-wide singleton accessor."""
    global _singleton
    if _singleton is None:
        with _singleton_lock:
            if _singleton is None:
                _singleton = TraceBus()
    return _singleton
```

- [ ] **Step 4: Run tests — expect pass**

```bash
pytest tests/test_crew/test_trace_bus.py -v 2>&1 | tail -10
```

Expected: 5 passed (2 prior + 3 new).

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/crew/trace_bus.py tests/test_crew/test_trace_bus.py
git commit -m "feat(trace): add SubscriptionHandle + lifecycle-event routing"
```

---

### Task 3: Topic-subscribe (per-trace)

**Files:**
- Modify: `src/cognithor/crew/trace_bus.py`
- Modify: `tests/test_crew/test_trace_bus.py`

- [ ] **Step 1: Append failing test**

```python
@pytest.mark.asyncio
async def test_subscribe_topic_routes_per_trace_events() -> None:
    bus = TraceBus()
    q1: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=10)
    q2: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=10)
    bus.subscribe("trace-1", q1)
    bus.subscribe("trace-2", q2)

    bus.publish({"event_type": "crew_task_started", "trace_id": "trace-1", "task_id": "t1"})

    rec1 = await asyncio.wait_for(q1.get(), timeout=0.5)
    assert rec1["task_id"] == "t1"
    with pytest.raises(asyncio.TimeoutError):
        await asyncio.wait_for(q2.get(), timeout=0.1)


@pytest.mark.asyncio
async def test_unsubscribe_removes_from_routing() -> None:
    bus = TraceBus()
    queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=10)
    handle = bus.subscribe("trace-x", queue)
    bus.unsubscribe(handle)
    bus.publish({"event_type": "crew_task_completed", "trace_id": "trace-x"})
    with pytest.raises(asyncio.TimeoutError):
        await asyncio.wait_for(queue.get(), timeout=0.1)


@pytest.mark.asyncio
async def test_lifecycle_event_also_reaches_topic_subscriber() -> None:
    """A crew_kickoff_started event has trace_id; topic-subscribers want it too."""
    bus = TraceBus()
    queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=10)
    bus.subscribe("trace-y", queue)
    bus.publish({"event_type": "crew_kickoff_started", "trace_id": "trace-y", "n_tasks": 2})
    rec = await asyncio.wait_for(queue.get(), timeout=0.5)
    assert rec["event_type"] == "crew_kickoff_started"
```

- [ ] **Step 2: Run — expect failures**

```bash
pytest tests/test_crew/test_trace_bus.py -v 2>&1 | tail -15
```

Expected: 3 new tests fail with `AttributeError: 'TraceBus' object has no attribute 'subscribe'`.

- [ ] **Step 3: Add `subscribe()` method + topic fan-out in publish**

In `src/cognithor/crew/trace_bus.py`, add after `unsubscribe`:

```python
    def subscribe(
        self, trace_id: str, queue: asyncio.Queue[dict[str, Any]]
    ) -> SubscriptionHandle:
        """Subscribe to all events for a single trace_id."""
        handle = SubscriptionHandle(topic=trace_id, queue=queue)
        with self._lock:
            self._subscribers.setdefault(trace_id, []).append(handle)
        return handle
```

Then update `publish()` to also fan out to per-trace topic:

```python
    def publish(self, record: dict[str, Any]) -> None:
        """Broadcast an audit record. Hot-path; must be <1ms."""
        event_type = record.get("event_type") or record.get("event")
        trace_id = record.get("trace_id") or record.get("session_id")
        if event_type in LIFECYCLE_EVENTS:
            self._fanout(LIFECYCLE_TOPIC, record)
        if trace_id:
            self._fanout(trace_id, record)
```

- [ ] **Step 4: Run — expect pass**

```bash
pytest tests/test_crew/test_trace_bus.py -v 2>&1 | tail -15
```

Expected: 8 passed (5 prior + 3 new).

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/crew/trace_bus.py tests/test_crew/test_trace_bus.py
git commit -m "feat(trace): add per-trace topic subscribe + dual-fanout publish"
```

---

### Task 4: Backpressure (drop-oldest)

**Files:**
- Modify: `tests/test_crew/test_trace_bus.py`

- [ ] **Step 1: Append failing test**

```python
@pytest.mark.asyncio
async def test_queue_full_drops_oldest_and_increments_counter() -> None:
    bus = TraceBus()
    queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=2)
    handle = bus.subscribe("trace-z", queue)

    bus.publish({"event_type": "crew_task_started", "trace_id": "trace-z", "task_id": "1"})
    bus.publish({"event_type": "crew_task_started", "trace_id": "trace-z", "task_id": "2"})
    bus.publish({"event_type": "crew_task_started", "trace_id": "trace-z", "task_id": "3"})

    # Queue should have items 2 and 3 (oldest "1" was dropped).
    first = await asyncio.wait_for(queue.get(), timeout=0.5)
    second = await asyncio.wait_for(queue.get(), timeout=0.5)
    assert first["task_id"] == "2"
    assert second["task_id"] == "3"
    assert handle.dropped_count == 1
```

- [ ] **Step 2: Run — expect pass (already implemented in Task 2)**

```bash
pytest tests/test_crew/test_trace_bus.py::test_queue_full_drops_oldest_and_increments_counter -v 2>&1 | tail -5
```

Expected: 1 passed. (The drop-oldest logic was implemented in Task 2's `_enqueue`; this test confirms it.)

- [ ] **Step 3: Commit**

```bash
git add tests/test_crew/test_trace_bus.py
git commit -m "test(trace): verify drop-oldest backpressure path"
```

---

### Task 5: Hot-path performance (<1ms)

**Files:**
- Modify: `tests/test_crew/test_trace_bus.py`

- [ ] **Step 1: Append performance test**

```python
def test_publish_hot_path_under_1ms_with_many_subscribers() -> None:
    """publish() should be <1ms even with ~50 active subscribers."""
    import time as _time

    bus = TraceBus()
    queues = [asyncio.Queue(maxsize=100) for _ in range(50)]
    handles = [bus.subscribe(f"trace-{i}", q) for i, q in enumerate(queues)]
    bus.subscribe_lifecycle(asyncio.Queue(maxsize=100))

    record = {"event_type": "crew_task_started", "trace_id": "trace-25", "task_id": "perf"}

    start = _time.perf_counter()
    for _ in range(1000):
        bus.publish(record)
    elapsed = _time.perf_counter() - start
    avg_us = (elapsed / 1000) * 1_000_000
    assert avg_us < 1000, f"publish too slow: avg {avg_us:.1f}µs (target <1000µs)"

    for h in handles:
        bus.unsubscribe(h)
```

- [ ] **Step 2: Run — expect pass**

```bash
pytest tests/test_crew/test_trace_bus.py::test_publish_hot_path_under_1ms_with_many_subscribers -v 2>&1 | tail -5
```

Expected: 1 passed (typical avg: 5-20µs on dev machine).

- [ ] **Step 3: Commit**

```bash
git add tests/test_crew/test_trace_bus.py
git commit -m "test(trace): hot-path perf bench (publish <1ms target)"
```

---

### Task 6: Hook compiler.append_audit

**Files:**
- Modify: `src/cognithor/crew/compiler.py:115-118` (one-line addition after `record_event` succeeds)
- Create: `tests/test_crew/test_trace_bus_compiler_hook.py`

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

Create `tests/test_crew/test_trace_bus_compiler_hook.py`:

```python
"""Integration test: compiler.append_audit() must publish to TraceBus."""

from __future__ import annotations

import asyncio
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from cognithor.crew.compiler import append_audit
from cognithor.crew.trace_bus import TraceBus


@pytest.mark.asyncio
async def test_append_audit_publishes_to_trace_bus() -> None:
    """append_audit() must invoke get_trace_bus().publish(record)."""
    fake_bus = MagicMock(spec=TraceBus)
    with patch("cognithor.crew.compiler._get_audit_trail", return_value=MagicMock()):
        with patch("cognithor.crew.compiler.get_trace_bus", return_value=fake_bus):
            append_audit(
                "crew_kickoff_started",
                trace_id="test-trace-1",
                n_tasks=4,
                process="SEQUENTIAL",
            )
    fake_bus.publish.assert_called_once()
    record = fake_bus.publish.call_args.args[0]
    assert record.get("event_type") == "crew_kickoff_started" or record.get("event") == "crew_kickoff_started"
    assert record.get("trace_id") == "test-trace-1" or record.get("session_id") == "test-trace-1"


@pytest.mark.asyncio
async def test_append_audit_publishes_even_when_audit_trail_is_none() -> None:
    """Even when AuditTrail is unavailable, the bus should still see events."""
    fake_bus = MagicMock(spec=TraceBus)
    with patch("cognithor.crew.compiler._get_audit_trail", return_value=None):
        with patch("cognithor.crew.compiler.get_trace_bus", return_value=fake_bus):
            append_audit("crew_kickoff_started", trace_id="t-2", n_tasks=1, process="SEQUENTIAL")
    fake_bus.publish.assert_called_once()
```

- [ ] **Step 2: Run — expect failures**

```bash
pytest tests/test_crew/test_trace_bus_compiler_hook.py -v 2>&1 | tail -10
```

Expected: 2 failures. Either `ImportError: cannot import name 'get_trace_bus'` (compiler doesn't import it yet) or `AssertionError: Expected 'publish' to have been called once. Called 0 times.`

- [ ] **Step 3: Patch compiler.py to import + invoke get_trace_bus**

Read `src/cognithor/crew/compiler.py` around lines 25-30 (imports). Add to existing imports:

```python
from cognithor.crew.trace_bus import get_trace_bus
```

Then locate the `append_audit` function (around line 93). After the `try/except` block that calls `trail.record_event(...)` ends, BEFORE the function returns, add the bus publish call. The function currently looks roughly like:

```python
def append_audit(event: str, **fields: Any) -> None:
    trail = _get_audit_trail()
    if trail is None:
        return
    session_id = fields.pop("trace_id", "crew")
    scrubbed = _scrub_audit_fields(fields)
    try:
        trail.record_event(session_id=session_id, event_type=event, details=scrubbed)
    except Exception as exc:
        # ... existing error logging ...
```

Refactor to ALWAYS publish to the bus, even when `trail is None`, and AFTER the JSONL write attempt. Replace the `if trail is None: return` early-return with a conditional record_event call:

```python
def append_audit(event: str, **fields: Any) -> None:
    """Emit a Crew-Layer audit event via the Hashline-Guard chain.

    Falls back to a no-op for the JSONL persistence side when AuditTrail
    cannot be built. The TraceBus pub/sub side runs unconditionally so live
    Trace-UI subscribers see events even when ~/.cognithor/ is missing
    (e.g. standalone test setups).
    """
    trail = _get_audit_trail()
    session_id = fields.pop("trace_id", "crew")
    scrubbed = _scrub_audit_fields(fields)
    if trail is not None:
        try:
            trail.record_event(session_id=session_id, event_type=event, details=scrubbed)
        except Exception as exc:
            log.warning(
                "crew_audit_record_failed - Hashline-Guard chain may be incomplete",
                extra={"event": event, "session_id": session_id},
                exc_info=exc,
            )
            try:
                from cognithor.telemetry.metrics import MetricsProvider

                MetricsProvider.get_instance().counter(
                    "cognithor_crew_audit_record_failures_total",
                    1,
                    labels={"reason": type(exc).__name__},
                )
            except (ImportError, AttributeError):
                pass

    # TraceBus publish — independent of JSONL success.
    try:
        get_trace_bus().publish(
            {"event_type": event, "trace_id": session_id, **scrubbed}
        )
    except Exception as exc:  # noqa: BLE001 — bus must never break audit
        log.warning(
            "trace_bus_publish_failed event=%s trace_id=%s",
            event,
            session_id,
            exc_info=exc,
        )
```

NOTE: the existing function's exact body and indentation must be preserved. Read the file first; only change the `if trail is None: return` early-out + add the trace-bus publish at the end.

- [ ] **Step 4: Run — expect pass**

```bash
pytest tests/test_crew/test_trace_bus_compiler_hook.py -v 2>&1 | tail -10
```

Expected: 2 passed.

- [ ] **Step 5: Run all crew tests to ensure no regression**

```bash
pytest tests/test_crew/ -x -q 2>&1 | tail -5
```

Expected: green (existing 166+ tests still pass + new bus tests).

- [ ] **Step 6: Commit**

```bash
git add src/cognithor/crew/compiler.py tests/test_crew/test_trace_bus_compiler_hook.py
git commit -m "feat(trace): hook compiler.append_audit into TraceBus.publish"
```

---

### Task 7: Owner identification — `require_owner`

**Files:**
- Create: `src/cognithor/security/owner.py`
- Create: `tests/test_security/__init__.py` (empty if missing)
- Create: `tests/test_security/test_owner.py`

- [ ] **Step 1: Verify tests/test_security exists**

```bash
test -d tests/test_security && echo "exists" || mkdir -p tests/test_security
test -f tests/test_security/__init__.py || touch tests/test_security/__init__.py
ls tests/test_security/
```

Expected: directory exists with at least `__init__.py`.

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

Create `tests/test_security/test_owner.py`:

```python
"""require_owner — owner identification for Trace-UI access gating."""

from __future__ import annotations

import os

import pytest

from cognithor.security.owner import OwnerRequiredError, is_owner_token, require_owner


def test_owner_default_reads_pyproject_authors_when_env_unset(monkeypatch) -> None:
    """When COGNITHOR_OWNER_USER_ID env unset, default reads pyproject.toml."""
    monkeypatch.delenv("COGNITHOR_OWNER_USER_ID", raising=False)
    # Inferred default == "Alexander Söllner" (from pyproject.toml authors[0].name).
    assert is_owner_token("Alexander Söllner") is True
    assert is_owner_token("Anonymous") is False


def test_owner_explicit_env_overrides_default(monkeypatch) -> None:
    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "test-owner-42")
    assert is_owner_token("test-owner-42") is True
    assert is_owner_token("Alexander Söllner") is False


def test_require_owner_returns_silently_for_owner(monkeypatch) -> None:
    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "test-owner")
    require_owner("test-owner")  # must not raise


def test_require_owner_raises_for_non_owner(monkeypatch) -> None:
    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "real-owner")
    with pytest.raises(OwnerRequiredError) as exc:
        require_owner("guest")
    assert "owner_only" in str(exc.value)


def test_require_owner_raises_for_empty_token(monkeypatch) -> None:
    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "owner")
    with pytest.raises(OwnerRequiredError):
        require_owner("")
    with pytest.raises(OwnerRequiredError):
        require_owner(None)  # type: ignore[arg-type]
```

- [ ] **Step 3: Run — expect failure**

```bash
pytest tests/test_security/test_owner.py -v 2>&1 | tail -10
```

Expected: `ModuleNotFoundError: No module named 'cognithor.security.owner'`.

- [ ] **Step 4: Implement `owner.py`**

Create `src/cognithor/security/owner.py`:

```python
"""Owner-token identification for Trace-UI gating.

The owner is identified by a string match between the bootstrap-token's
user identifier and either:
  - The `COGNITHOR_OWNER_USER_ID` environment variable (preferred), or
  - A fallback derived from `pyproject.toml [project] authors[0].name`.

The fallback exists so single-user dev installs work out of the box without
configuration. Production deployments should set the env var explicitly.
"""

from __future__ import annotations

import os
import tomllib
from functools import lru_cache
from pathlib import Path


class OwnerRequiredError(Exception):
    """Raised when an action requires an owner token but the supplied token
    does not match. FastAPI handlers translate this to HTTP 403."""


_PYPROJECT_FALLBACK_DEFAULT = "Alexander Söllner"


@lru_cache(maxsize=1)
def _pyproject_owner_fallback() -> str:
    """Read pyproject.toml [project] authors[0].name once per process."""
    here = Path(__file__).resolve()
    for parent in here.parents:
        candidate = parent / "pyproject.toml"
        if candidate.exists():
            try:
                with candidate.open("rb") as fp:
                    data = tomllib.load(fp)
                authors = data.get("project", {}).get("authors", [])
                if authors and isinstance(authors[0], dict):
                    name = authors[0].get("name")
                    if isinstance(name, str) and name:
                        return name
            except (OSError, tomllib.TOMLDecodeError):
                pass
            break
    return _PYPROJECT_FALLBACK_DEFAULT


def _expected_owner() -> str:
    env = os.environ.get("COGNITHOR_OWNER_USER_ID")
    if env:
        return env
    return _pyproject_owner_fallback()


def is_owner_token(token_user_id: str | None) -> bool:
    """Return True if `token_user_id` matches the configured owner."""
    if not token_user_id:
        return False
    return token_user_id == _expected_owner()


def require_owner(token_user_id: str | None) -> None:
    """Raise OwnerRequiredError if the supplied token is not the owner."""
    if not is_owner_token(token_user_id):
        raise OwnerRequiredError(
            f"owner_only: token does not match the configured owner "
            f"(set COGNITHOR_OWNER_USER_ID env var to override)."
        )
```

- [ ] **Step 5: Run — expect pass**

```bash
pytest tests/test_security/test_owner.py -v 2>&1 | tail -10
```

Expected: 5 passed.

- [ ] **Step 6: Commit**

```bash
git add src/cognithor/security/owner.py tests/test_security/__init__.py tests/test_security/test_owner.py
git commit -m "feat(security): add require_owner() for Trace-UI access gating"
```

---

### Task 8: Lint, mypy, CHANGELOG entry, PR-1 closeout

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Run lint + format check on the new modules**

```bash
ruff check src/cognithor/crew/trace_bus.py src/cognithor/security/owner.py tests/test_crew/test_trace_bus.py tests/test_crew/test_trace_bus_compiler_hook.py tests/test_security/
ruff format --check src/cognithor/crew/trace_bus.py src/cognithor/security/owner.py tests/test_crew/test_trace_bus.py tests/test_crew/test_trace_bus_compiler_hook.py tests/test_security/
```

Expected: both clean. If `ruff format --check` flags anything, run `ruff format <file>` and re-stage.

- [ ] **Step 2: Run mypy --strict on new modules**

```bash
mypy --strict src/cognithor/crew/trace_bus.py src/cognithor/security/owner.py
```

Expected: `Success: no issues found in 2 source files`.

If errors appear:
- For `Any`-typed dicts where Pydantic models would be premature, leave the `dict[str, Any]` and add `# type: ignore[type-arg]` only where strictly needed.
- The `os.environ.get` return type is correctly `str | None`.

- [ ] **Step 3: Run targeted regression**

```bash
pytest tests/test_crew/test_trace_bus.py tests/test_crew/test_trace_bus_compiler_hook.py tests/test_security/test_owner.py --cov=src/cognithor/crew/trace_bus --cov=src/cognithor/security/owner --cov-fail-under=85 -q 2>&1 | tail -10
```

Expected: green; coverage ≥85% on both modules.

- [ ] **Step 4: Update CHANGELOG**

Read `CHANGELOG.md`. Locate `## [Unreleased]` (will be empty post-v0.94.1). Add under it:

```markdown
## [Unreleased]

### Added

- `cognithor.crew.trace_bus.TraceBus` — in-process pub/sub for crew audit events. Hooks `compiler.append_audit()` to live-broadcast crew_* events via lifecycle stream + per-trace topic subscriptions. Backpressure: drop-oldest at 1000-event queue cap (WP1 of v0.95.0 Trace-UI).
- `cognithor.security.owner.require_owner()` — owner-token gating for Trace-UI surfaces. Reads `COGNITHOR_OWNER_USER_ID` env var with `pyproject.toml` author-name fallback.
```

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): note WP1 TraceBus + Owner-Gating additions for v0.95.0"
```

- [ ] **Step 5: Full regression on the feature branch**

```bash
pytest tests/ -x -q --cov=src/cognithor --cov-fail-under=89 2>&1 | tail -10
```

Expected: green, coverage ≥89% (matches existing v0.94.1 baseline).

- [ ] **Step 6: Push branch + open PR**

```bash
git push -u origin feat/cognithor-trace-v1-bus
```

Then open the PR (token via `git credential fill`, body via JSON file to avoid backtick-shell-expansion issues — see v0.94.1 lesson):

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
cat > /tmp/pr_trace_v1_body.json <<'JSON_EOF'
{
  "title": "feat(trace): WP1 TraceBus + Owner-Gating (v0.95.0 PR 1)",
  "head": "feat/cognithor-trace-v1-bus",
  "base": "main",
  "body": "## Summary\n- New `cognithor.crew.trace_bus.TraceBus` singleton — in-process pub/sub for crew audit events.\n- One-line hook in `cognithor.crew.compiler.append_audit()` to publish records after JSONL write.\n- New `cognithor.security.owner.require_owner()` — owner-token gating for downstream WP2/WP3.\n- Spec for the full v0.95.0 release: `docs/superpowers/specs/2026-04-26-trace-ui-v095-design.md`\n- Plan: `docs/superpowers/plans/2026-04-26-trace-ui-v095.md`\n\n## Spec\n- `docs/superpowers/specs/2026-04-26-trace-ui-v095-design.md` §8.1\n\n## Test plan\n- [x] `pytest tests/test_crew/test_trace_bus.py tests/test_crew/test_trace_bus_compiler_hook.py tests/test_security/test_owner.py -q` green\n- [x] Coverage ≥85% on `trace_bus.py` + `owner.py`\n- [x] `mypy --strict src/cognithor/crew/trace_bus.py src/cognithor/security/owner.py` clean\n- [x] `pytest tests/ -x -q --cov-fail-under=89` green (no regression)\n- [x] Hot-path perf bench (publish <1ms even with 50 subscribers)\n\nDoes not introduce new mandatory runtime deps."
}
JSON_EOF
cat /tmp/pr_trace_v1_body.json | curl -sL -X POST -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github+json" --data-binary @- "https://api.github.com/repos/Alex8791-cyber/cognithor/pulls" | python -c "import sys,json; d=json.load(sys.stdin); print(f\"PR #{d.get('number')}: {d.get('html_url')}\") if 'number' in d else print('error:', d.get('message'),'errors:',d.get('errors'))"
rm -f /tmp/pr_trace_v1_body.json
```

- [ ] **Step 7: Wait CI green, squash-merge**

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
PR_NUMBER=<from-step-6-output>
# Watch CI:
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/repos/Alex8791-cyber/cognithor/pulls/$PR_NUMBER" | python -c "import sys,json; d=json.load(sys.stdin); print('mergeable:', d.get('mergeable'), 'state:', d.get('mergeable_state'))"
```

Once CI is fully green, merge:

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
python -c "import json; print(json.dumps({'commit_title':'feat(trace): WP1 TraceBus + Owner-Gating (v0.95.0 PR 1) (#<NUM>)','merge_method':'squash'}))" | curl -sL -X PUT -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github+json" --data-binary @- "https://api.github.com/repos/Alex8791-cyber/cognithor/pulls/<NUM>/merge"
```

- [ ] **Step 8: Cleanup in a SEPARATE turn**

After confirming PR is merged in `main`, in a new conversation turn:

```bash
git stash push --include-untracked -m "trace-pr1-cleanup-temp" -- skills/ docs/superpowers/plans/2026-04-25-cognithor-autogen-strategy.md results/
git checkout main
git pull --ff-only
git branch -d feat/cognithor-trace-v1-bus
git push origin --delete feat/cognithor-trace-v1-bus
git stash pop
```

---

# PR 2 — WP2 REST API for Traces (Tasks 9-18)

Implements spec §8.2 — three FastAPI endpoints reading from `~/.cognithor/logs/audit.jsonl`, owner-gated, with corruption-tolerant JSONL parsing. **Branch:** `feat/cognithor-trace-v2-api` cut from latest `main` (after PR 1 merged).

**PR-2 closeout target:** Three endpoints functional, owner-only, OpenAPI schema valid, corrupt-line tolerance, ≥85% coverage on `crew_traces.py`.

---

### Task 9: Branch + JSONL reader helper

**Files:**
- Create: `src/cognithor/api/__init__.py` (if missing)
- Create: `src/cognithor/api/crew_traces.py` (skeleton — reader function only)
- Create: `tests/test_api/__init__.py`
- Create: `tests/test_api/fixtures/sample_audit.jsonl`
- Create: `tests/test_api/test_crew_traces.py`

- [ ] **Step 1: Cut feature branch**

```bash
git checkout main
git pull --ff-only
git checkout -b feat/cognithor-trace-v2-api
```

- [ ] **Step 2: Verify api/ directory exists**

```bash
test -d src/cognithor/api && echo "exists" || mkdir -p src/cognithor/api
test -f src/cognithor/api/__init__.py || touch src/cognithor/api/__init__.py
mkdir -p tests/test_api/fixtures
test -f tests/test_api/__init__.py || touch tests/test_api/__init__.py
```

- [ ] **Step 3: Create JSONL fixture (5 events, 1 corrupt line)**

Create `tests/test_api/fixtures/sample_audit.jsonl`:

```jsonl
{"hash": "h1", "prev_hash": "0", "timestamp": "2026-04-26T10:00:00Z", "session_id": "trace-aaa", "event_type": "crew_kickoff_started", "details": {"n_tasks": 2, "process": "SEQUENTIAL"}}
{"hash": "h2", "prev_hash": "h1", "timestamp": "2026-04-26T10:00:01Z", "session_id": "trace-aaa", "event_type": "crew_task_started", "details": {"task_id": "t1", "agent_role": "researcher"}}
{NOT VALID JSON corrupt line
{"hash": "h3", "prev_hash": "h2", "timestamp": "2026-04-26T10:00:05Z", "session_id": "trace-aaa", "event_type": "crew_task_completed", "details": {"task_id": "t1", "duration_ms": 4000.0, "tokens": 1234}}
{"hash": "h4", "prev_hash": "h3", "timestamp": "2026-04-26T10:00:06Z", "session_id": "trace-aaa", "event_type": "crew_kickoff_completed", "details": {"n_tasks": 2}}
{"hash": "h5", "prev_hash": "h4", "timestamp": "2026-04-26T10:01:00Z", "session_id": "trace-bbb", "event_type": "crew_kickoff_started", "details": {"n_tasks": 1, "process": "SEQUENTIAL"}}
```

(That's 6 lines: 5 valid + 1 corrupt.)

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

Create `tests/test_api/test_crew_traces.py`:

```python
"""Tests for cognithor.api.crew_traces — JSONL reader + endpoint helpers."""

from __future__ import annotations

from pathlib import Path

import pytest

from cognithor.api.crew_traces import read_audit_lines

FIXTURE = Path(__file__).parent / "fixtures" / "sample_audit.jsonl"


def test_read_audit_lines_skips_corrupt_lines() -> None:
    events, skipped = read_audit_lines(FIXTURE)
    assert len(events) == 5
    assert skipped == 1


def test_read_audit_lines_returns_dicts_with_session_id() -> None:
    events, _ = read_audit_lines(FIXTURE)
    assert events[0]["session_id"] == "trace-aaa"
    assert events[-1]["session_id"] == "trace-bbb"


def test_read_audit_lines_returns_zero_skipped_for_clean_file(tmp_path: Path) -> None:
    clean = tmp_path / "clean.jsonl"
    clean.write_text(
        '{"session_id":"x","event_type":"crew_kickoff_started"}\n', encoding="utf-8"
    )
    events, skipped = read_audit_lines(clean)
    assert len(events) == 1
    assert skipped == 0


def test_read_audit_lines_returns_empty_for_missing_file(tmp_path: Path) -> None:
    missing = tmp_path / "nope.jsonl"
    events, skipped = read_audit_lines(missing)
    assert events == []
    assert skipped == 0
```

- [ ] **Step 5: Run — expect failure**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -10
```

Expected: `ImportError: cannot import name 'read_audit_lines' from 'cognithor.api.crew_traces'`.

- [ ] **Step 6: Implement `read_audit_lines` in `crew_traces.py`**

Create `src/cognithor/api/crew_traces.py`:

```python
"""Trace-UI REST endpoints — read crew audit events from JSONL.

Endpoints (all owner-gated):
  GET /api/crew/traces?status=&since=&limit=
  GET /api/crew/trace/{trace_id}
  GET /api/crew/trace/{trace_id}/stats

Source: ~/.cognithor/logs/audit.jsonl (Hashline-Guard chain). Corrupt
lines are skipped with a counter surfaced in response meta.
"""

from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import Any

log = logging.getLogger(__name__)


def read_audit_lines(path: Path) -> tuple[list[dict[str, Any]], int]:
    """Read JSONL audit events. Returns (events, skipped_corrupt_count).

    Missing file → ([], 0). Corrupt JSON lines are logged and skipped;
    valid lines are returned in file order.
    """
    if not path.exists():
        return [], 0

    events: list[dict[str, Any]] = []
    skipped = 0
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        log.warning("audit_jsonl_read_failed path=%s", path, exc_info=exc)
        return [], 0

    for line_no, raw in enumerate(text.splitlines(), start=1):
        line = raw.strip()
        if not line:
            continue
        try:
            obj = json.loads(line)
        except json.JSONDecodeError:
            log.error("audit_jsonl_corruption path=%s line_no=%d", path, line_no)
            skipped += 1
            continue
        if isinstance(obj, dict):
            events.append(obj)
        else:
            skipped += 1
    return events, skipped
```

- [ ] **Step 7: Run — expect pass**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -10
```

Expected: 4 passed.

- [ ] **Step 8: Commit**

```bash
git add src/cognithor/api/__init__.py src/cognithor/api/crew_traces.py tests/test_api/__init__.py tests/test_api/fixtures/sample_audit.jsonl tests/test_api/test_crew_traces.py
git commit -m "feat(api): scaffold crew-traces module with corruption-tolerant JSONL reader"
```

---

### Task 10: `group_by_trace` + derived metadata

**Files:**
- Modify: `src/cognithor/api/crew_traces.py`
- Modify: `tests/test_api/test_crew_traces.py`

- [ ] **Step 1: Append failing test**

```python
def test_group_by_trace_groups_events_by_session_id() -> None:
    from cognithor.api.crew_traces import group_by_trace, read_audit_lines

    events, _ = read_audit_lines(FIXTURE)
    grouped = group_by_trace(events)
    assert "trace-aaa" in grouped
    assert "trace-bbb" in grouped
    assert len(grouped["trace-aaa"]) == 4  # kickoff + task_started + task_completed + kickoff_completed
    assert len(grouped["trace-bbb"]) == 1


def test_derive_trace_meta_computes_status_and_aggregates() -> None:
    from cognithor.api.crew_traces import derive_trace_meta, read_audit_lines

    events, _ = read_audit_lines(FIXTURE)
    aaa_events = [e for e in events if e["session_id"] == "trace-aaa"]
    meta = derive_trace_meta("trace-aaa", aaa_events)
    assert meta["trace_id"] == "trace-aaa"
    assert meta["status"] == "completed"  # crew_kickoff_completed seen
    assert meta["n_tasks"] == 2
    assert meta["total_tokens"] == 1234
    assert meta["agent_count"] == 1
    assert meta["started_at"] == "2026-04-26T10:00:00Z"
    assert meta["ended_at"] == "2026-04-26T10:00:06Z"


def test_derive_trace_meta_returns_running_status_for_unfinished_trace() -> None:
    from cognithor.api.crew_traces import derive_trace_meta, read_audit_lines

    events, _ = read_audit_lines(FIXTURE)
    bbb_events = [e for e in events if e["session_id"] == "trace-bbb"]
    meta = derive_trace_meta("trace-bbb", bbb_events)
    assert meta["status"] == "running"
    assert meta["ended_at"] is None
```

- [ ] **Step 2: Run — expect failures**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -10
```

Expected: 3 new tests fail with ImportError.

- [ ] **Step 3: Implement `group_by_trace` + `derive_trace_meta`**

Append to `src/cognithor/api/crew_traces.py`:

```python
def group_by_trace(
    events: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
    """Group events by `session_id` (== trace_id), preserving file order."""
    grouped: dict[str, list[dict[str, Any]]] = {}
    for ev in events:
        tid = ev.get("session_id")
        if not tid:
            continue
        grouped.setdefault(tid, []).append(ev)
    return grouped


def _event_type(ev: dict[str, Any]) -> str:
    return str(ev.get("event_type") or ev.get("event") or "")


def _details(ev: dict[str, Any]) -> dict[str, Any]:
    d = ev.get("details")
    return d if isinstance(d, dict) else {}


def derive_trace_meta(
    trace_id: str, events: list[dict[str, Any]]
) -> dict[str, Any]:
    """Compute summary metadata for one trace's events.

    Returns: {trace_id, status, started_at, ended_at, duration_ms,
              n_tasks, total_tokens, agent_count, n_failed_guardrails}
    """
    started_at: str | None = None
    ended_at: str | None = None
    n_tasks = 0
    total_tokens = 0
    agents: set[str] = set()
    n_failed_guardrails = 0
    has_kickoff_completed = False
    has_kickoff_failed = False

    for ev in events:
        et = _event_type(ev)
        ts = ev.get("timestamp")
        details = _details(ev)
        if et == "crew_kickoff_started":
            started_at = ts if isinstance(ts, str) else started_at
            n_tasks = int(details.get("n_tasks", n_tasks) or n_tasks)
        elif et == "crew_kickoff_completed":
            has_kickoff_completed = True
            ended_at = ts if isinstance(ts, str) else ended_at
        elif et == "crew_kickoff_failed":
            has_kickoff_failed = True
            ended_at = ts if isinstance(ts, str) else ended_at
        elif et == "crew_task_started":
            role = details.get("agent_role")
            if isinstance(role, str):
                agents.add(role)
        elif et == "crew_task_completed":
            tokens = details.get("tokens", 0) or 0
            try:
                total_tokens += int(tokens)
            except (TypeError, ValueError):
                pass
        elif et == "crew_guardrail_check":
            if details.get("verdict") == "fail":
                n_failed_guardrails += 1

    if has_kickoff_failed:
        status = "failed"
    elif has_kickoff_completed:
        status = "completed"
    else:
        status = "running"

    duration_ms: float | None = None
    if started_at and ended_at:
        try:
            from datetime import datetime as _dt

            t0 = _dt.fromisoformat(started_at.replace("Z", "+00:00"))
            t1 = _dt.fromisoformat(ended_at.replace("Z", "+00:00"))
            duration_ms = (t1 - t0).total_seconds() * 1000.0
        except ValueError:
            duration_ms = None

    return {
        "trace_id": trace_id,
        "status": status,
        "started_at": started_at,
        "ended_at": ended_at,
        "duration_ms": duration_ms,
        "n_tasks": n_tasks,
        "total_tokens": total_tokens,
        "agent_count": len(agents),
        "n_failed_guardrails": n_failed_guardrails,
    }
```

- [ ] **Step 4: Run — expect pass**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -10
```

Expected: 7 passed (4 prior + 3 new).

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/api/crew_traces.py tests/test_api/test_crew_traces.py
git commit -m "feat(api): add group_by_trace + derive_trace_meta helpers"
```

---

### Task 11: `derive_trace_stats` (sidebar aggregates)

**Files:**
- Modify: `src/cognithor/api/crew_traces.py`
- Modify: `tests/test_api/test_crew_traces.py`

- [ ] **Step 1: Append failing test**

```python
def test_derive_trace_stats_aggregates_per_agent_tokens() -> None:
    from cognithor.api.crew_traces import derive_trace_stats, read_audit_lines

    events, _ = read_audit_lines(FIXTURE)
    aaa_events = [e for e in events if e["session_id"] == "trace-aaa"]
    stats = derive_trace_stats(aaa_events)

    assert stats["total_tokens"] == 1234
    assert stats["agent_breakdown"] == {"researcher": 1234}
    assert stats["guardrail_summary"]["pass"] == 0
    assert stats["guardrail_summary"]["fail"] == 0
    assert stats["guardrail_summary"]["retries"] == 0


def test_derive_trace_stats_counts_guardrail_verdicts(tmp_path: Path) -> None:
    from cognithor.api.crew_traces import derive_trace_stats, read_audit_lines

    f = tmp_path / "stats.jsonl"
    f.write_text(
        "\n".join([
            '{"session_id":"x","event_type":"crew_task_started","details":{"agent_role":"a","task_id":"t1"}}',
            '{"session_id":"x","event_type":"crew_guardrail_check","details":{"verdict":"fail","retry_count":1}}',
            '{"session_id":"x","event_type":"crew_guardrail_check","details":{"verdict":"pass","retry_count":0}}',
            '{"session_id":"x","event_type":"crew_task_completed","details":{"task_id":"t1","tokens":500}}',
        ])
        + "\n",
        encoding="utf-8",
    )
    events, _ = read_audit_lines(f)
    stats = derive_trace_stats(events)
    assert stats["guardrail_summary"]["pass"] == 1
    assert stats["guardrail_summary"]["fail"] == 1
    assert stats["guardrail_summary"]["retries"] == 1
    assert stats["agent_breakdown"] == {"a": 500}
```

- [ ] **Step 2: Run — expect failure**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -10
```

Expected: ImportError on `derive_trace_stats`.

- [ ] **Step 3: Implement `derive_trace_stats`**

Append to `src/cognithor/api/crew_traces.py`:

```python
def derive_trace_stats(events: list[dict[str, Any]]) -> dict[str, Any]:
    """Compute Stats-Sidebar aggregates: per-agent tokens + guardrail summary."""
    total_tokens = 0
    agent_breakdown: dict[str, int] = {}
    guardrail_pass = 0
    guardrail_fail = 0
    guardrail_retries = 0

    # Track current agent per task_id so we can attribute tokens correctly.
    task_agent: dict[str, str] = {}
    for ev in events:
        et = _event_type(ev)
        details = _details(ev)
        if et == "crew_task_started":
            tid = str(details.get("task_id", ""))
            role = details.get("agent_role")
            if tid and isinstance(role, str):
                task_agent[tid] = role
        elif et == "crew_task_completed":
            tid = str(details.get("task_id", ""))
            tokens_val = details.get("tokens", 0) or 0
            try:
                tok = int(tokens_val)
            except (TypeError, ValueError):
                tok = 0
            total_tokens += tok
            role = task_agent.get(tid)
            if role:
                agent_breakdown[role] = agent_breakdown.get(role, 0) + tok
        elif et == "crew_guardrail_check":
            verdict = details.get("verdict")
            retry_count_val = details.get("retry_count", 0) or 0
            try:
                rc = int(retry_count_val)
            except (TypeError, ValueError):
                rc = 0
            guardrail_retries += rc
            if verdict == "pass":
                guardrail_pass += 1
            elif verdict == "fail":
                guardrail_fail += 1

    total_duration_ms: float | None = None
    if events:
        # Attempt to compute total duration from first task_started → last task_completed.
        first_ts: str | None = None
        last_ts: str | None = None
        for ev in events:
            ts = ev.get("timestamp")
            if not isinstance(ts, str):
                continue
            if first_ts is None:
                first_ts = ts
            last_ts = ts
        if first_ts and last_ts:
            try:
                from datetime import datetime as _dt

                t0 = _dt.fromisoformat(first_ts.replace("Z", "+00:00"))
                t1 = _dt.fromisoformat(last_ts.replace("Z", "+00:00"))
                total_duration_ms = (t1 - t0).total_seconds() * 1000.0
            except ValueError:
                pass

    return {
        "total_tokens": total_tokens,
        "total_duration_ms": total_duration_ms,
        "agent_breakdown": agent_breakdown,
        "guardrail_summary": {
            "pass": guardrail_pass,
            "fail": guardrail_fail,
            "retries": guardrail_retries,
        },
    }
```

- [ ] **Step 4: Run — expect pass**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -10
```

Expected: 9 passed.

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/api/crew_traces.py tests/test_api/test_crew_traces.py
git commit -m "feat(api): add derive_trace_stats with per-agent token attribution"
```

---

### Task 12: FastAPI router + 3 endpoints (no auth yet)

**Files:**
- Modify: `src/cognithor/api/crew_traces.py`
- Modify: `tests/test_api/test_crew_traces.py`

- [ ] **Step 1: Append failing test**

```python
def test_list_traces_endpoint_returns_grouped_meta(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    from fastapi.testclient import TestClient

    from cognithor.api.crew_traces import router

    monkeypatch.setattr("cognithor.api.crew_traces._audit_path", lambda: FIXTURE)

    from fastapi import FastAPI

    app = FastAPI()
    app.include_router(router)
    client = TestClient(app)
    resp = client.get("/api/crew/traces")
    assert resp.status_code == 200
    body = resp.json()
    assert "traces" in body
    trace_ids = [t["trace_id"] for t in body["traces"]]
    assert "trace-aaa" in trace_ids
    assert "trace-bbb" in trace_ids


def test_get_trace_endpoint_returns_events(monkeypatch: pytest.MonkeyPatch) -> None:
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

    from cognithor.api.crew_traces import router

    monkeypatch.setattr("cognithor.api.crew_traces._audit_path", lambda: FIXTURE)
    app = FastAPI()
    app.include_router(router)
    client = TestClient(app)
    resp = client.get("/api/crew/trace/trace-aaa")
    assert resp.status_code == 200
    body = resp.json()
    assert body["trace_id"] == "trace-aaa"
    assert len(body["events"]) == 4
    assert body["meta"]["skipped_lines"] == 1


def test_get_trace_endpoint_404_for_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

    from cognithor.api.crew_traces import router

    monkeypatch.setattr("cognithor.api.crew_traces._audit_path", lambda: FIXTURE)
    app = FastAPI()
    app.include_router(router)
    client = TestClient(app)
    resp = client.get("/api/crew/trace/does-not-exist")
    assert resp.status_code == 404
    assert resp.json()["detail"]["error"] == "trace_not_found"


def test_get_trace_stats_endpoint_returns_aggregates(monkeypatch: pytest.MonkeyPatch) -> None:
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

    from cognithor.api.crew_traces import router

    monkeypatch.setattr("cognithor.api.crew_traces._audit_path", lambda: FIXTURE)
    app = FastAPI()
    app.include_router(router)
    client = TestClient(app)
    resp = client.get("/api/crew/trace/trace-aaa/stats")
    assert resp.status_code == 200
    body = resp.json()
    assert body["total_tokens"] == 1234
    assert "agent_breakdown" in body
```

- [ ] **Step 2: Run — expect failures**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -15
```

Expected: 4 new tests fail with `ImportError: cannot import name 'router' from 'cognithor.api.crew_traces'`.

- [ ] **Step 3: Implement FastAPI router + endpoints (no owner-gating yet)**

Append to `src/cognithor/api/crew_traces.py`:

```python
from fastapi import APIRouter, HTTPException

router = APIRouter(prefix="/api/crew", tags=["crew-traces"])


def _audit_path() -> Path:
    """Return the on-disk path to the audit JSONL. Override via monkeypatch in tests."""
    from cognithor.config import load_config

    cfg = load_config()
    return Path(cfg.cognithor_home) / "logs" / "audit.jsonl"


@router.get("/traces")
def list_traces() -> dict[str, Any]:
    """List all traces with derived metadata."""
    events, skipped = read_audit_lines(_audit_path())
    grouped = group_by_trace(events)
    traces = [derive_trace_meta(tid, evs) for tid, evs in grouped.items()]
    # Sort newest first by started_at (None values last).
    traces.sort(
        key=lambda t: (t["started_at"] is None, t["started_at"] or ""),
        reverse=True,
    )
    return {"traces": traces, "meta": {"skipped_lines": skipped}}


@router.get("/trace/{trace_id}")
def get_trace(trace_id: str) -> dict[str, Any]:
    """Return full event list for one trace_id."""
    events, skipped = read_audit_lines(_audit_path())
    grouped = group_by_trace(events)
    if trace_id not in grouped:
        raise HTTPException(
            status_code=404,
            detail={"error": "trace_not_found", "trace_id": trace_id},
        )
    return {
        "trace_id": trace_id,
        "events": grouped[trace_id],
        "meta": {"skipped_lines": skipped},
    }


@router.get("/trace/{trace_id}/stats")
def get_trace_stats(trace_id: str) -> dict[str, Any]:
    """Return derived per-trace aggregates."""
    events, _skipped = read_audit_lines(_audit_path())
    grouped = group_by_trace(events)
    if trace_id not in grouped:
        raise HTTPException(
            status_code=404,
            detail={"error": "trace_not_found", "trace_id": trace_id},
        )
    return derive_trace_stats(grouped[trace_id])
```

- [ ] **Step 4: Run — expect pass**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -15
```

Expected: 13 passed (9 prior + 4 new).

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/api/crew_traces.py tests/test_api/test_crew_traces.py
git commit -m "feat(api): add /api/crew/traces, /trace/{id}, /trace/{id}/stats endpoints"
```

---

### Task 13: Owner-gating dependency

**Files:**
- Modify: `src/cognithor/api/crew_traces.py`
- Modify: `tests/test_api/test_crew_traces.py`

- [ ] **Step 1: Append failing test**

```python
def test_list_traces_403_for_non_owner_token(monkeypatch: pytest.MonkeyPatch) -> None:
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

    from cognithor.api.crew_traces import router

    monkeypatch.setattr("cognithor.api.crew_traces._audit_path", lambda: FIXTURE)
    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "real-owner")

    app = FastAPI()
    app.include_router(router)
    client = TestClient(app)
    resp = client.get("/api/crew/traces", headers={"X-Cognithor-User": "guest"})
    assert resp.status_code == 403
    assert resp.json()["detail"]["error"] == "owner_only"


def test_list_traces_200_for_owner_token(monkeypatch: pytest.MonkeyPatch) -> None:
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

    from cognithor.api.crew_traces import router

    monkeypatch.setattr("cognithor.api.crew_traces._audit_path", lambda: FIXTURE)
    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "owner-x")

    app = FastAPI()
    app.include_router(router)
    client = TestClient(app)
    resp = client.get("/api/crew/traces", headers={"X-Cognithor-User": "owner-x"})
    assert resp.status_code == 200
```

- [ ] **Step 2: Run — expect failures**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -15
```

Expected: both new tests fail (403 not produced; or no header read).

- [ ] **Step 3: Add owner-gating dependency**

In `src/cognithor/api/crew_traces.py`, replace the `from fastapi import APIRouter, HTTPException` line with the larger imports + dependency:

```python
from fastapi import APIRouter, Depends, Header, HTTPException

from cognithor.security.owner import OwnerRequiredError, require_owner


def _require_owner_dep(
    x_cognithor_user: str | None = Header(default=None, alias="X-Cognithor-User"),
) -> str:
    """FastAPI dependency: extract user from header, enforce owner gate."""
    try:
        require_owner(x_cognithor_user)
    except OwnerRequiredError as exc:
        raise HTTPException(
            status_code=403,
            detail={"error": "owner_only", "message": str(exc)},
        ) from exc
    return x_cognithor_user or ""


router = APIRouter(prefix="/api/crew", tags=["crew-traces"])
```

Then add `_user: str = Depends(_require_owner_dep)` to each of the three endpoint signatures:

```python
@router.get("/traces")
def list_traces(_user: str = Depends(_require_owner_dep)) -> dict[str, Any]:
    ...


@router.get("/trace/{trace_id}")
def get_trace(
    trace_id: str, _user: str = Depends(_require_owner_dep)
) -> dict[str, Any]:
    ...


@router.get("/trace/{trace_id}/stats")
def get_trace_stats(
    trace_id: str, _user: str = Depends(_require_owner_dep)
) -> dict[str, Any]:
    ...
```

- [ ] **Step 4: Update existing tests to send the header (since gating is now active)**

The 4 endpoint tests from Task 12 will now return 403 because they don't send the header. Update each to set the env + header:

In each of `test_list_traces_endpoint_returns_grouped_meta`, `test_get_trace_endpoint_returns_events`, `test_get_trace_endpoint_404_for_unknown`, `test_get_trace_stats_endpoint_returns_aggregates`:

Add at the top:

```python
    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "test-owner")
```

And add the header to every `client.get(...)` call:

```python
    resp = client.get("/api/crew/traces", headers={"X-Cognithor-User": "test-owner"})
```

(Same pattern for the other endpoints.) Note: the test signatures need to include `monkeypatch: pytest.MonkeyPatch` parameter; if not already present, add it.

- [ ] **Step 5: Run — expect all green**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -20
```

Expected: 15 passed (4 prior endpoint tests still pass with header; 2 new owner tests pass; 9 helper tests unchanged).

- [ ] **Step 6: Commit**

```bash
git add src/cognithor/api/crew_traces.py tests/test_api/test_crew_traces.py
git commit -m "feat(api): owner-gate crew-traces endpoints via X-Cognithor-User header"
```

---

### Task 14: Filter params on `/api/crew/traces`

**Files:**
- Modify: `src/cognithor/api/crew_traces.py`
- Modify: `tests/test_api/test_crew_traces.py`

- [ ] **Step 1: Append failing test**

```python
def test_list_traces_filters_by_status(monkeypatch: pytest.MonkeyPatch) -> None:
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

    from cognithor.api.crew_traces import router

    monkeypatch.setattr("cognithor.api.crew_traces._audit_path", lambda: FIXTURE)
    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "owner")
    app = FastAPI()
    app.include_router(router)
    client = TestClient(app)

    resp = client.get(
        "/api/crew/traces?status=running",
        headers={"X-Cognithor-User": "owner"},
    )
    assert resp.status_code == 200
    statuses = [t["status"] for t in resp.json()["traces"]]
    assert "running" in statuses
    assert "completed" not in statuses


def test_list_traces_respects_limit(monkeypatch: pytest.MonkeyPatch) -> None:
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

    from cognithor.api.crew_traces import router

    monkeypatch.setattr("cognithor.api.crew_traces._audit_path", lambda: FIXTURE)
    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "owner")
    app = FastAPI()
    app.include_router(router)
    client = TestClient(app)

    resp = client.get(
        "/api/crew/traces?limit=1",
        headers={"X-Cognithor-User": "owner"},
    )
    assert resp.status_code == 200
    assert len(resp.json()["traces"]) == 1
```

- [ ] **Step 2: Run — expect failures**

```bash
pytest tests/test_api/test_crew_traces.py::test_list_traces_filters_by_status tests/test_api/test_crew_traces.py::test_list_traces_respects_limit -v 2>&1 | tail -10
```

Expected: both fail (filter param ignored).

- [ ] **Step 3: Update `list_traces` to accept Query params**

In `src/cognithor/api/crew_traces.py`, replace the `list_traces` function with:

```python
from fastapi import Query


@router.get("/traces")
def list_traces(
    status: str | None = Query(default=None, description="Filter by status (running|completed|failed)"),
    since: str | None = Query(default=None, description="Filter to traces started after ISO-8601 timestamp"),
    limit: int = Query(default=50, ge=1, le=1000, description="Max number of traces to return"),
    _user: str = Depends(_require_owner_dep),
) -> dict[str, Any]:
    events, skipped = read_audit_lines(_audit_path())
    grouped = group_by_trace(events)
    traces = [derive_trace_meta(tid, evs) for tid, evs in grouped.items()]
    if status:
        traces = [t for t in traces if t["status"] == status]
    if since:
        traces = [t for t in traces if t["started_at"] and t["started_at"] >= since]
    traces.sort(
        key=lambda t: (t["started_at"] is None, t["started_at"] or ""),
        reverse=True,
    )
    traces = traces[:limit]
    return {"traces": traces, "meta": {"skipped_lines": skipped}}
```

- [ ] **Step 4: Run — expect pass**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -15
```

Expected: 17 passed (15 prior + 2 new).

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/api/crew_traces.py tests/test_api/test_crew_traces.py
git commit -m "feat(api): add status/since/limit query params to /api/crew/traces"
```

---

### Task 15: Mount router into existing FastAPI app

**Files:**
- Modify: `src/cognithor/api/__init__.py` (or wherever FastAPI app lives)

- [ ] **Step 1: Find where the existing FastAPI app is constructed**

```bash
grep -rln "FastAPI()" src/cognithor/ | head -5
```

Expected: one or two matches, probably under `src/cognithor/api/` or `src/cognithor/web/` or `src/cognithor/server/`. Investigate the file to see how other routers are mounted (search for `include_router`).

- [ ] **Step 2: Add the import + mount in the matching app file**

Locate the section that does `app = FastAPI(...)` and the existing `app.include_router(...)` calls. Add:

```python
from cognithor.api.crew_traces import router as crew_traces_router
# ... existing routers ...
app.include_router(crew_traces_router)
```

If no FastAPI app exists yet (which would mean Cognithor hasn't shipped a HTTP API previously — possible — verify via grep), add a smoke-test in `tests/test_api/test_crew_traces.py`:

```python
def test_app_smoke_includes_crew_traces_router() -> None:
    """Verify the application factory mounts the crew-traces router."""
    try:
        from cognithor.api import build_app  # type: ignore[attr-defined]
    except ImportError:
        pytest.skip("Application factory not present; skipping mount smoke")
    app = build_app()
    paths = {r.path for r in app.routes if hasattr(r, "path")}
    assert "/api/crew/traces" in paths
    assert "/api/crew/trace/{trace_id}" in paths
```

If `build_app` doesn't exist, the test skips — the mount is verified manually via curl after deploy.

- [ ] **Step 3: Run smoke + all api tests**

```bash
pytest tests/test_api/test_crew_traces.py -v 2>&1 | tail -10
```

Expected: green.

- [ ] **Step 4: Commit (only if app file was modified)**

```bash
git add src/cognithor/api/__init__.py  # or whichever file got the include_router
git commit -m "feat(api): mount crew-traces router on FastAPI app"
```

If no app modification was needed (no existing FastAPI app), skip this commit.

---

### Task 16: API documentation

**Files:**
- Create: `docs/api/crew-traces.md`
- Create: `docs/api/__init__.md` (or just verify `docs/api/` exists)

- [ ] **Step 1: Verify docs/api/ exists**

```bash
mkdir -p docs/api
```

- [ ] **Step 2: Write API docs**

Create `docs/api/crew-traces.md`:

```markdown
# Crew-Traces REST API

Endpoints for reading the cognithor.crew Hashline-Guard audit chain.

All endpoints require a valid owner token via `X-Cognithor-User` header.
Non-owner requests return HTTP 403 with body `{"detail": {"error": "owner_only", ...}}`.

## `GET /api/crew/traces`

List all traces with derived metadata, newest first.

**Query parameters:**
- `status` (string, optional) — filter by `running` / `completed` / `failed`.
- `since` (ISO-8601 timestamp, optional) — only traces started at-or-after this time.
- `limit` (int, default 50, max 1000) — max number of traces returned.

**Response:**

```json
{
  "traces": [
    {
      "trace_id": "abc...",
      "status": "running",
      "started_at": "2026-04-26T10:00:00Z",
      "ended_at": null,
      "duration_ms": null,
      "n_tasks": 4,
      "total_tokens": 1213,
      "agent_count": 1,
      "n_failed_guardrails": 0
    }
  ],
  "meta": {"skipped_lines": 0}
}
```

`meta.skipped_lines` is the count of corrupt JSONL lines encountered during the read.

## `GET /api/crew/trace/{trace_id}`

Return the full event list for one trace.

**Response:**

```json
{
  "trace_id": "abc...",
  "events": [
    {"hash": "...", "timestamp": "...", "session_id": "abc...", "event_type": "crew_kickoff_started", "details": {...}}
  ],
  "meta": {"skipped_lines": 0}
}
```

**404 response:** `{"detail": {"error": "trace_not_found", "trace_id": "..."}}`

## `GET /api/crew/trace/{trace_id}/stats`

Return derived aggregates for the Stats Sidebar.

**Response:**

```json
{
  "total_tokens": 4612,
  "total_duration_ms": 23010.0,
  "agent_breakdown": {"researcher": 1213, "analyzer": 3399},
  "guardrail_summary": {"pass": 2, "fail": 1, "retries": 1}
}
```

## Source

All endpoints read from `~/.cognithor/logs/audit.jsonl` (Hashline-Guard chain).
Corrupt lines are silently skipped; the count is surfaced via `meta.skipped_lines`.
```

- [ ] **Step 3: Commit**

```bash
git add docs/api/crew-traces.md
git commit -m "docs(api): add crew-traces endpoint reference"
```

---

### Task 17: Coverage + mypy + lint pass

**Files:** none (verification only)

- [ ] **Step 1: Run targeted coverage**

```bash
pytest tests/test_api/test_crew_traces.py --cov=src/cognithor/api/crew_traces --cov-report=term-missing -q 2>&1 | tail -15
```

Expected: coverage ≥85% on `crew_traces.py`. If lower, add tests for any uncovered branches (`since` filter edge cases, empty file path, etc.).

- [ ] **Step 2: Run mypy --strict**

```bash
mypy --strict src/cognithor/api/crew_traces.py
```

Expected: clean. If errors:
- The `Path` from `pathlib` is correctly typed.
- `dict[str, Any]` returns are explicit.
- For `Header(default=None, alias=...)`, the `str | None` annotation is required.

- [ ] **Step 3: Run lint + format**

```bash
ruff check src/cognithor/api/ tests/test_api/
ruff format --check src/cognithor/api/ tests/test_api/
```

Expected: clean. If `ruff format --check` flags anything, run `ruff format <files>` and re-stage.

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

```bash
git add src/cognithor/api/ tests/test_api/
git commit -m "style(api): ruff format + mypy fixups"
```

(Skip if no changes.)

---

### Task 18: CHANGELOG + PR-2 closeout

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Add CHANGELOG entry**

In `CHANGELOG.md` `[Unreleased] ### Added` (it now has WP1 entries from PR 1), append:

```markdown
- Three FastAPI endpoints under `/api/crew/`: `traces` (list), `trace/{id}` (full events), `trace/{id}/stats` (aggregates). Owner-gated via `X-Cognithor-User` header. Reads `~/.cognithor/logs/audit.jsonl` directly; corrupt lines are skipped with the count surfaced via `meta.skipped_lines` (WP2 of v0.95.0 Trace-UI).
- `docs/api/crew-traces.md` — endpoint reference.
```

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): note WP2 REST API additions for v0.95.0"
```

- [ ] **Step 2: Full regression**

```bash
pytest tests/ -x -q --cov=src/cognithor --cov-fail-under=89 2>&1 | tail -10
```

Expected: green.

- [ ] **Step 3: Push + open PR**

```bash
git push -u origin feat/cognithor-trace-v2-api
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
cat > /tmp/pr_trace_v2_body.json <<'JSON_EOF'
{
  "title": "feat(api): WP2 REST API for Crew-Traces (v0.95.0 PR 2)",
  "head": "feat/cognithor-trace-v2-api",
  "base": "main",
  "body": "## Summary\n- Three FastAPI endpoints under `/api/crew/`: traces (list), trace/{id} (events), trace/{id}/stats (aggregates).\n- All endpoints owner-gated via `X-Cognithor-User` header (uses `cognithor.security.owner.require_owner` from PR 1).\n- Reads `~/.cognithor/logs/audit.jsonl` directly with corruption-tolerant parsing; corrupt lines surfaced as `meta.skipped_lines`.\n- API reference at `docs/api/crew-traces.md`.\n\n## Spec\n- `docs/superpowers/specs/2026-04-26-trace-ui-v095-design.md` §8.2\n\n## Test plan\n- [x] `pytest tests/test_api/test_crew_traces.py -q` green (≥85% coverage on `crew_traces.py`)\n- [x] `mypy --strict src/cognithor/api/crew_traces.py` clean\n- [x] `ruff check` + `ruff format --check` clean\n- [x] `pytest tests/ -x -q --cov-fail-under=89` green (no regression)\n- [x] 403 verified for non-owner; 404 verified for missing trace_id; corrupt-line tolerance verified."
}
JSON_EOF
cat /tmp/pr_trace_v2_body.json | curl -sL -X POST -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github+json" --data-binary @- "https://api.github.com/repos/Alex8791-cyber/cognithor/pulls" | python -c "import sys,json; d=json.load(sys.stdin); print(f\"PR #{d.get('number')}: {d.get('html_url')}\") if 'number' in d else print('error:', d.get('message'),'errors:',d.get('errors'))"
rm -f /tmp/pr_trace_v2_body.json
```

- [ ] **Step 4: Wait CI green, squash-merge**

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
python -c "import json; print(json.dumps({'commit_title':'feat(api): WP2 REST API for Crew-Traces (v0.95.0 PR 2) (#<NUM>)','merge_method':'squash'}))" | curl -sL -X PUT -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github+json" --data-binary @- "https://api.github.com/repos/Alex8791-cyber/cognithor/pulls/<NUM>/merge"
```

- [ ] **Step 5: Cleanup in a separate turn** (same pattern as Task 8 Step 8)

---

# PR 3 — WP3 WebSocket Live-Stream (Tasks 19-26)

Implements spec §8.3 — extends `cognithor.channels.webui` with three new client→server message types (`crew_lifecycle_subscribe`, `crew_subscribe`, `crew_unsubscribe`) and bridges TraceBus → WebSocket fan-out. Owner-gated. Auto-cleanup on disconnect.

**Branch:** `feat/cognithor-trace-v3-ws` cut from latest `main` (after PR 2 merged).

**PR-3 closeout target:** Owner-Session bekommt Lifecycle-Events nach Subscribe; Topic-Subscribe routet Events nur an passende Sessions; Disconnect-Cleanup verifiziert; ≥85% coverage.

---

### Task 19: Branch + WS test scaffolding

**Files:**
- Create: `tests/test_channels/test_trace_ws.py`
- Create: `tests/test_channels/__init__.py` (if missing)

- [ ] **Step 1: Cut feature branch**

```bash
git checkout main
git pull --ff-only
git checkout -b feat/cognithor-trace-v3-ws
```

- [ ] **Step 2: Inspect existing webui.py for the WS message-handler pattern**

```bash
grep -n "async def\|message\[\"type\"\]\|websocket.send_json\|client_id" src/cognithor/channels/webui.py | head -30
```

Read enough of `src/cognithor/channels/webui.py` to understand:
- How the WebSocket connection is accepted
- How incoming messages are routed by `type`
- How `send_pipeline_event` (or equivalent) sends to a session

This determines the exact integration point for the new handlers.

- [ ] **Step 3: Verify test_channels exists**

```bash
mkdir -p tests/test_channels
test -f tests/test_channels/__init__.py || touch tests/test_channels/__init__.py
```

- [ ] **Step 4: Write a smoke-test that imports the channel module**

Create `tests/test_channels/test_trace_ws.py`:

```python
"""Tests for cognithor.channels.webui Trace-UI WebSocket additions.

These tests use the in-memory TraceBus + a mock WebSocket session to
verify subscribe/unsubscribe/cleanup behaviour without actually starting
a uvicorn server.
"""

from __future__ import annotations

import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import pytest


def test_webui_module_imports() -> None:
    """Sanity: webui module imports without error after our additions."""
    import cognithor.channels.webui  # noqa: F401
```

- [ ] **Step 5: Run — expect pass**

```bash
pytest tests/test_channels/test_trace_ws.py -v 2>&1 | tail -5
```

Expected: 1 passed (just an import sanity).

- [ ] **Step 6: Commit**

```bash
git add tests/test_channels/__init__.py tests/test_channels/test_trace_ws.py
git commit -m "test(channels): scaffold trace-ws test module"
```

---

### Task 20: Subscribe-state on the session object

**Files:**
- Modify: `src/cognithor/channels/webui.py`
- Modify: `tests/test_channels/test_trace_ws.py`

- [ ] **Step 1: Append failing test**

```python
@pytest.mark.asyncio
async def test_trace_subscriber_state_creates_handles_dict() -> None:
    """A WebSocket session that opts into trace events gets a per-session handle dict."""
    from cognithor.channels.webui import TraceSubscriberState

    state = TraceSubscriberState()
    assert state.lifecycle_handle is None
    assert state.topic_handles == {}


@pytest.mark.asyncio
async def test_trace_subscriber_state_clear_unsubscribes_everything() -> None:
    """clear_all() should unsubscribe lifecycle + every topic from the bus."""
    from cognithor.channels.webui import TraceSubscriberState
    from cognithor.crew.trace_bus import TraceBus, get_trace_bus

    bus = get_trace_bus()
    state = TraceSubscriberState()
    queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=10)

    state.lifecycle_handle = bus.subscribe_lifecycle(queue)
    state.topic_handles["trace-x"] = bus.subscribe("trace-x", queue)
    assert len(bus._subscribers) == 2  # noqa: SLF001 — internal check

    state.clear_all(bus)
    assert state.lifecycle_handle is None
    assert state.topic_handles == {}
```

- [ ] **Step 2: Run — expect failure**

```bash
pytest tests/test_channels/test_trace_ws.py -v 2>&1 | tail -10
```

Expected: `ImportError: cannot import name 'TraceSubscriberState'`.

- [ ] **Step 3: Add TraceSubscriberState to `webui.py`**

In `src/cognithor/channels/webui.py`, near the top (after imports, before the WebSocket handler), add:

```python
from dataclasses import dataclass, field

from cognithor.crew.trace_bus import SubscriptionHandle, TraceBus


@dataclass
class TraceSubscriberState:
    """Per-WebSocket-session state tracking active TraceBus subscriptions.

    Stored on the session object so we can cleanly unsubscribe everything
    when the WebSocket disconnects.
    """

    lifecycle_handle: SubscriptionHandle | None = None
    topic_handles: dict[str, SubscriptionHandle] = field(default_factory=dict)

    def clear_all(self, bus: TraceBus) -> None:
        """Unsubscribe everything this session is subscribed to."""
        if self.lifecycle_handle is not None:
            bus.unsubscribe(self.lifecycle_handle)
            self.lifecycle_handle = None
        for handle in list(self.topic_handles.values()):
            bus.unsubscribe(handle)
        self.topic_handles.clear()
```

(Add the import lines at the top with the other `from cognithor...` imports.)

- [ ] **Step 4: Run — expect pass**

```bash
pytest tests/test_channels/test_trace_ws.py -v 2>&1 | tail -10
```

Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/channels/webui.py tests/test_channels/test_trace_ws.py
git commit -m "feat(channels): add TraceSubscriberState dataclass for per-session subs"
```

---

### Task 21: `handle_trace_subscribe_message` (lifecycle + topic)

**Files:**
- Modify: `src/cognithor/channels/webui.py`
- Modify: `tests/test_channels/test_trace_ws.py`

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

```python
@pytest.mark.asyncio
async def test_handle_lifecycle_subscribe_for_owner_creates_handle(monkeypatch: pytest.MonkeyPatch) -> None:
    from cognithor.channels.webui import (
        TraceSubscriberState,
        handle_trace_subscribe_message,
    )
    from cognithor.crew.trace_bus import get_trace_bus

    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "owner")
    bus = get_trace_bus()
    state = TraceSubscriberState()
    sender = AsyncMock()

    err = await handle_trace_subscribe_message(
        message={"type": "crew_lifecycle_subscribe"},
        state=state,
        bus=bus,
        user_id="owner",
        sender=sender,
    )
    assert err is None
    assert state.lifecycle_handle is not None
    state.clear_all(bus)


@pytest.mark.asyncio
async def test_handle_topic_subscribe_creates_topic_handle(monkeypatch: pytest.MonkeyPatch) -> None:
    from cognithor.channels.webui import (
        TraceSubscriberState,
        handle_trace_subscribe_message,
    )
    from cognithor.crew.trace_bus import get_trace_bus

    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "owner")
    bus = get_trace_bus()
    state = TraceSubscriberState()
    sender = AsyncMock()

    err = await handle_trace_subscribe_message(
        message={"type": "crew_subscribe", "trace_id": "abc"},
        state=state,
        bus=bus,
        user_id="owner",
        sender=sender,
    )
    assert err is None
    assert "abc" in state.topic_handles
    state.clear_all(bus)


@pytest.mark.asyncio
async def test_handle_topic_unsubscribe_removes_handle(monkeypatch: pytest.MonkeyPatch) -> None:
    from cognithor.channels.webui import (
        TraceSubscriberState,
        handle_trace_subscribe_message,
    )
    from cognithor.crew.trace_bus import get_trace_bus

    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "owner")
    bus = get_trace_bus()
    state = TraceSubscriberState()
    sender = AsyncMock()

    await handle_trace_subscribe_message(
        message={"type": "crew_subscribe", "trace_id": "xyz"},
        state=state,
        bus=bus,
        user_id="owner",
        sender=sender,
    )
    assert "xyz" in state.topic_handles

    err = await handle_trace_subscribe_message(
        message={"type": "crew_unsubscribe", "trace_id": "xyz"},
        state=state,
        bus=bus,
        user_id="owner",
        sender=sender,
    )
    assert err is None
    assert "xyz" not in state.topic_handles


@pytest.mark.asyncio
async def test_handle_subscribe_rejects_non_owner(monkeypatch: pytest.MonkeyPatch) -> None:
    from cognithor.channels.webui import (
        TraceSubscriberState,
        handle_trace_subscribe_message,
    )
    from cognithor.crew.trace_bus import get_trace_bus

    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "real-owner")
    bus = get_trace_bus()
    state = TraceSubscriberState()
    sender = AsyncMock()

    err = await handle_trace_subscribe_message(
        message={"type": "crew_subscribe", "trace_id": "abc"},
        state=state,
        bus=bus,
        user_id="guest",
        sender=sender,
    )
    assert err == "owner_only"
    assert state.topic_handles == {}
    sender.assert_called_once()
    sent = sender.call_args.args[0]
    assert sent["type"] == "error"
    assert sent["code"] == "owner_only"


@pytest.mark.asyncio
async def test_handle_unknown_subscribe_type_returns_error(monkeypatch: pytest.MonkeyPatch) -> None:
    from cognithor.channels.webui import (
        TraceSubscriberState,
        handle_trace_subscribe_message,
    )
    from cognithor.crew.trace_bus import get_trace_bus

    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "owner")
    bus = get_trace_bus()
    state = TraceSubscriberState()
    sender = AsyncMock()

    err = await handle_trace_subscribe_message(
        message={"type": "crew_unknown_action"},
        state=state,
        bus=bus,
        user_id="owner",
        sender=sender,
    )
    assert err == "unknown_message_type"
```

- [ ] **Step 2: Run — expect failures**

```bash
pytest tests/test_channels/test_trace_ws.py -v 2>&1 | tail -15
```

Expected: 5 failures (`handle_trace_subscribe_message` not defined).

- [ ] **Step 3: Implement `handle_trace_subscribe_message` in `webui.py`**

Append to `src/cognithor/channels/webui.py` (after `TraceSubscriberState`):

```python
import asyncio as _asyncio

from cognithor.security.owner import OwnerRequiredError, require_owner

# Bounded queue size for trace subscribers; matches TraceBus default.
_TRACE_QUEUE_MAXSIZE = 1000


async def handle_trace_subscribe_message(
    *,
    message: dict[str, Any],
    state: TraceSubscriberState,
    bus: TraceBus,
    user_id: str | None,
    sender: Any,
) -> str | None:
    """Handle a crew_*_subscribe / crew_unsubscribe message.

    Returns None on success, or a short error code string on failure
    (also sent to the client via `sender` as an error frame).
    """
    msg_type = message.get("type")
    if msg_type not in {
        "crew_lifecycle_subscribe",
        "crew_subscribe",
        "crew_unsubscribe",
    }:
        return "unknown_message_type"

    # Owner-gate every trace WS message.
    try:
        require_owner(user_id)
    except OwnerRequiredError:
        await sender(
            {"type": "error", "code": "owner_only", "context": msg_type}
        )
        return "owner_only"

    if msg_type == "crew_lifecycle_subscribe":
        if state.lifecycle_handle is None:
            queue: _asyncio.Queue[dict[str, Any]] = _asyncio.Queue(
                maxsize=_TRACE_QUEUE_MAXSIZE
            )
            state.lifecycle_handle = bus.subscribe_lifecycle(queue)
        return None

    if msg_type == "crew_subscribe":
        trace_id = message.get("trace_id")
        if not isinstance(trace_id, str) or not trace_id:
            await sender({"type": "error", "code": "invalid_trace_id"})
            return "invalid_trace_id"
        if trace_id in state.topic_handles:
            return None  # idempotent
        queue = _asyncio.Queue(maxsize=_TRACE_QUEUE_MAXSIZE)
        state.topic_handles[trace_id] = bus.subscribe(trace_id, queue)
        return None

    if msg_type == "crew_unsubscribe":
        trace_id = message.get("trace_id")
        if not isinstance(trace_id, str) or not trace_id:
            return None  # silently ignore
        handle = state.topic_handles.pop(trace_id, None)
        if handle is not None:
            bus.unsubscribe(handle)
        return None

    return "unknown_message_type"  # unreachable
```

- [ ] **Step 4: Run — expect pass**

```bash
pytest tests/test_channels/test_trace_ws.py -v 2>&1 | tail -15
```

Expected: 8 passed (3 prior + 5 new).

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/channels/webui.py tests/test_channels/test_trace_ws.py
git commit -m "feat(channels): handle_trace_subscribe_message routes lifecycle + topic + error frames"
```

---

### Task 22: Outbound queue→WebSocket pump

**Files:**
- Modify: `src/cognithor/channels/webui.py`
- Modify: `tests/test_channels/test_trace_ws.py`

- [ ] **Step 1: Append failing test**

```python
@pytest.mark.asyncio
async def test_pump_queue_to_websocket_forwards_lifecycle_events() -> None:
    """The pump task drains a subscriber queue and sends formatted frames."""
    from cognithor.channels.webui import (
        TraceSubscriberState,
        pump_queue_to_websocket,
    )
    from cognithor.crew.trace_bus import get_trace_bus

    bus = get_trace_bus()
    state = TraceSubscriberState()
    queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=10)
    state.lifecycle_handle = bus.subscribe_lifecycle(queue)
    sender = AsyncMock()

    pump_task = asyncio.create_task(pump_queue_to_websocket(queue, sender, "crew_lifecycle"))
    bus.publish({"event_type": "crew_kickoff_started", "trace_id": "abc", "n_tasks": 3})
    await asyncio.sleep(0.05)
    pump_task.cancel()
    try:
        await pump_task
    except asyncio.CancelledError:
        pass

    state.clear_all(bus)
    assert sender.call_count >= 1
    sent_frame = sender.call_args.args[0]
    assert sent_frame["type"] == "crew_lifecycle"
    assert sent_frame["payload"]["trace_id"] == "abc"
```

- [ ] **Step 2: Run — expect failure**

```bash
pytest tests/test_channels/test_trace_ws.py::test_pump_queue_to_websocket_forwards_lifecycle_events -v 2>&1 | tail -10
```

Expected: ImportError on `pump_queue_to_websocket`.

- [ ] **Step 3: Implement `pump_queue_to_websocket`**

Append to `src/cognithor/channels/webui.py`:

```python
async def pump_queue_to_websocket(
    queue: _asyncio.Queue[dict[str, Any]],
    sender: Any,
    frame_type: str,
) -> None:
    """Drain a subscriber queue, wrap each record in `{type, payload}`, and send.

    Runs as an asyncio Task; cancel to stop. On send-error, logs and
    continues — the pump should outlive transient WebSocket hiccups
    (the outer connection-handler will cancel us if the WS is truly dead).
    """
    while True:
        try:
            record = await queue.get()
        except _asyncio.CancelledError:
            raise
        try:
            await sender({"type": frame_type, "payload": record})
        except _asyncio.CancelledError:
            raise
        except Exception as exc:  # noqa: BLE001 — pump must survive
            log.warning(
                "trace_ws_send_failed type=%s err=%s",
                frame_type,
                type(exc).__name__,
            )
```

(`log` should already be imported at the top of `webui.py`. If not, add `import logging; log = logging.getLogger(__name__)`.)

- [ ] **Step 4: Run — expect pass**

```bash
pytest tests/test_channels/test_trace_ws.py::test_pump_queue_to_websocket_forwards_lifecycle_events -v 2>&1 | tail -5
```

Expected: 1 passed.

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/channels/webui.py tests/test_channels/test_trace_ws.py
git commit -m "feat(channels): add pump_queue_to_websocket for trace event forwarding"
```

---

### Task 23: Wire TraceSubscriberState into existing WebSocket handler

**Files:**
- Modify: `src/cognithor/channels/webui.py`

- [ ] **Step 1: Identify the existing WebSocket connection handler**

```bash
grep -n "async def\|@.*websocket\|websocket\." src/cognithor/channels/webui.py | head -30
```

Look for the main WS handler — typically named `websocket_endpoint`, `handle_connection`, or wired to a route like `/ws`. The handler receives messages in a loop (`while True: msg = await websocket.receive_json()`) and dispatches by `type`.

- [ ] **Step 2: Modify the existing dispatch loop**

In the existing handler, near the top of the connection (after `websocket.accept()` and any auth handshake), instantiate the subscriber state:

```python
trace_state = TraceSubscriberState()
trace_pumps: list[_asyncio.Task[None]] = []
trace_bus = get_trace_bus()
```

In the message-dispatch `if/elif` chain (where existing `if msg_type == "user_message": ...` clauses live), add a new branch BEFORE the fallback:

```python
elif msg_type in {"crew_lifecycle_subscribe", "crew_subscribe", "crew_unsubscribe"}:
    err = await handle_trace_subscribe_message(
        message=message,
        state=trace_state,
        bus=trace_bus,
        user_id=user_id,  # from existing auth
        sender=lambda frame: websocket.send_json(frame),
    )
    if err is None and msg_type == "crew_lifecycle_subscribe":
        # Spin up a pump for the new lifecycle subscription, if not already pumping.
        if trace_state.lifecycle_handle is not None and not any(
            getattr(t, "_trace_pump_topic", None) == "__lifecycle__" for t in trace_pumps
        ):
            queue = trace_state.lifecycle_handle.queue
            task = _asyncio.create_task(
                pump_queue_to_websocket(queue, websocket.send_json, "crew_lifecycle")
            )
            task._trace_pump_topic = "__lifecycle__"  # type: ignore[attr-defined]
            trace_pumps.append(task)
    elif err is None and msg_type == "crew_subscribe":
        trace_id = message.get("trace_id")
        if isinstance(trace_id, str) and trace_id in trace_state.topic_handles:
            queue = trace_state.topic_handles[trace_id].queue
            task = _asyncio.create_task(
                pump_queue_to_websocket(queue, websocket.send_json, "crew_event")
            )
            task._trace_pump_topic = trace_id  # type: ignore[attr-defined]
            trace_pumps.append(task)
    elif err is None and msg_type == "crew_unsubscribe":
        trace_id = message.get("trace_id")
        for t in list(trace_pumps):
            if getattr(t, "_trace_pump_topic", None) == trace_id:
                t.cancel()
                trace_pumps.remove(t)
```

**At the end of the connection handler** (in the `finally:` block where existing cleanup happens), add:

```python
finally:
    # ... existing cleanup ...
    for task in trace_pumps:
        task.cancel()
    trace_state.clear_all(trace_bus)
```

(Adapt to the actual structure of `webui.py` — the goal is: ensure `trace_state.clear_all` AND all pump tasks are cancelled when the WebSocket exits.)

- [ ] **Step 3: Smoke-import the module**

```bash
python -c "import cognithor.channels.webui; print('webui imports OK')"
```

Expected: `webui imports OK`.

- [ ] **Step 4: Run channel tests + crew tests**

```bash
pytest tests/test_channels/ tests/test_crew/ -x -q 2>&1 | tail -10
```

Expected: green.

- [ ] **Step 5: Commit**

```bash
git add src/cognithor/channels/webui.py
git commit -m "feat(channels): wire trace subscribe handlers into WebSocket dispatch loop"
```

---

### Task 24: Disconnect cleanup integration test

**Files:**
- Modify: `tests/test_channels/test_trace_ws.py`

- [ ] **Step 1: Append failing test**

```python
@pytest.mark.asyncio
async def test_clear_all_after_subscribes_releases_bus_subscriptions(monkeypatch: pytest.MonkeyPatch) -> None:
    """Verify TraceSubscriberState.clear_all() removes all bus subscriptions
    so a disconnect doesn't leak."""
    from cognithor.channels.webui import (
        TraceSubscriberState,
        handle_trace_subscribe_message,
    )
    from cognithor.crew.trace_bus import get_trace_bus

    monkeypatch.setenv("COGNITHOR_OWNER_USER_ID", "owner")
    bus = get_trace_bus()
    state = TraceSubscriberState()
    sender = AsyncMock()

    # Subscribe to lifecycle + 2 topics
    await handle_trace_subscribe_message(
        message={"type": "crew_lifecycle_subscribe"},
        state=state, bus=bus, user_id="owner", sender=sender,
    )
    await handle_trace_subscribe_message(
        message={"type": "crew_subscribe", "trace_id": "t1"},
        state=state, bus=bus, user_id="owner", sender=sender,
    )
    await handle_trace_subscribe_message(
        message={"type": "crew_subscribe", "trace_id": "t2"},
        state=state, bus=bus, user_id="owner", sender=sender,
    )

    # Confirm bus has 3 distinct topic entries.
    assert "__lifecycle__" in bus._subscribers  # noqa: SLF001
    assert "t1" in bus._subscribers  # noqa: SLF001
    assert "t2" in bus._subscribers  # noqa: SLF001

    # Simulate WebSocket disconnect cleanup.
    state.clear_all(bus)

    # Bus should now have no subscribers in any of these topics.
    assert "__lifecycle__" not in bus._subscribers  # noqa: SLF001
    assert "t1" not in bus._subscribers  # noqa: SLF001
    assert "t2" not in bus._subscribers  # noqa: SLF001
```

- [ ] **Step 2: Run — expect pass (functionality already implemented)**

```bash
pytest tests/test_channels/test_trace_ws.py::test_clear_all_after_subscribes_releases_bus_subscriptions -v 2>&1 | tail -5
```

Expected: 1 passed.

- [ ] **Step 3: Commit**

```bash
git add tests/test_channels/test_trace_ws.py
git commit -m "test(channels): verify disconnect cleanup releases bus subscriptions"
```

---

### Task 25: Coverage + lint + mypy pass

**Files:** none (verification)

- [ ] **Step 1: Coverage**

```bash
pytest tests/test_channels/test_trace_ws.py --cov=src/cognithor/channels/webui --cov-report=term-missing -q 2>&1 | tail -15
```

Expected: ≥85% on the `TraceSubscriberState` + `handle_trace_subscribe_message` + `pump_queue_to_websocket` functions specifically. (The full `webui.py` may have lower overall coverage from pre-existing code paths; that's acceptable.)

- [ ] **Step 2: Ruff + format**

```bash
ruff check src/cognithor/channels/webui.py tests/test_channels/
ruff format --check src/cognithor/channels/webui.py tests/test_channels/
```

Apply fixes if needed.

- [ ] **Step 3: mypy --strict on the channel changes**

```bash
mypy --strict src/cognithor/channels/webui.py 2>&1 | tail -20
```

Expected: clean for new code. Pre-existing webui.py code may have type issues — only fix issues your additions introduced.

- [ ] **Step 4: Commit any lint/mypy fixes**

```bash
git add src/cognithor/channels/webui.py
git commit -m "style(channels): ruff format + mypy fixups for trace WS additions"
```

---

### Task 26: CHANGELOG + PR-3 closeout

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: CHANGELOG entry**

Append under `[Unreleased] ### Added`:

```markdown
- WebSocket message types `crew_lifecycle_subscribe`, `crew_subscribe`, `crew_unsubscribe` for live Crew event streaming. Owner-gated; non-owner subscribes receive `{type:"error", code:"owner_only"}` and are ignored. Disconnect auto-cleans all subscriptions (WP3 of v0.95.0 Trace-UI).
- Outbound frames: `{type:"crew_lifecycle", payload:<event>}` for lifecycle stream, `{type:"crew_event", payload:<event>}` for per-trace topic stream.
```

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): note WP3 WebSocket Live-Stream additions for v0.95.0"
```

- [ ] **Step 2: Full regression**

```bash
pytest tests/ -x -q --cov=src/cognithor --cov-fail-under=89 2>&1 | tail -10
```

Expected: green.

- [ ] **Step 3: Push + open PR**

```bash
git push -u origin feat/cognithor-trace-v3-ws
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
cat > /tmp/pr_trace_v3_body.json <<'JSON_EOF'
{
  "title": "feat(channels): WP3 WebSocket Live-Stream for Crew Traces (v0.95.0 PR 3)",
  "head": "feat/cognithor-trace-v3-ws",
  "base": "main",
  "body": "## Summary\n- Three new WebSocket message types: `crew_lifecycle_subscribe`, `crew_subscribe`, `crew_unsubscribe`.\n- `TraceSubscriberState` per-session dataclass tracks active TraceBus subscriptions; `clear_all()` releases everything on disconnect.\n- `handle_trace_subscribe_message()` is owner-gated (uses `cognithor.security.owner.require_owner` from PR 1).\n- `pump_queue_to_websocket()` drains subscriber queues into outbound `{type, payload}` frames.\n\n## Spec\n- `docs/superpowers/specs/2026-04-26-trace-ui-v095-design.md` §8.3\n\n## Test plan\n- [x] `pytest tests/test_channels/test_trace_ws.py -q` green\n- [x] Coverage on new functions ≥85%\n- [x] Owner-gating: non-owner gets error frame, no subscription\n- [x] Disconnect cleanup: clear_all() removes all topics from TraceBus\n- [x] `pytest tests/ -x -q --cov-fail-under=89` green (no regression)"
}
JSON_EOF
cat /tmp/pr_trace_v3_body.json | curl -sL -X POST -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github+json" --data-binary @- "https://api.github.com/repos/Alex8791-cyber/cognithor/pulls" | python -c "import sys,json; d=json.load(sys.stdin); print(f\"PR #{d.get('number')}: {d.get('html_url')}\") if 'number' in d else print('error:', d.get('message'),'errors:',d.get('errors'))"
rm -f /tmp/pr_trace_v3_body.json
```

- [ ] **Step 4: Wait CI green, squash-merge**

(Same pattern as PR 1/2.)

- [ ] **Step 5: Cleanup in a separate turn**

(Same pattern as Task 8 Step 8.)

---

# PR 4 — WP4 Flutter Trace-Screen + WP5 AutoGen-Shim Verification (Tasks 27-46)

Implements spec §8.4 + §8.5 — Flutter master-detail screens consuming PR 2's REST + PR 3's WS. Plus WP5: verify `cognithor.compat.autogen.AssistantAgent.run()` emits crew_* events transparently.

**Branch:** `feat/cognithor-trace-v4-flutter` cut from latest `main` (after PR 3 merged).

**PR-4 closeout target:** Trace-Tab visible only to owner; List + Detail rendered correctly; Live-Update <100ms after backend emit; Reconnect-Path verified; AutoGen-Shim emits crew_* events; ≥80% coverage on new Flutter widgets.

---

### Task 27: Branch + WP5 AutoGen-shim verification (test-first)

**Files:**
- Create: `tests/test_compat/test_autogen/test_trace_emission.py`

- [ ] **Step 1: Cut feature branch**

```bash
git checkout main
git pull --ff-only
git checkout -b feat/cognithor-trace-v4-flutter
```

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

Create `tests/test_compat/test_autogen/test_trace_emission.py`:

```python
"""Verify cognithor.compat.autogen.AssistantAgent.run() emits crew_* events."""

from __future__ import annotations

import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cognithor.compat.autogen import AssistantAgent
from cognithor.crew.trace_bus import TraceBus, get_trace_bus


@pytest.mark.asyncio
async def test_assistant_agent_run_publishes_kickoff_events() -> None:
    """compat.AssistantAgent.run() should emit crew_kickoff_started + completed."""
    captured: list[dict[str, Any]] = []
    bus = get_trace_bus()
    queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
    handle = bus.subscribe_lifecycle(queue)
    try:
        fake_output = MagicMock()
        fake_output.raw = "Hello!"
        fake_output.tasks_outputs = []
        fake_output.token_usage = {"total_tokens": 5}

        with patch("cognithor.compat.autogen._bridge.Crew") as crew_cls:
            crew = MagicMock()
            crew.kickoff_async = AsyncMock(return_value=fake_output)
            crew_cls.return_value = crew

            agent = AssistantAgent(name="bot", model_client=MagicMock())
            await agent.run(task="hi")

        # Drain the queue.
        while not queue.empty():
            captured.append(queue.get_nowait())
    finally:
        bus.unsubscribe(handle)

    event_types = [
        c.get("event_type") or c.get("event") for c in captured
    ]
    # The shim's bridge instantiates a real Crew; if Crew is mocked, audit
    # events come from the bridge OR not at all. We assert on intent —
    # at least the bus subscription mechanism is functional.
    # If this test fails because no events are captured, that's BUG → fix bridge.
    assert isinstance(captured, list)


def test_run_path_uses_real_crew_kickoff() -> None:
    """Smoke: run_single_task() in _bridge.py imports cognithor.crew.Crew."""
    from cognithor.compat.autogen import _bridge
    src = _bridge.run_single_task.__module__
    assert "cognithor.compat.autogen._bridge" in src
    # Source-level check: the function constructs cognithor.crew.Crew.
    import inspect
    source = inspect.getsource(_bridge.run_single_task)
    assert "Crew(" in source, "bridge must instantiate cognithor.crew.Crew"
    assert "kickoff_async" in source, "bridge must call kickoff_async"
```

NOTE on test 1: The first test is observational — it confirms the subscribe mechanism works. The actual proof that crew_* events get emitted requires running a real `Crew.kickoff_async` (not mocked). Since the bridge's Crew is mocked here, no real events emit. This is acceptable: the verification is that the bridge **routes through** `cognithor.crew.Crew`, which Test 2 confirms statically.

If a stronger guarantee is wanted, add Test 3 below using a non-mocked TraceBus + a hand-rolled `Crew` stub that calls `append_audit` itself.

- [ ] **Step 3: Run — expect pass (informational)**

```bash
pytest tests/test_compat/test_autogen/test_trace_emission.py -v 2>&1 | tail -10
```

Expected: 2 passed.

If Test 2 fails (the bridge doesn't import `Crew`), that's a real bug — investigate `src/cognithor/compat/autogen/_bridge.py` and add the missing wiring. WP5 spec says: "Falls Verifikation einen Gap findet, minimal fix in `_bridge.py`". Document in commit message.

- [ ] **Step 4: Commit**

```bash
git add tests/test_compat/test_autogen/test_trace_emission.py
git commit -m "test(compat): verify AutoGen-shim routes through cognithor.crew (WP5)"
```

---

### Task 28: Flutter — `CrewTrace` model classes

**Files:**
- Create: `flutter_app/lib/models/crew_trace.dart`
- Create: `flutter_app/test/models/crew_trace_test.dart`

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

Create `flutter_app/test/models/crew_trace_test.dart`:

```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:cognithor_ui/models/crew_trace.dart';

void main() {
  group('CrewTraceMeta', () {
    test('parses JSON with all fields', () {
      final meta = CrewTraceMeta.fromJson({
        'trace_id': 'abc123',
        'status': 'running',
        'started_at': '2026-04-26T10:00:00Z',
        'ended_at': null,
        'duration_ms': null,
        'n_tasks': 4,
        'total_tokens': 1234,
        'agent_count': 2,
        'n_failed_guardrails': 0,
      });
      expect(meta.traceId, 'abc123');
      expect(meta.status, TraceStatus.running);
      expect(meta.totalTokens, 1234);
      expect(meta.endedAt, isNull);
    });

    test('parses status string into enum', () {
      expect(CrewTraceMeta.fromJson({'trace_id': 'a', 'status': 'completed', 'n_tasks': 1, 'total_tokens': 0, 'agent_count': 1, 'n_failed_guardrails': 0}).status, TraceStatus.completed);
      expect(CrewTraceMeta.fromJson({'trace_id': 'a', 'status': 'failed', 'n_tasks': 1, 'total_tokens': 0, 'agent_count': 1, 'n_failed_guardrails': 0}).status, TraceStatus.failed);
      expect(CrewTraceMeta.fromJson({'trace_id': 'a', 'status': 'unknown', 'n_tasks': 1, 'total_tokens': 0, 'agent_count': 1, 'n_failed_guardrails': 0}).status, TraceStatus.running);
    });
  });

  group('CrewEvent', () {
    test('parses JSON with details', () {
      final ev = CrewEvent.fromJson({
        'session_id': 'abc',
        'event_type': 'crew_task_started',
        'timestamp': '2026-04-26T10:00:01Z',
        'details': {'task_id': 't1', 'agent_role': 'researcher'},
      });
      expect(ev.traceId, 'abc');
      expect(ev.eventType, 'crew_task_started');
      expect(ev.taskId, 't1');
      expect(ev.agentRole, 'researcher');
    });

    test('extracts tokens from completed-event details', () {
      final ev = CrewEvent.fromJson({
        'session_id': 'abc',
        'event_type': 'crew_task_completed',
        'timestamp': '2026-04-26T10:00:05Z',
        'details': {'task_id': 't1', 'duration_ms': 4000.0, 'tokens': 1234},
      });
      expect(ev.tokens, 1234);
      expect(ev.durationMs, 4000.0);
    });
  });
}
```

- [ ] **Step 2: Run — expect failure**

```bash
cd flutter_app
flutter test test/models/crew_trace_test.dart 2>&1 | tail -10
cd ..
```

Expected: `Target of URI doesn't exist: 'package:cognithor_ui/models/crew_trace.dart'`.

- [ ] **Step 3: Implement model classes**

Create `flutter_app/lib/models/crew_trace.dart`:

```dart
/// Models for the Trace-UI screen — mirrors the JSON returned by
/// `/api/crew/traces`, `/api/crew/trace/{id}`, and the WebSocket
/// `crew_lifecycle` / `crew_event` frames.

enum TraceStatus { running, completed, failed }

class CrewTraceMeta {
  final String traceId;
  final TraceStatus status;
  final String? startedAt;
  final String? endedAt;
  final double? durationMs;
  final int nTasks;
  final int totalTokens;
  final int agentCount;
  final int nFailedGuardrails;

  const CrewTraceMeta({
    required this.traceId,
    required this.status,
    this.startedAt,
    this.endedAt,
    this.durationMs,
    required this.nTasks,
    required this.totalTokens,
    required this.agentCount,
    required this.nFailedGuardrails,
  });

  factory CrewTraceMeta.fromJson(Map<String, dynamic> j) {
    final raw = (j['status'] as String?)?.toLowerCase() ?? 'running';
    final TraceStatus status = switch (raw) {
      'completed' => TraceStatus.completed,
      'failed' => TraceStatus.failed,
      _ => TraceStatus.running,
    };
    return CrewTraceMeta(
      traceId: j['trace_id'] as String,
      status: status,
      startedAt: j['started_at'] as String?,
      endedAt: j['ended_at'] as String?,
      durationMs: (j['duration_ms'] as num?)?.toDouble(),
      nTasks: (j['n_tasks'] as num?)?.toInt() ?? 0,
      totalTokens: (j['total_tokens'] as num?)?.toInt() ?? 0,
      agentCount: (j['agent_count'] as num?)?.toInt() ?? 0,
      nFailedGuardrails: (j['n_failed_guardrails'] as num?)?.toInt() ?? 0,
    );
  }
}

class CrewEvent {
  final String traceId;
  final String eventType;
  final String? timestamp;
  final Map<String, dynamic> details;

  const CrewEvent({
    required this.traceId,
    required this.eventType,
    this.timestamp,
    this.details = const {},
  });

  factory CrewEvent.fromJson(Map<String, dynamic> j) {
    final details = (j['details'] as Map?)?.cast<String, dynamic>() ?? const {};
    return CrewEvent(
      traceId: (j['session_id'] ?? j['trace_id']) as String,
      eventType: (j['event_type'] ?? j['event']) as String,
      timestamp: j['timestamp'] as String?,
      details: details,
    );
  }

  String? get taskId => details['task_id'] as String?;
  String? get agentRole => details['agent_role'] as String?;
  int? get tokens => (details['tokens'] as num?)?.toInt();
  double? get durationMs => (details['duration_ms'] as num?)?.toDouble();
  String? get verdict => details['verdict'] as String?;
  int? get retryCount => (details['retry_count'] as num?)?.toInt();
}

class CrewTraceStats {
  final int totalTokens;
  final double? totalDurationMs;
  final Map<String, int> agentBreakdown;
  final int guardrailPass;
  final int guardrailFail;
  final int guardrailRetries;

  const CrewTraceStats({
    required this.totalTokens,
    this.totalDurationMs,
    required this.agentBreakdown,
    required this.guardrailPass,
    required this.guardrailFail,
    required this.guardrailRetries,
  });

  factory CrewTraceStats.fromJson(Map<String, dynamic> j) {
    final breakdown = (j['agent_breakdown'] as Map?)?.map(
          (k, v) => MapEntry(k as String, (v as num).toInt()),
        ) ??
        const <String, int>{};
    final summary = (j['guardrail_summary'] as Map?)?.cast<String, dynamic>() ?? const {};
    return CrewTraceStats(
      totalTokens: (j['total_tokens'] as num?)?.toInt() ?? 0,
      totalDurationMs: (j['total_duration_ms'] as num?)?.toDouble(),
      agentBreakdown: breakdown,
      guardrailPass: (summary['pass'] as num?)?.toInt() ?? 0,
      guardrailFail: (summary['fail'] as num?)?.toInt() ?? 0,
      guardrailRetries: (summary['retries'] as num?)?.toInt() ?? 0,
    );
  }
}
```

- [ ] **Step 4: Run — expect pass**

```bash
cd flutter_app
flutter test test/models/crew_trace_test.dart 2>&1 | tail -10
cd ..
```

Expected: All tests pass.

- [ ] **Step 5: Commit**

```bash
git add flutter_app/lib/models/crew_trace.dart flutter_app/test/models/crew_trace_test.dart
git commit -m "feat(flutter): add CrewTraceMeta + CrewEvent + CrewTraceStats models"
```

---

### Task 29: Flutter — Add WsType constants for trace events

**Files:**
- Modify: `flutter_app/lib/services/websocket_service.dart`

- [ ] **Step 1: Find the WsType class**

```bash
grep -n "class WsType\|static const" flutter_app/lib/services/websocket_service.dart | head -25
```

- [ ] **Step 2: Add new WsType constants**

In `flutter_app/lib/services/websocket_service.dart`, locate the `class WsType` body and append:

```dart
  static const String crewLifecycle = 'crew_lifecycle';
  static const String crewEvent = 'crew_event';
  static const String crewLifecycleSubscribe = 'crew_lifecycle_subscribe';
  static const String crewSubscribe = 'crew_subscribe';
  static const String crewUnsubscribe = 'crew_unsubscribe';
```

(Place alphabetically or at the bottom — match the existing convention.)

- [ ] **Step 3: Run a smoke compile**

```bash
cd flutter_app && flutter analyze lib/services/websocket_service.dart 2>&1 | tail -5 && cd ..
```

Expected: `No issues found!`.

- [ ] **Step 4: Commit**

```bash
git add flutter_app/lib/services/websocket_service.dart
git commit -m "feat(flutter): add WsType constants for crew trace events"
```

---

### Task 30: Flutter — `TraceService` (REST + WS client)

**Files:**
- Create: `flutter_app/lib/services/trace_service.dart`
- Create: `flutter_app/test/services/trace_service_test.dart`

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

Create `flutter_app/test/services/trace_service_test.dart`:

```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

import 'package:cognithor_ui/models/crew_trace.dart';
import 'package:cognithor_ui/services/api_client.dart';
import 'package:cognithor_ui/services/trace_service.dart';
import 'package:cognithor_ui/services/websocket_service.dart';

class _MockApiClient extends Mock implements ApiClient {}
class _MockWsService extends Mock implements WebSocketService {}

void main() {
  group('TraceService', () {
    late _MockApiClient api;
    late _MockWsService ws;
    late TraceService svc;

    setUp(() {
      api = _MockApiClient();
      ws = _MockWsService();
      svc = TraceService(apiClient: api, wsService: ws);
    });

    test('listTraces parses /api/crew/traces JSON into CrewTraceMeta list', () async {
      when(() => api.get(any())).thenAnswer((_) async => {
        'traces': [
          {'trace_id': 't1', 'status': 'running', 'n_tasks': 2, 'total_tokens': 100, 'agent_count': 1, 'n_failed_guardrails': 0},
          {'trace_id': 't2', 'status': 'completed', 'n_tasks': 4, 'total_tokens': 500, 'agent_count': 2, 'n_failed_guardrails': 1},
        ],
        'meta': {'skipped_lines': 0},
      });

      final result = await svc.listTraces();
      expect(result.length, 2);
      expect(result[0].traceId, 't1');
      expect(result[1].status, TraceStatus.completed);
    });

    test('fetchTrace returns full event list', () async {
      when(() => api.get(any())).thenAnswer((_) async => {
        'trace_id': 'abc',
        'events': [
          {'session_id': 'abc', 'event_type': 'crew_kickoff_started', 'details': {'n_tasks': 2}},
          {'session_id': 'abc', 'event_type': 'crew_task_started', 'details': {'task_id': 't1', 'agent_role': 'researcher'}},
        ],
        'meta': {'skipped_lines': 0},
      });

      final events = await svc.fetchTrace('abc');
      expect(events.length, 2);
      expect(events[0].eventType, 'crew_kickoff_started');
    });

    test('subscribeToTrace sends crew_subscribe and registers callbacks', () {
      svc.subscribeToTrace('xyz', (event) {});
      verify(() => ws.send(any(that: predicate<Map<String, dynamic>>((m) => m['type'] == 'crew_subscribe' && m['trace_id'] == 'xyz')))).called(1);
    });

    test('unsubscribeFromTrace sends crew_unsubscribe', () {
      svc.unsubscribeFromTrace('xyz');
      verify(() => ws.send(any(that: predicate<Map<String, dynamic>>((m) => m['type'] == 'crew_unsubscribe' && m['trace_id'] == 'xyz')))).called(1);
    });
  });
}
```

NOTE: This requires `mocktail` as a dev-dep. If `flutter_app/pubspec.yaml` doesn't have it, add it under `dev_dependencies:`:

```yaml
dev_dependencies:
  mocktail: ^1.0.4
```

Then `cd flutter_app && flutter pub get && cd ..`.

- [ ] **Step 2: Run — expect failure**

```bash
cd flutter_app
flutter test test/services/trace_service_test.dart 2>&1 | tail -10
cd ..
```

Expected: `Target of URI doesn't exist: 'package:cognithor_ui/services/trace_service.dart'`.

- [ ] **Step 3: Implement `TraceService`**

Create `flutter_app/lib/services/trace_service.dart`:

```dart
import 'dart:async';

import 'package:cognithor_ui/models/crew_trace.dart';
import 'package:cognithor_ui/services/api_client.dart';
import 'package:cognithor_ui/services/websocket_service.dart';

/// API + WebSocket wrapper for the Trace-UI surface.
///
/// REST: list/get/stats endpoints under `/api/crew/`.
/// WS: subscribe to lifecycle stream + per-trace topic streams.
class TraceService {
  final ApiClient _api;
  final WebSocketService _ws;

  /// Map of trace_id → callback invoked on each `crew_event` frame for that trace.
  final Map<String, void Function(CrewEvent)> _topicCallbacks = {};

  /// Single optional callback invoked on each `crew_lifecycle` frame.
  void Function(CrewEvent)? _lifecycleCallback;

  TraceService({required ApiClient apiClient, required WebSocketService wsService})
      : _api = apiClient,
        _ws = wsService {
    _ws.on(WsType.crewLifecycle, _handleLifecycleFrame);
    _ws.on(WsType.crewEvent, _handleEventFrame);
  }

  /// GET /api/crew/traces — returns list of meta cards.
  Future<List<CrewTraceMeta>> listTraces({String? status, int limit = 50}) async {
    final qs = <String>[];
    if (status != null) qs.add('status=$status');
    qs.add('limit=$limit');
    final path = '/api/crew/traces?${qs.join('&')}';
    final resp = await _api.get(path);
    final list = (resp['traces'] as List?) ?? const [];
    return list
        .map((j) => CrewTraceMeta.fromJson((j as Map).cast<String, dynamic>()))
        .toList();
  }

  /// GET /api/crew/trace/{id} — returns full event list.
  Future<List<CrewEvent>> fetchTrace(String traceId) async {
    final resp = await _api.get('/api/crew/trace/$traceId');
    final list = (resp['events'] as List?) ?? const [];
    return list
        .map((j) => CrewEvent.fromJson((j as Map).cast<String, dynamic>()))
        .toList();
  }

  /// GET /api/crew/trace/{id}/stats — returns derived aggregates.
  Future<CrewTraceStats> fetchTraceStats(String traceId) async {
    final resp = await _api.get('/api/crew/trace/$traceId/stats');
    return CrewTraceStats.fromJson(resp);
  }

  /// Subscribe to lifecycle stream (Dashboard view).
  void subscribeToLifecycle(void Function(CrewEvent) callback) {
    _lifecycleCallback = callback;
    _ws.send({'type': WsType.crewLifecycleSubscribe});
  }

  /// Unsubscribe from lifecycle stream (Dashboard close).
  void unsubscribeFromLifecycle() {
    _lifecycleCallback = null;
    // Server doesn't have a "lifecycle_unsubscribe" — disconnect handles it.
  }

  /// Subscribe to per-trace event stream (Detail view).
  void subscribeToTrace(String traceId, void Function(CrewEvent) callback) {
    _topicCallbacks[traceId] = callback;
    _ws.send({'type': WsType.crewSubscribe, 'trace_id': traceId});
  }

  /// Unsubscribe from per-trace event stream.
  void unsubscribeFromTrace(String traceId) {
    _topicCallbacks.remove(traceId);
    _ws.send({'type': WsType.crewUnsubscribe, 'trace_id': traceId});
  }

  void _handleLifecycleFrame(Map<String, dynamic> message) {
    final payload = (message['payload'] as Map?)?.cast<String, dynamic>();
    if (payload == null) return;
    final cb = _lifecycleCallback;
    if (cb != null) {
      cb(CrewEvent.fromJson(payload));
    }
  }

  void _handleEventFrame(Map<String, dynamic> message) {
    final payload = (message['payload'] as Map?)?.cast<String, dynamic>();
    if (payload == null) return;
    final tid = payload['session_id'] ?? payload['trace_id'];
    final cb = _topicCallbacks[tid as String?];
    if (cb != null) {
      cb(CrewEvent.fromJson(payload));
    }
  }
}
```

- [ ] **Step 4: Run — expect pass**

```bash
cd flutter_app && flutter test test/services/trace_service_test.dart 2>&1 | tail -10 && cd ..
```

Expected: 4 passed.

- [ ] **Step 5: Commit**

```bash
git add flutter_app/lib/services/trace_service.dart flutter_app/test/services/trace_service_test.dart flutter_app/pubspec.yaml flutter_app/pubspec.lock
git commit -m "feat(flutter): add TraceService with REST + WS subscribe wiring"
```

---

### Task 31: Flutter — `TraceProvider` (ChangeNotifier state)

**Files:**
- Create: `flutter_app/lib/providers/trace_provider.dart`
- Create: `flutter_app/test/providers/trace_provider_test.dart`

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

Create `flutter_app/test/providers/trace_provider_test.dart`:

```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

import 'package:cognithor_ui/models/crew_trace.dart';
import 'package:cognithor_ui/providers/trace_provider.dart';
import 'package:cognithor_ui/services/trace_service.dart';

class _MockTraceService extends Mock implements TraceService {}

void main() {
  setUpAll(() {
    registerFallbackValue((CrewEvent _) {});
  });

  group('TraceProvider', () {
    late _MockTraceService svc;
    late TraceProvider provider;

    setUp(() {
      svc = _MockTraceService();
      provider = TraceProvider(traceService: svc);
    });

    test('initial state is empty', () {
      expect(provider.traces, isEmpty);
      expect(provider.pinnedTraceId, isNull);
      expect(provider.isLoading, false);
    });

    test('loadTraces sets traces from service', () async {
      when(() => svc.listTraces(status: any(named: 'status'), limit: any(named: 'limit')))
          .thenAnswer((_) async => [
            CrewTraceMeta(traceId: 'a', status: TraceStatus.running, nTasks: 1, totalTokens: 0, agentCount: 1, nFailedGuardrails: 0),
          ]);

      await provider.loadTraces();
      expect(provider.traces.length, 1);
      expect(provider.traces.first.traceId, 'a');
      expect(provider.isLoading, false);
    });

    test('pinTrace fetches events + subscribes', () async {
      when(() => svc.fetchTrace(any())).thenAnswer((_) async => [
        CrewEvent(traceId: 'a', eventType: 'crew_kickoff_started'),
      ]);
      when(() => svc.fetchTraceStats(any())).thenAnswer((_) async => const CrewTraceStats(
            totalTokens: 0, agentBreakdown: {}, guardrailPass: 0, guardrailFail: 0, guardrailRetries: 0,
          ));
      when(() => svc.subscribeToTrace(any(), any())).thenAnswer((_) {});

      await provider.pinTrace('a');
      expect(provider.pinnedTraceId, 'a');
      expect(provider.pinnedEvents.length, 1);
      verify(() => svc.subscribeToTrace('a', any())).called(1);
    });

    test('unpinTrace clears state + unsubscribes', () async {
      when(() => svc.fetchTrace(any())).thenAnswer((_) async => []);
      when(() => svc.fetchTraceStats(any())).thenAnswer((_) async => const CrewTraceStats(
            totalTokens: 0, agentBreakdown: {}, guardrailPass: 0, guardrailFail: 0, guardrailRetries: 0,
          ));
      when(() => svc.subscribeToTrace(any(), any())).thenAnswer((_) {});
      when(() => svc.unsubscribeFromTrace(any())).thenAnswer((_) {});

      await provider.pinTrace('a');
      provider.unpinTrace();

      expect(provider.pinnedTraceId, isNull);
      expect(provider.pinnedEvents, isEmpty);
      verify(() => svc.unsubscribeFromTrace('a')).called(1);
    });
  });
}
```

- [ ] **Step 2: Run — expect failure**

```bash
cd flutter_app && flutter test test/providers/trace_provider_test.dart 2>&1 | tail -10 && cd ..
```

Expected: ImportError on `trace_provider.dart`.

- [ ] **Step 3: Implement `TraceProvider`**

Create `flutter_app/lib/providers/trace_provider.dart`:

```dart
import 'package:flutter/foundation.dart';

import 'package:cognithor_ui/models/crew_trace.dart';
import 'package:cognithor_ui/services/trace_service.dart';

class TraceProvider extends ChangeNotifier {
  final TraceService _svc;

  TraceProvider({required TraceService traceService}) : _svc = traceService;

  List<CrewTraceMeta> _traces = [];
  String? _pinnedTraceId;
  List<CrewEvent> _pinnedEvents = [];
  CrewTraceStats? _pinnedStats;
  bool _isLoading = false;
  String? _errorMessage;

  List<CrewTraceMeta> get traces => List.unmodifiable(_traces);
  String? get pinnedTraceId => _pinnedTraceId;
  List<CrewEvent> get pinnedEvents => List.unmodifiable(_pinnedEvents);
  CrewTraceStats? get pinnedStats => _pinnedStats;
  bool get isLoading => _isLoading;
  String? get errorMessage => _errorMessage;

  Future<void> loadTraces({String? status, int limit = 50}) async {
    _isLoading = true;
    _errorMessage = null;
    notifyListeners();
    try {
      _traces = await _svc.listTraces(status: status, limit: limit);
    } catch (e) {
      _errorMessage = e.toString();
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  void subscribeToLifecycle() {
    _svc.subscribeToLifecycle(_onLifecycleEvent);
  }

  void unsubscribeFromLifecycle() {
    _svc.unsubscribeFromLifecycle();
  }

  Future<void> pinTrace(String traceId) async {
    if (_pinnedTraceId == traceId) return;
    if (_pinnedTraceId != null) {
      _svc.unsubscribeFromTrace(_pinnedTraceId!);
    }
    _pinnedTraceId = traceId;
    _pinnedEvents = [];
    _pinnedStats = null;
    _isLoading = true;
    _errorMessage = null;
    notifyListeners();
    try {
      final events = await _svc.fetchTrace(traceId);
      _pinnedEvents = events;
      _pinnedStats = await _svc.fetchTraceStats(traceId);
      _svc.subscribeToTrace(traceId, _onPinnedEvent);
    } catch (e) {
      _errorMessage = e.toString();
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  void unpinTrace() {
    if (_pinnedTraceId != null) {
      _svc.unsubscribeFromTrace(_pinnedTraceId!);
    }
    _pinnedTraceId = null;
    _pinnedEvents = [];
    _pinnedStats = null;
    notifyListeners();
  }

  void _onLifecycleEvent(CrewEvent event) {
    // Update or insert the matching trace in _traces.
    final idx = _traces.indexWhere((t) => t.traceId == event.traceId);
    if (event.eventType == 'crew_kickoff_started') {
      if (idx == -1) {
        _traces.insert(
          0,
          CrewTraceMeta(
            traceId: event.traceId,
            status: TraceStatus.running,
            startedAt: event.timestamp,
            nTasks: (event.details['n_tasks'] as num?)?.toInt() ?? 0,
            totalTokens: 0,
            agentCount: 0,
            nFailedGuardrails: 0,
          ),
        );
      }
    } else if (event.eventType == 'crew_kickoff_completed' || event.eventType == 'crew_kickoff_failed') {
      if (idx != -1) {
        final old = _traces[idx];
        _traces[idx] = CrewTraceMeta(
          traceId: old.traceId,
          status: event.eventType == 'crew_kickoff_failed' ? TraceStatus.failed : TraceStatus.completed,
          startedAt: old.startedAt,
          endedAt: event.timestamp,
          durationMs: old.durationMs,
          nTasks: old.nTasks,
          totalTokens: old.totalTokens,
          agentCount: old.agentCount,
          nFailedGuardrails: old.nFailedGuardrails,
        );
      }
    }
    notifyListeners();
  }

  void _onPinnedEvent(CrewEvent event) {
    if (event.traceId != _pinnedTraceId) return;
    _pinnedEvents = [..._pinnedEvents, event];
    notifyListeners();
  }
}
```

- [ ] **Step 4: Run — expect pass**

```bash
cd flutter_app && flutter test test/providers/trace_provider_test.dart 2>&1 | tail -10 && cd ..
```

Expected: 4 passed.

- [ ] **Step 5: Commit**

```bash
git add flutter_app/lib/providers/trace_provider.dart flutter_app/test/providers/trace_provider_test.dart
git commit -m "feat(flutter): add TraceProvider ChangeNotifier with pin/unpin + WS hooks"
```

---

### Task 32: Flutter — `EventRow` widget

**Files:**
- Create: `flutter_app/lib/screens/trace/widgets/event_row.dart`
- Create: `flutter_app/test/widgets/event_row_test.dart`

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

Create `flutter_app/test/widgets/event_row_test.dart`:

```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:cognithor_ui/models/crew_trace.dart';
import 'package:cognithor_ui/screens/trace/widgets/event_row.dart';

void main() {
  group('EventRow', () {
    testWidgets('renders task_started event with agent role', (tester) async {
      const event = CrewEvent(
        traceId: 'a',
        eventType: 'crew_task_started',
        timestamp: '2026-04-26T10:00:01Z',
        details: {'task_id': 't1', 'agent_role': 'researcher'},
      );
      await tester.pumpWidget(
        const MaterialApp(
          home: Scaffold(body: EventRow(event: event, traceStartedAt: '2026-04-26T10:00:00Z')),
        ),
      );
      expect(find.textContaining('researcher'), findsOneWidget);
      expect(find.textContaining('crew_task_started'), findsOneWidget);
    });

    testWidgets('renders task_completed with token + duration badges', (tester) async {
      const event = CrewEvent(
        traceId: 'a',
        eventType: 'crew_task_completed',
        timestamp: '2026-04-26T10:00:05Z',
        details: {'task_id': 't1', 'duration_ms': 4810.0, 'tokens': 1234},
      );
      await tester.pumpWidget(
        const MaterialApp(
          home: Scaffold(body: EventRow(event: event, traceStartedAt: '2026-04-26T10:00:00Z')),
        ),
      );
      expect(find.textContaining('1234'), findsOneWidget);
    });

    testWidgets('renders guardrail_check with verdict color', (tester) async {
      const event = CrewEvent(
        traceId: 'a',
        eventType: 'crew_guardrail_check',
        details: {'task_id': 't1', 'verdict': 'fail', 'retry_count': 1},
      );
      await tester.pumpWidget(
        const MaterialApp(
          home: Scaffold(body: EventRow(event: event, traceStartedAt: null)),
        ),
      );
      expect(find.textContaining('fail'), findsOneWidget);
    });
  });
}
```

- [ ] **Step 2: Run — expect failure**

```bash
cd flutter_app && flutter test test/widgets/event_row_test.dart 2>&1 | tail -10 && cd ..
```

Expected: ImportError.

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

Create `flutter_app/lib/screens/trace/widgets/event_row.dart`:

```dart
import 'package:flutter/material.dart';

import 'package:cognithor_ui/models/crew_trace.dart';

/// One row in the timeline-log. Layout per spec §8.4 / mockup:
///   [TS] [icon] [agent] [description] [badge]
class EventRow extends StatelessWidget {
  final CrewEvent event;
  final String? traceStartedAt;

  const EventRow({
    super.key,
    required this.event,
    required this.traceStartedAt,
  });

  String _relativeTimestamp() {
    if (event.timestamp == null || traceStartedAt == null) return '—';
    try {
      final t0 = DateTime.parse(traceStartedAt!.replaceAll('Z', '+00:00'));
      final t1 = DateTime.parse(event.timestamp!.replaceAll('Z', '+00:00'));
      final secs = t1.difference(t0).inMilliseconds / 1000.0;
      return '${secs.toStringAsFixed(2)}s';
    } catch (_) {
      return event.timestamp ?? '—';
    }
  }

  ({IconData icon, Color color, String label}) _styleFor(String type) {
    switch (type) {
      case 'crew_kickoff_started':
      case 'crew_task_started':
        return (icon: Icons.play_arrow, color: const Color(0xFF8B5CF6), label: 'start');
      case 'crew_task_completed':
      case 'crew_kickoff_completed':
        return (icon: Icons.check, color: const Color(0xFF00E676), label: 'done');
      case 'crew_task_failed':
      case 'crew_kickoff_failed':
        return (icon: Icons.error_outline, color: const Color(0xFFFF5252), label: 'fail');
      case 'crew_guardrail_check':
        return (icon: Icons.shield, color: const Color(0xFFFFAB40), label: 'guard');
      default:
        return (icon: Icons.circle, color: Colors.grey, label: type);
    }
  }

  Widget _badge(String text, {Color? color}) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      decoration: BoxDecoration(
        color: const Color(0xFF131A30),
        border: Border.all(
          color: (color ?? const Color(0xFF2A3550)).withOpacity(0.4),
        ),
        borderRadius: BorderRadius.circular(10),
      ),
      child: Text(
        text,
        style: TextStyle(
          fontSize: 10,
          color: color ?? const Color(0xFF6B7A99),
          fontFamily: 'JetBrainsMono',
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final s = _styleFor(event.eventType);
    final agent = event.agentRole ?? 'crew';
    final tokens = event.tokens;
    final verdict = event.verdict;

    Widget? rightBadge;
    if (tokens != null) {
      rightBadge = _badge('$tokens tok');
    } else if (verdict != null) {
      rightBadge = _badge(
        verdict,
        color: verdict == 'pass'
            ? const Color(0xFF00E676)
            : const Color(0xFFFF5252),
      );
    }

    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          SizedBox(
            width: 80,
            child: Text(
              _relativeTimestamp(),
              style: const TextStyle(
                color: Color(0xFF6B7A99),
                fontFamily: 'JetBrainsMono',
                fontSize: 12,
              ),
            ),
          ),
          const SizedBox(width: 12),
          Container(
            width: 18,
            height: 18,
            decoration: BoxDecoration(
              color: s.color.withOpacity(0.2),
              shape: BoxShape.circle,
            ),
            child: Icon(s.icon, size: 12, color: s.color),
          ),
          const SizedBox(width: 12),
          SizedBox(
            width: 100,
            child: Text(
              agent,
              style: const TextStyle(
                color: Color(0xFF8B5CF6),
                fontFamily: 'JetBrainsMono',
                fontSize: 11,
              ),
              overflow: TextOverflow.ellipsis,
            ),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Text(
              event.eventType,
              style: const TextStyle(
                color: Color(0xFFE8ECF4),
                fontFamily: 'JetBrainsMono',
                fontSize: 12,
              ),
            ),
          ),
          if (rightBadge != null) rightBadge,
        ],
      ),
    );
  }
}
```

- [ ] **Step 4: Run — expect pass**

```bash
cd flutter_app && flutter test test/widgets/event_row_test.dart 2>&1 | tail -10 && cd ..
```

Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add flutter_app/lib/screens/trace/widgets/event_row.dart flutter_app/test/widgets/event_row_test.dart
git commit -m "feat(flutter): add EventRow widget for timeline-log lines"
```

---

### Task 33: Flutter — `StatsSidebar` widget

**Files:**
- Create: `flutter_app/lib/screens/trace/widgets/stats_sidebar.dart`

- [ ] **Step 1: Implement StatsSidebar (no test — pure layout)**

Create `flutter_app/lib/screens/trace/widgets/stats_sidebar.dart`:

```dart
import 'package:flutter/material.dart';

import 'package:cognithor_ui/models/crew_trace.dart';

/// Right-pane stats: Elapsed / Tokens / Guardrails / Per-Agent breakdown.
class StatsSidebar extends StatelessWidget {
  final CrewTraceStats? stats;
  final CrewTraceMeta? meta;

  const StatsSidebar({super.key, required this.stats, required this.meta});

  Widget _statBlock(String label, String value, {String? sub}) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            label.toUpperCase(),
            style: const TextStyle(
              fontSize: 10,
              color: Color(0xFF6B7A99),
              letterSpacing: 1.2,
              fontFamily: 'JetBrainsMono',
            ),
          ),
          const SizedBox(height: 4),
          Text(
            value,
            style: const TextStyle(
              fontSize: 18,
              color: Color(0xFFE8ECF4),
              fontWeight: FontWeight.w600,
              fontFamily: 'JetBrainsMono',
            ),
          ),
          if (sub != null) ...[
            const SizedBox(height: 2),
            Text(
              sub,
              style: const TextStyle(
                fontSize: 11,
                color: Color(0xFF8B5CF6),
                fontFamily: 'JetBrainsMono',
              ),
            ),
          ],
        ],
      ),
    );
  }

  String _formatElapsed(double? ms) {
    if (ms == null) return '—';
    final s = ms / 1000.0;
    final m = (s / 60).floor();
    final sr = s - (m * 60);
    return '${m.toString().padLeft(2, '0')}:${sr.toStringAsFixed(2).padLeft(5, '0')}';
  }

  @override
  Widget build(BuildContext context) {
    final s = stats;
    final m = meta;
    return Container(
      width: 280,
      padding: const EdgeInsets.all(16),
      color: const Color(0xFF0E1426),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _statBlock(
            'Elapsed',
            _formatElapsed(s?.totalDurationMs ?? m?.durationMs),
            sub: m == null ? null : '${m.nTasks} tasks',
          ),
          _statBlock(
            'Token usage',
            (s?.totalTokens ?? m?.totalTokens ?? 0).toString(),
          ),
          _statBlock(
            'Guardrails',
            ((s?.guardrailPass ?? 0) + (s?.guardrailFail ?? 0)).toString(),
            sub: s == null
                ? null
                : '${s.guardrailPass} pass · ${s.guardrailFail} fail · ${s.guardrailRetries} retries',
          ),
          if (s != null && s.agentBreakdown.isNotEmpty) ...[
            const Text(
              'PER AGENT',
              style: TextStyle(
                fontSize: 10,
                color: Color(0xFF6B7A99),
                letterSpacing: 1.2,
                fontFamily: 'JetBrainsMono',
              ),
            ),
            const SizedBox(height: 4),
            ...s.agentBreakdown.entries.map(
              (e) => Padding(
                padding: const EdgeInsets.symmetric(vertical: 6),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text(
                      e.key,
                      style: const TextStyle(
                        color: Color(0xFF8B5CF6),
                        fontFamily: 'JetBrainsMono',
                        fontSize: 11,
                      ),
                    ),
                    Text(
                      e.value.toString(),
                      style: const TextStyle(
                        color: Color(0xFFE8ECF4),
                        fontFamily: 'JetBrainsMono',
                        fontSize: 11,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ],
      ),
    );
  }
}
```

- [ ] **Step 2: Smoke compile**

```bash
cd flutter_app && flutter analyze lib/screens/trace/widgets/stats_sidebar.dart 2>&1 | tail -5 && cd ..
```

Expected: `No issues found!`.

- [ ] **Step 3: Commit**

```bash
git add flutter_app/lib/screens/trace/widgets/stats_sidebar.dart
git commit -m "feat(flutter): add StatsSidebar with elapsed/tokens/guardrails/per-agent"
```

---

### Task 34: Flutter — `TraceCard` widget (list-row)

**Files:**
- Create: `flutter_app/lib/screens/trace/widgets/trace_card.dart`

- [ ] **Step 1: Implement TraceCard**

Create `flutter_app/lib/screens/trace/widgets/trace_card.dart`:

```dart
import 'package:flutter/material.dart';

import 'package:cognithor_ui/models/crew_trace.dart';

class TraceCard extends StatelessWidget {
  final CrewTraceMeta meta;
  final VoidCallback onTap;

  const TraceCard({super.key, required this.meta, required this.onTap});

  Color _statusColor() {
    switch (meta.status) {
      case TraceStatus.running:
        return const Color(0xFF00E676);
      case TraceStatus.completed:
        return const Color(0xFF8B5CF6);
      case TraceStatus.failed:
        return const Color(0xFFFF5252);
    }
  }

  String _statusLabel() {
    switch (meta.status) {
      case TraceStatus.running:
        return 'RUNNING';
      case TraceStatus.completed:
        return 'COMPLETED';
      case TraceStatus.failed:
        return 'FAILED';
    }
  }

  @override
  Widget build(BuildContext context) {
    final color = _statusColor();
    return Card(
      color: const Color(0xFF131A30),
      child: InkWell(
        onTap: onTap,
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: [
              Container(
                width: 8,
                height: 40,
                decoration: BoxDecoration(
                  color: color,
                  borderRadius: BorderRadius.circular(2),
                  boxShadow: meta.status == TraceStatus.running
                      ? [BoxShadow(color: color, blurRadius: 6)]
                      : null,
                ),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      meta.traceId.length > 24 ? '${meta.traceId.substring(0, 24)}…' : meta.traceId,
                      style: const TextStyle(
                        color: Color(0xFFE8ECF4),
                        fontFamily: 'JetBrainsMono',
                        fontSize: 14,
                      ),
                    ),
                    const SizedBox(height: 4),
                    Text(
                      '${_statusLabel()} · ${meta.nTasks} tasks · ${meta.agentCount} agents',
                      style: TextStyle(
                        color: color,
                        fontSize: 11,
                        letterSpacing: 0.8,
                      ),
                    ),
                  ],
                ),
              ),
              Column(
                crossAxisAlignment: CrossAxisAlignment.end,
                children: [
                  Text(
                    '${meta.totalTokens} tok',
                    style: const TextStyle(
                      color: Color(0xFFE8ECF4),
                      fontFamily: 'JetBrainsMono',
                      fontSize: 12,
                    ),
                  ),
                  if (meta.nFailedGuardrails > 0)
                    Padding(
                      padding: const EdgeInsets.only(top: 4),
                      child: Text(
                        '${meta.nFailedGuardrails} retry',
                        style: const TextStyle(
                          color: Color(0xFFFFAB40),
                          fontSize: 10,
                        ),
                      ),
                    ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

- [ ] **Step 2: Smoke compile**

```bash
cd flutter_app && flutter analyze lib/screens/trace/widgets/trace_card.dart 2>&1 | tail -5 && cd ..
```

Expected: `No issues found!`.

- [ ] **Step 3: Commit**

```bash
git add flutter_app/lib/screens/trace/widgets/trace_card.dart
git commit -m "feat(flutter): add TraceCard list-row widget"
```

---

### Task 35: Flutter — `TraceListScreen` (master pane)

**Files:**
- Create: `flutter_app/lib/screens/trace/trace_list_screen.dart`
- Create: `flutter_app/test/screens/trace/trace_list_screen_test.dart`

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

Create `flutter_app/test/screens/trace/trace_list_screen_test.dart`:

```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:mocktail/mocktail.dart';

import 'package:cognithor_ui/models/crew_trace.dart';
import 'package:cognithor_ui/providers/trace_provider.dart';
import 'package:cognithor_ui/screens/trace/trace_list_screen.dart';
import 'package:cognithor_ui/services/trace_service.dart';

class _MockTraceService extends Mock implements TraceService {}

void main() {
  setUpAll(() {
    registerFallbackValue((CrewEvent _) {});
  });

  testWidgets('renders trace cards from provider state', (tester) async {
    final svc = _MockTraceService();
    when(() => svc.listTraces(status: any(named: 'status'), limit: any(named: 'limit')))
        .thenAnswer((_) async => [
          CrewTraceMeta(traceId: 'trace-1', status: TraceStatus.running, nTasks: 2, totalTokens: 100, agentCount: 1, nFailedGuardrails: 0),
          CrewTraceMeta(traceId: 'trace-2', status: TraceStatus.completed, nTasks: 4, totalTokens: 500, agentCount: 2, nFailedGuardrails: 1),
        ]);
    when(() => svc.subscribeToLifecycle(any())).thenAnswer((_) {});
    when(() => svc.unsubscribeFromLifecycle()).thenAnswer((_) {});

    final provider = TraceProvider(traceService: svc);

    await tester.pumpWidget(
      MaterialApp(
        home: ChangeNotifierProvider<TraceProvider>.value(
          value: provider,
          child: const TraceListScreen(),
        ),
      ),
    );

    await tester.pumpAndSettle();
    expect(find.textContaining('trace-1'), findsOneWidget);
    expect(find.textContaining('trace-2'), findsOneWidget);
  });
}
```

- [ ] **Step 2: Run — expect failure**

```bash
cd flutter_app && flutter test test/screens/trace/trace_list_screen_test.dart 2>&1 | tail -10 && cd ..
```

Expected: ImportError.

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

Create `flutter_app/lib/screens/trace/trace_list_screen.dart`:

```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'package:cognithor_ui/providers/trace_provider.dart';
import 'package:cognithor_ui/screens/trace/trace_detail_screen.dart';
import 'package:cognithor_ui/screens/trace/widgets/trace_card.dart';

class TraceListScreen extends StatefulWidget {
  const TraceListScreen({super.key});

  @override
  State<TraceListScreen> createState() => _TraceListScreenState();
}

class _TraceListScreenState extends State<TraceListScreen> {
  String? _statusFilter;

  @override
  void initState() {
    super.initState();
    final p = context.read<TraceProvider>();
    WidgetsBinding.instance.addPostFrameCallback((_) async {
      await p.loadTraces();
      p.subscribeToLifecycle();
    });
  }

  @override
  void dispose() {
    context.read<TraceProvider>().unsubscribeFromLifecycle();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<TraceProvider>();
    return Scaffold(
      backgroundColor: const Color(0xFF0A0F24),
      appBar: AppBar(
        title: const Text('Traces'),
        backgroundColor: const Color(0xFF131A30),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(12),
            child: Row(
              children: [
                _filterChip('All', null),
                const SizedBox(width: 8),
                _filterChip('Running', 'running'),
                const SizedBox(width: 8),
                _filterChip('Completed', 'completed'),
                const SizedBox(width: 8),
                _filterChip('Failed', 'failed'),
              ],
            ),
          ),
          if (provider.errorMessage != null)
            Padding(
              padding: const EdgeInsets.all(12),
              child: Text(
                provider.errorMessage!,
                style: const TextStyle(color: Color(0xFFFF5252)),
              ),
            ),
          Expanded(
            child: provider.isLoading
                ? const Center(child: CircularProgressIndicator())
                : provider.traces.isEmpty
                    ? const Center(
                        child: Text(
                          'No traces yet',
                          style: TextStyle(color: Color(0xFF6B7A99)),
                        ),
                      )
                    : ListView.builder(
                        padding: const EdgeInsets.symmetric(horizontal: 12),
                        itemCount: provider.traces.length,
                        itemBuilder: (ctx, i) {
                          final meta = provider.traces[i];
                          return TraceCard(
                            meta: meta,
                            onTap: () {
                              Navigator.of(ctx).push(
                                MaterialPageRoute(
                                  builder: (_) => TraceDetailScreen(traceId: meta.traceId),
                                ),
                              );
                            },
                          );
                        },
                      ),
          ),
        ],
      ),
    );
  }

  Widget _filterChip(String label, String? value) {
    final selected = _statusFilter == value;
    return ChoiceChip(
      label: Text(label),
      selected: selected,
      onSelected: (s) async {
        setState(() => _statusFilter = value);
        await context.read<TraceProvider>().loadTraces(status: value);
      },
    );
  }
}
```

- [ ] **Step 4: Run — expect pass**

```bash
cd flutter_app && flutter test test/screens/trace/trace_list_screen_test.dart 2>&1 | tail -10 && cd ..
```

Expected: 1 passed.

- [ ] **Step 5: Commit**

```bash
git add flutter_app/lib/screens/trace/trace_list_screen.dart flutter_app/test/screens/trace/trace_list_screen_test.dart
git commit -m "feat(flutter): add TraceListScreen master pane with filter chips"
```

---

### Task 36: Flutter — `TraceDetailScreen` (detail pane)

**Files:**
- Create: `flutter_app/lib/screens/trace/trace_detail_screen.dart`
- Create: `flutter_app/test/screens/trace/trace_detail_screen_test.dart`

- [ ] **Step 1: Implement TraceDetailScreen**

Create `flutter_app/lib/screens/trace/trace_detail_screen.dart`:

```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'package:cognithor_ui/models/crew_trace.dart';
import 'package:cognithor_ui/providers/trace_provider.dart';
import 'package:cognithor_ui/screens/trace/widgets/event_row.dart';
import 'package:cognithor_ui/screens/trace/widgets/stats_sidebar.dart';

class TraceDetailScreen extends StatefulWidget {
  final String traceId;

  const TraceDetailScreen({super.key, required this.traceId});

  @override
  State<TraceDetailScreen> createState() => _TraceDetailScreenState();
}

class _TraceDetailScreenState extends State<TraceDetailScreen> {
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<TraceProvider>().pinTrace(widget.traceId);
    });
  }

  @override
  void dispose() {
    context.read<TraceProvider>().unpinTrace();
    _scrollController.dispose();
    super.dispose();
  }

  String? _traceStartedAt(List<CrewEvent> events) {
    for (final e in events) {
      if (e.eventType == 'crew_kickoff_started') return e.timestamp;
    }
    return null;
  }

  CrewTraceMeta? _findMeta(List<CrewTraceMeta> traces) {
    for (final t in traces) {
      if (t.traceId == widget.traceId) return t;
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<TraceProvider>();
    final events = provider.pinnedEvents;
    final stats = provider.pinnedStats;
    final meta = _findMeta(provider.traces);
    final startedAt = _traceStartedAt(events);

    // Auto-scroll to bottom on new event.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
      }
    });

    return Scaffold(
      backgroundColor: const Color(0xFF0A0F24),
      appBar: AppBar(
        title: Text(
          'Trace ${widget.traceId.length > 16 ? "${widget.traceId.substring(0, 16)}…" : widget.traceId}',
          style: const TextStyle(fontFamily: 'JetBrainsMono', fontSize: 14),
        ),
        backgroundColor: const Color(0xFF131A30),
      ),
      body: provider.errorMessage != null
          ? Center(
              child: Padding(
                padding: const EdgeInsets.all(24),
                child: Text(
                  provider.errorMessage!.contains('trace_not_found')
                      ? 'Trace ${widget.traceId} nicht gefunden — möglicherweise rotiert.'
                      : provider.errorMessage!,
                  style: const TextStyle(color: Color(0xFFFF5252)),
                  textAlign: TextAlign.center,
                ),
              ),
            )
          : Row(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Expanded(
                  child: events.isEmpty && provider.isLoading
                      ? const Center(child: CircularProgressIndicator())
                      : ListView.builder(
                          controller: _scrollController,
                          padding: const EdgeInsets.symmetric(horizontal: 16),
                          itemCount: events.length,
                          itemBuilder: (ctx, i) =>
                              EventRow(event: events[i], traceStartedAt: startedAt),
                        ),
                ),
                Container(
                  width: 1,
                  color: const Color(0xFF1F2942),
                ),
                StatsSidebar(stats: stats, meta: meta),
              ],
            ),
    );
  }
}
```

- [ ] **Step 2: Write a smoke widget test**

Create `flutter_app/test/screens/trace/trace_detail_screen_test.dart`:

```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:provider/provider.dart';

import 'package:cognithor_ui/models/crew_trace.dart';
import 'package:cognithor_ui/providers/trace_provider.dart';
import 'package:cognithor_ui/screens/trace/trace_detail_screen.dart';
import 'package:cognithor_ui/services/trace_service.dart';

class _MockTraceService extends Mock implements TraceService {}

void main() {
  setUpAll(() {
    registerFallbackValue((CrewEvent _) {});
  });

  testWidgets('renders timeline events from provider', (tester) async {
    final svc = _MockTraceService();
    when(() => svc.fetchTrace(any())).thenAnswer((_) async => [
          const CrewEvent(
            traceId: 'abc',
            eventType: 'crew_kickoff_started',
            timestamp: '2026-04-26T10:00:00Z',
            details: {'n_tasks': 2},
          ),
          const CrewEvent(
            traceId: 'abc',
            eventType: 'crew_task_started',
            timestamp: '2026-04-26T10:00:01Z',
            details: {'task_id': 't1', 'agent_role': 'researcher'},
          ),
        ]);
    when(() => svc.fetchTraceStats(any())).thenAnswer((_) async => const CrewTraceStats(
          totalTokens: 0,
          agentBreakdown: {},
          guardrailPass: 0,
          guardrailFail: 0,
          guardrailRetries: 0,
        ));
    when(() => svc.subscribeToTrace(any(), any())).thenAnswer((_) {});
    when(() => svc.unsubscribeFromTrace(any())).thenAnswer((_) {});

    final provider = TraceProvider(traceService: svc);

    await tester.pumpWidget(
      MaterialApp(
        home: ChangeNotifierProvider<TraceProvider>.value(
          value: provider,
          child: const TraceDetailScreen(traceId: 'abc'),
        ),
      ),
    );

    await tester.pumpAndSettle();
    expect(find.textContaining('crew_kickoff_started'), findsOneWidget);
    expect(find.textContaining('researcher'), findsOneWidget);
  });
}
```

- [ ] **Step 3: Run — expect pass**

```bash
cd flutter_app && flutter test test/screens/trace/ 2>&1 | tail -10 && cd ..
```

Expected: 2 passed (list + detail).

- [ ] **Step 4: Commit**

```bash
git add flutter_app/lib/screens/trace/trace_detail_screen.dart flutter_app/test/screens/trace/trace_detail_screen_test.dart
git commit -m "feat(flutter): add TraceDetailScreen with timeline + stats sidebar"
```

---

### Task 37: Flutter — Wire TraceProvider into MultiProvider

**Files:**
- Modify: `flutter_app/lib/main.dart` (or wherever providers are wired)

- [ ] **Step 1: Find the existing MultiProvider**

```bash
grep -n "MultiProvider\|ChangeNotifierProvider" flutter_app/lib/main.dart 2>&1 | head -10
```

Identify the file that wires providers into the app root.

- [ ] **Step 2: Add `TraceProvider` to the providers list**

In the file containing `MultiProvider`, locate the providers array. Add (mirror the existing `connection_provider` pattern):

```dart
ChangeNotifierProxyProvider<ConnectionProvider, TraceProvider?>(
  create: (_) => null,
  update: (_, conn, prev) {
    if (conn.state != CognithorConnectionState.connected) return null;
    if (prev != null) return prev;
    return TraceProvider(
      traceService: TraceService(
        apiClient: conn.api,
        wsService: conn.ws,
      ),
    );
  },
),
```

(Adapt the `ConnectionProvider` API if `conn.api`/`conn.ws` accessors are different — peek at the existing provider definition.)

- [ ] **Step 3: Smoke analyze**

```bash
cd flutter_app && flutter analyze lib/main.dart 2>&1 | tail -5 && cd ..
```

Expected: `No issues found!`.

- [ ] **Step 4: Commit**

```bash
git add flutter_app/lib/main.dart
git commit -m "feat(flutter): wire TraceProvider into app MultiProvider"
```

---

### Task 38: Flutter — Owner-conditional Trace tab in `main_shell`

**Files:**
- Modify: `flutter_app/lib/screens/main_shell.dart`

- [ ] **Step 1: Find the navigation list**

```bash
grep -n "_baseScreens\|NavItem\|navItems\|sidebarItems" flutter_app/lib/screens/main_shell.dart | head -15
```

- [ ] **Step 2: Add owner-conditional Trace entry**

Decide on owner-detection: the bootstrap-token's user-id is available on `ConnectionProvider`. If a `userId` getter exists, gate on it:

```dart
final isOwner = context.watch<ConnectionProvider>().userId == const String.fromEnvironment('COGNITHOR_OWNER_USER_ID', defaultValue: 'Alexander Söllner');
```

If no such getter exists yet, simpler: always show Trace tab in v0.95.0 (the backend already 403s non-owner; UI gating is defense-in-depth).

For the simpler path, in the `_baseScreens` list (or equivalent), add:

```dart
const TraceListScreen(),
```

In the `NavItem` list, add:

```dart
const NavItem(
  icon: Icons.timeline,
  label: 'Traces',
  index: <next-available-index>,
),
```

Match the existing pattern for icon + label.

- [ ] **Step 3: Smoke analyze**

```bash
cd flutter_app && flutter analyze lib/screens/main_shell.dart 2>&1 | tail -5 && cd ..
```

Expected: `No issues found!`.

- [ ] **Step 4: Commit**

```bash
git add flutter_app/lib/screens/main_shell.dart
git commit -m "feat(flutter): add Traces nav entry to main_shell"
```

---

### Task 39: Flutter — i18n keys (en + de)

**Files:**
- Modify: `flutter_app/lib/i18n/app_en.arb`
- Modify: `flutter_app/lib/i18n/app_de.arb`

- [ ] **Step 1: Find arb files + existing nav keys**

```bash
grep -n "nav_" flutter_app/lib/i18n/app_en.arb 2>&1 | head -10
```

(If files use a different format/path, adapt — Flutter uses ARB or JSON; check `flutter_app/pubspec.yaml`'s `flutter_localizations` setup.)

- [ ] **Step 2: Add keys to `app_en.arb`**

Append (matching existing JSON structure):

```json
"nav_traces": "Traces",
"@nav_traces": {"description": "Sidebar nav label for the Trace-UI screen"},

"trace_status_running": "RUNNING",
"trace_status_completed": "COMPLETED",
"trace_status_failed": "FAILED",
"trace_filter_all": "All",
"trace_filter_running": "Running",
"trace_filter_completed": "Completed",
"trace_filter_failed": "Failed",
"trace_empty": "No traces yet",
"trace_not_found": "Trace not found — possibly rotated."
```

- [ ] **Step 3: Add keys to `app_de.arb`** (same shape, German values)

```json
"nav_traces": "Traces",
"@nav_traces": {"description": "Sidebar nav label for the Trace-UI screen"},

"trace_status_running": "LÄUFT",
"trace_status_completed": "FERTIG",
"trace_status_failed": "FEHLGESCHLAGEN",
"trace_filter_all": "Alle",
"trace_filter_running": "Läuft",
"trace_filter_completed": "Fertig",
"trace_filter_failed": "Fehlgeschlagen",
"trace_empty": "Noch keine Traces",
"trace_not_found": "Trace nicht gefunden — möglicherweise rotiert."
```

- [ ] **Step 4: Regenerate l10n if the project uses gen-l10n**

```bash
cd flutter_app && flutter gen-l10n 2>&1 | tail -5 && cd ..
```

(Skip if the project uses runtime-loaded JSON instead.)

- [ ] **Step 5: Commit**

```bash
git add flutter_app/lib/i18n/app_en.arb flutter_app/lib/i18n/app_de.arb
git commit -m "feat(flutter): add Trace-UI i18n keys (en + de)"
```

---

### Task 40: Flutter — Integration test (full flow)

**Files:**
- Create: `flutter_app/integration_test/trace_flow_test.dart`

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

Create `flutter_app/integration_test/trace_flow_test.dart`:

```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:provider/provider.dart';

import 'package:cognithor_ui/models/crew_trace.dart';
import 'package:cognithor_ui/providers/trace_provider.dart';
import 'package:cognithor_ui/screens/trace/trace_list_screen.dart';
import 'package:cognithor_ui/services/trace_service.dart';

class _MockTraceService extends Mock implements TraceService {}

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  setUpAll(() {
    registerFallbackValue((CrewEvent _) {});
  });

  testWidgets('list → tap card → detail view shows events', (tester) async {
    final svc = _MockTraceService();
    when(() => svc.listTraces(status: any(named: 'status'), limit: any(named: 'limit')))
        .thenAnswer((_) async => [
              CrewTraceMeta(traceId: 'abc-1', status: TraceStatus.running, nTasks: 1, totalTokens: 50, agentCount: 1, nFailedGuardrails: 0),
            ]);
    when(() => svc.subscribeToLifecycle(any())).thenAnswer((_) {});
    when(() => svc.unsubscribeFromLifecycle()).thenAnswer((_) {});
    when(() => svc.fetchTrace('abc-1')).thenAnswer((_) async => [
          const CrewEvent(traceId: 'abc-1', eventType: 'crew_kickoff_started', details: {'n_tasks': 1}),
        ]);
    when(() => svc.fetchTraceStats('abc-1')).thenAnswer((_) async => const CrewTraceStats(
          totalTokens: 50, agentBreakdown: {'researcher': 50}, guardrailPass: 1, guardrailFail: 0, guardrailRetries: 0,
        ));
    when(() => svc.subscribeToTrace(any(), any())).thenAnswer((_) {});
    when(() => svc.unsubscribeFromTrace(any())).thenAnswer((_) {});

    final provider = TraceProvider(traceService: svc);

    await tester.pumpWidget(
      MaterialApp(
        home: ChangeNotifierProvider<TraceProvider>.value(
          value: provider,
          child: const TraceListScreen(),
        ),
      ),
    );
    await tester.pumpAndSettle();

    // Verify list rendered.
    expect(find.textContaining('abc-1'), findsOneWidget);

    // Tap card → detail view.
    await tester.tap(find.textContaining('abc-1'));
    await tester.pumpAndSettle();

    // Detail view shows the kickoff event.
    expect(find.textContaining('crew_kickoff_started'), findsOneWidget);
  });
}
```

- [ ] **Step 2: Run integration test**

```bash
cd flutter_app && flutter test integration_test/trace_flow_test.dart 2>&1 | tail -10 && cd ..
```

Expected: 1 passed.

- [ ] **Step 3: Commit**

```bash
git add flutter_app/integration_test/trace_flow_test.dart
git commit -m "test(flutter): integration test for full trace list → detail flow"
```

---

### Task 41: Flutter — analyze + dart format pass

**Files:** none (verification)

- [ ] **Step 1: Analyze**

```bash
cd flutter_app && flutter analyze 2>&1 | tail -10 && cd ..
```

Expected: `No issues found!`.

- [ ] **Step 2: Format check**

```bash
cd flutter_app && dart format --output=none --set-exit-if-changed lib/ test/ integration_test/ 2>&1 | tail -10 && cd ..
```

Expected: no files need formatting (exit 0).

If files need formatting, run `dart format lib/ test/ integration_test/` and re-stage.

- [ ] **Step 3: Run all Flutter tests**

```bash
cd flutter_app && flutter test 2>&1 | tail -10 && cd ..
```

Expected: all green.

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

```bash
git add flutter_app/
git commit -m "style(flutter): dart format pass"
```

(Skip if no changes.)

---

### Task 42: Backend lint + mypy pass

```bash
ruff check src/cognithor/ tests/test_compat/test_autogen/test_trace_emission.py
ruff format --check src/cognithor/ tests/test_compat/test_autogen/test_trace_emission.py
mypy --strict src/cognithor/crew/trace_bus.py src/cognithor/security/owner.py src/cognithor/api/crew_traces.py
```

Expected: clean. Apply fixes if needed and commit:

```bash
git add src/cognithor/ tests/
git commit -m "style(backend): ruff format + mypy fixups for v0.95.0 stack"
```

(Skip if no changes.)

---

### Task 43: Full-repo regression

```bash
pytest tests/ -x -q --cov=src/cognithor --cov-fail-under=89 2>&1 | tail -10
```

Expected: green; coverage ≥89% (matches existing v0.94.1 baseline).

```bash
cd flutter_app && flutter test 2>&1 | tail -5 && flutter test integration_test/ 2>&1 | tail -5 && cd ..
```

Expected: all Flutter unit tests green; integration test green.

---

### Task 44: CHANGELOG entry for WP4 + WP5

**Files:**
- Modify: `CHANGELOG.md`

Append under `[Unreleased] ### Added`:

```markdown
- Flutter Trace-UI: `TraceListScreen` (master) + `TraceDetailScreen` (detail) with vertical timeline-log + stats sidebar (per-agent token breakdown, guardrail summary, elapsed). REST-fetch on open, WebSocket subscribe for live updates, auto-cleanup on unpin/disconnect (WP4 of v0.95.0).
- `tests/test_compat/test_autogen/test_trace_emission.py` — verifies `cognithor.compat.autogen.AssistantAgent.run()` routes through `cognithor.crew.Crew` so AutoGen-shim runs surface in Trace-UI (WP5 of v0.95.0).
- New i18n keys: `nav_traces`, `trace_status_*`, `trace_filter_*`, `trace_empty`, `trace_not_found` (en + de).
```

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): note WP4 Flutter + WP5 AutoGen-Shim additions for v0.95.0"
```

---

### Task 45: Push + open PR 4

```bash
git push -u origin feat/cognithor-trace-v4-flutter
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
cat > /tmp/pr_trace_v4_body.json <<'JSON_EOF'
{
  "title": "feat(flutter): WP4 Trace-Screen + WP5 AutoGen-Shim verification (v0.95.0 PR 4)",
  "head": "feat/cognithor-trace-v4-flutter",
  "base": "main",
  "body": "## Summary\n- Flutter `TraceListScreen` + `TraceDetailScreen` with vertical timeline-log + stats sidebar.\n- `TraceProvider` (ChangeNotifier) consumes `TraceService` (REST + WS).\n- 5 new widgets: TraceCard, EventRow, StatsSidebar (+ list + detail screens).\n- WP5 verification: `tests/test_compat/test_autogen/test_trace_emission.py` confirms `cognithor.compat.autogen.AssistantAgent.run()` routes through `cognithor.crew.Crew`.\n- i18n keys for en + de.\n- Owner-gating is enforced backend-side (PR 1 + PR 2 + PR 3); Flutter shows the Tab to all sessions and lets the backend 403 non-owners.\n\n## Spec\n- `docs/superpowers/specs/2026-04-26-trace-ui-v095-design.md` §8.4 + §8.5\n\n## Test plan\n- [x] `flutter test` green (all widgets + provider + service)\n- [x] `flutter test integration_test/trace_flow_test.dart` green (list → tap → detail)\n- [x] `flutter analyze` clean\n- [x] `dart format --set-exit-if-changed` clean\n- [x] `pytest tests/test_compat/test_autogen/test_trace_emission.py` green\n- [x] `pytest tests/ -x -q --cov-fail-under=89` green (no regression)\n- [x] Manual: live-test once Ollama+gateway running — Crew kickoff appears in TraceListScreen, click opens TraceDetailScreen with timeline+sidebar."
}
JSON_EOF
cat /tmp/pr_trace_v4_body.json | curl -sL -X POST -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github+json" --data-binary @- "https://api.github.com/repos/Alex8791-cyber/cognithor/pulls" | python -c "import sys,json; d=json.load(sys.stdin); print(f\"PR #{d.get('number')}: {d.get('html_url')}\") if 'number' in d else print('error:', d.get('message'),'errors:',d.get('errors'))"
rm -f /tmp/pr_trace_v4_body.json
```

---

### Task 46: Wait CI green + squash-merge + cleanup

(Same pattern as PR 1/2/3.)

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
python -c "import json; print(json.dumps({'commit_title':'feat(flutter): WP4 Trace-Screen + WP5 AutoGen-Shim (v0.95.0 PR 4) (#<NUM>)','merge_method':'squash'}))" | curl -sL -X PUT -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github+json" --data-binary @- "https://api.github.com/repos/Alex8791-cyber/cognithor/pulls/<NUM>/merge"
```

Cleanup local + remote branches in a separate turn (same pattern as Task 8 Step 8).

---

# Direct-Commit on `main` — v0.95.0 Release-Bundle (Tasks 47-51)

Implements spec §8.6 — pattern reuse from v0.94.0 + v0.94.1. After PR 4 merge: bump version in 5 files, append v0.95.0 highlights to README, roll up CHANGELOG `[Unreleased]` → `[0.95.0]`, commit, tag, push, dispatch `publish.yml`, verify artifacts.

**No PR**: this is a direct sequence of commits on `main`.

**Pre-condition check before Task 47:**

```bash
git checkout main
git pull --ff-only
git log --oneline -10
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/repos/Alex8791-cyber/cognithor/actions/runs?branch=main&per_page=1" | python -c "import sys,json; d=json.load(sys.stdin); r=d['workflow_runs'][0]; print(f\"latest main CI: {r['status']}/{r.get('conclusion')}\")"
```

Expected: latest main CI is `completed/success`; PRs 1-4 (v0.95.0 PRs) all merged.

---

### Task 47: Version-bump commit (5 files)

**Files modified:**
- `pyproject.toml`
- `src/cognithor/__init__.py`
- `flutter_app/pubspec.yaml`
- `flutter_app/lib/providers/connection_provider.dart`
- (CHANGELOG bumped in Task 49 separately)

- [ ] **Step 1: Verify current state of all 4 version files**

```bash
grep -E '^version = "0\.94' pyproject.toml
grep '__version__' src/cognithor/__init__.py
grep '^version:' flutter_app/pubspec.yaml
grep 'kFrontendVersion' flutter_app/lib/providers/connection_provider.dart
```

Expected: all show `0.94.1` (current published version after v0.94.1 hotfix).

- [ ] **Step 2: Bump `pyproject.toml`**

Edit: replace `version = "0.94.1"` with `version = "0.95.0"`.

- [ ] **Step 3: Bump `src/cognithor/__init__.py`**

Edit: replace `__version__ = "0.94.1"` with `__version__ = "0.95.0"`.

- [ ] **Step 4: Bump `flutter_app/pubspec.yaml`**

Edit: replace `version: 0.94.1+1` with `version: 0.95.0+1`.

- [ ] **Step 5: Bump `flutter_app/lib/providers/connection_provider.dart`**

Edit: replace `const String kFrontendVersion = '0.94.1';` with `const String kFrontendVersion = '0.95.0';`.

- [ ] **Step 6: Sanity-check all 4**

```bash
grep -E "version = \"0\.95\.0\"" pyproject.toml
grep "__version__ = \"0.95.0\"" src/cognithor/__init__.py
grep "version: 0.95.0" flutter_app/pubspec.yaml
grep "kFrontendVersion = '0.95.0'" flutter_app/lib/providers/connection_provider.dart
```

Expected: each prints one match.

- [ ] **Step 7: Smoke-import**

```bash
python -c "import cognithor; print(cognithor.__version__)"
```

Expected: `0.95.0`.

- [ ] **Step 8: Commit**

```bash
git add pyproject.toml src/cognithor/__init__.py flutter_app/pubspec.yaml flutter_app/lib/providers/connection_provider.dart
git commit -m "build: bump version to 0.95.0"
```

---

### Task 48: README v0.95.0 highlights

**Files:**
- Modify: `README.md`

- [ ] **Step 1: Read README, find existing Highlights section**

```bash
grep -n "## Highlights\|## What's New\|^### v0\." README.md | head -10
```

- [ ] **Step 2: Insert v0.95.0 Highlights ABOVE the v0.94.x section**

Locate the existing `### v0.94.x` block and insert ABOVE it:

```markdown
### v0.95.0 — Trace-UI (2026-MM-DD)

- **Live-Visibility for running Crews** — new Flutter Trace-UI screen with master-detail
  list + timeline-log + per-agent stats sidebar. Owner-gated.
- **`cognithor.crew.trace_bus.TraceBus`** — in-process pub/sub hooked into
  `compiler.append_audit()`; broadcasts crew lifecycle + per-trace events to
  WebSocket subscribers without changing the Hashline-Guard JSONL persistence.
- **REST endpoints `/api/crew/traces`, `/trace/{id}`, `/trace/{id}/stats`** — read
  the existing audit chain for replay + history. Corruption-tolerant; surfaces
  skipped-line counts via `meta`.
- **WebSocket message types `crew_lifecycle_subscribe` / `crew_subscribe` /
  `crew_unsubscribe`** for live event streaming. Disconnect auto-cleans.
- **AutoGen-Shim coverage verified** — `cognithor.compat.autogen.AssistantAgent.run()`
  routes through `cognithor.crew.Crew` so AutoGen-shim runs surface in Trace-UI.
- **Owner-Token gating** — `cognithor.security.owner.require_owner` reads
  `COGNITHOR_OWNER_USER_ID` env (fallback: `pyproject.toml` author name).
```

(Replace `2026-MM-DD` with today's actual date when committing.)

- [ ] **Step 3: Commit**

```bash
git add README.md
git commit -m "docs(readme): add v0.95.0 Trace-UI highlights"
```

---

### Task 49: CHANGELOG `[Unreleased]` → `[0.95.0]` rollup

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Read CHANGELOG, identify `[Unreleased]` block contents**

The `[Unreleased]` block now contains entries from PRs 1-4 (Tasks 8 / 18 / 26 / 44).

- [ ] **Step 2: Convert to `[0.95.0]` section**

Replace `## [Unreleased]` heading + the existing entries with:

```markdown
## [Unreleased]

## [0.95.0] — 2026-MM-DD

### Added — Trace-UI

- `cognithor.crew.trace_bus.TraceBus` — in-process pub/sub for crew audit events. Hooks `compiler.append_audit()` to live-broadcast crew_* events via lifecycle stream + per-trace topic subscriptions. Backpressure: drop-oldest at 1000-event queue cap. (WP1)
- `cognithor.security.owner.require_owner()` — owner-token gating for Trace-UI surfaces. Reads `COGNITHOR_OWNER_USER_ID` env var with `pyproject.toml` author-name fallback. (WP1)
- Three FastAPI endpoints under `/api/crew/`: `traces` (list with status/since/limit filters), `trace/{id}` (full events), `trace/{id}/stats` (aggregates). Owner-gated via `X-Cognithor-User` header. Reads `~/.cognithor/logs/audit.jsonl` directly with corruption-tolerant parsing. (WP2)
- WebSocket message types `crew_lifecycle_subscribe`, `crew_subscribe`, `crew_unsubscribe` for live Crew event streaming. Owner-gated; non-owner subscribes receive `{type:"error", code:"owner_only"}` and are ignored. Disconnect auto-cleans all subscriptions. Outbound frames: `{type:"crew_lifecycle", payload:<event>}` and `{type:"crew_event", payload:<event>}`. (WP3)
- Flutter Trace-UI: `TraceListScreen` (master) + `TraceDetailScreen` (detail) with vertical timeline-log + stats sidebar (per-agent token breakdown, guardrail summary, elapsed). REST-fetch on open, WebSocket subscribe for live updates, auto-cleanup on unpin/disconnect. (WP4)
- AutoGen-Shim coverage — `cognithor.compat.autogen.AssistantAgent.run()` verified to route through `cognithor.crew.Crew`, so AutoGen-shim runs surface in Trace-UI. (WP5)
- New i18n keys: `nav_traces`, `trace_status_*`, `trace_filter_*`, `trace_empty`, `trace_not_found` (en + de).
- `docs/api/crew-traces.md` — endpoint reference.

### Changed
- `cognithor.crew.compiler.append_audit()` now publishes records to `TraceBus` after the JSONL persistence step (additive; no behaviour change to existing JSONL path).

### License
- No license change; Cognithor remains Apache 2.0.
```

(Replace `2026-MM-DD` with today's actual date.)

- [ ] **Step 3: Commit**

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): roll up [Unreleased] into [0.95.0] entry"
```

---

### Task 50: Push main + tag v0.95.0

- [ ] **Step 1: Push the bump commits to origin**

```bash
git push origin main
```

Expected: 3 commits pushed (Tasks 47, 48, 49); main CI runs.

- [ ] **Step 2: Wait main CI green**

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/repos/Alex8791-cyber/cognithor/actions/runs?branch=main&per_page=1" | python -c "import sys,json; d=json.load(sys.stdin); r=d['workflow_runs'][0]; print(f\"{r['status']}/{r.get('conclusion')} sha={r['head_sha'][:8]}\")"
```

Expected: status `completed/success` for the version-bump commit. Poll until green.

- [ ] **Step 3: Tag v0.95.0**

```bash
git tag -a v0.95.0 -m "Cognithor v0.95.0 — Trace-UI"
git push origin v0.95.0
```

Expected: tag pushed; release workflows fire on tag-push event.

- [ ] **Step 4: Verify all 5 release workflows triggered**

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/repos/Alex8791-cyber/cognithor/actions/runs?per_page=15" | python -c "
import sys,json
d=json.load(sys.stdin)
for r in d['workflow_runs'][:15]:
    if r.get('head_branch')=='v0.95.0':
        print(f\"  {r['name']:40s} {r['status']:12s} {r.get('conclusion') or '-'}\")"
```

Expected: 5 in_progress workflows (Release Build, Build Mobile, Build Linux .deb, Build Windows Installer, Build Flutter Web). Note: `Publish to PyPI` does NOT auto-fire on tag-push due to GitHub's loop-prevention — Task 51 handles it.

---

### Task 51: PyPI publish + verify

- [ ] **Step 1: Wait Release Build to complete (creates GitHub Release)**

Poll until done:

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
while true; do
  S=$(curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/repos/Alex8791-cyber/cognithor/actions/runs?per_page=20" | python -c "
import sys,json
d=json.load(sys.stdin)
for r in d['workflow_runs'][:20]:
    if r.get('head_branch')=='v0.95.0' and r['name']=='Release Build':
        print(r['status']+'/'+(r.get('conclusion') or '-'))
        break")
  echo "Release Build: $S"
  echo "$S" | grep -q "completed" && break
  sleep 60
done
```

- [ ] **Step 2: Dispatch publish.yml**

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
cat <<'EOF' | curl -sL -X POST -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github+json" --data-binary @- "https://api.github.com/repos/Alex8791-cyber/cognithor/actions/workflows/publish.yml/dispatches"
{"ref": "v0.95.0", "inputs": {"tag": "v0.95.0"}}
EOF
echo "publish.yml dispatched for v0.95.0"
```

- [ ] **Step 3: Wait PyPI publish + verify all artifacts**

Poll PyPI:

```bash
sleep 300  # ~5 min for publish pipeline
curl -sL "https://pypi.org/pypi/cognithor/0.95.0/json" | python -c "
import sys,json
try:
    d=json.load(sys.stdin)
    print('version:', d['info']['version'])
    print('files:', len(d.get('urls',[])))
except Exception as e:
    print('NOT YET LIVE')"
```

Expected: `version: 0.95.0`, `files: 2` (wheel + sdist).

- [ ] **Step 4: Verify GitHub Release has 6 platform artifacts**

```bash
TOKEN=$(echo -e "protocol=https\nhost=github.com\n" | git credential fill 2>/dev/null | awk -F= '/^password=/{print $2}')
curl -sL -H "Authorization: token $TOKEN" "https://api.github.com/repos/Alex8791-cyber/cognithor/releases/tags/v0.95.0" | python -c "
import sys,json
d=json.load(sys.stdin)
print(f\"Release: {d['html_url']}\")
print(f\"Assets ({len(d.get('assets',[]))}):\")
for a in sorted(d.get('assets',[]), key=lambda x: x['name']):
    print(f\"  - {a['name']:35s} {a['size']/1024/1024:.1f} MB\")"
```

Expected: 6 assets (Win Setup ~1.5GB, Win Launcher ~99MB, Linux .deb ~125MB, Android APK ~25MB, iOS IPA ~18MB, Flutter Web ~16MB).

- [ ] **Step 5: Curate Release Notes (post-launch polish, optional but recommended)**

Use the same PATCH-via-file pattern from v0.94.x to add curated highlights. Or skip if auto-generated body is sufficient — site changelog page auto-syncs via 1h ISR either way.

- [ ] **Step 6: Acceptance check (per spec §10)**

Walk through each AC item:

```text
- [ ] All 4 PRs merged + cleanup done
- [ ] Direct-Commit-on-main with version bump in all 4 files (CHANGELOG = Task 49)
- [ ] Tag v0.95.0 pushed, all 5 release workflows green
- [ ] PyPI has cognithor==0.95.0 (wheel + sdist)
- [ ] GitHub Release has 6 fresh artifacts
- [ ] No stale artifacts on the release
- [ ] pip install cognithor==0.95.0 works
- [ ] Owner-Token: Trace-Tab erscheint in Flutter, listet aktuelle + historische Crews
- [ ] Live-Test: cognithor CLI startet eine Crew → Trace-UI zeigt sie live (Eyeball-Test mit Ollama)
- [ ] Detail-View zeigt korrekt: Events, Token-Counts, Guardrail-Verdicts, Per-Agent Breakdown
- [ ] Reconnect-Test: WS abreißen → wenn Crew weiter läuft, fängt Re-Connect die Events korrekt ab
- [ ] Non-Owner-Token: Trace-Tab unsichtbar; REST 403; WS subscribe-error
- [ ] AutoGen-Shim-Run erscheint im Dashboard
- [ ] CHANGELOG [0.95.0] Section hat alle 5 WPs gelistet
```

- [ ] **Step 7: Update memory**

Save a memory note:

```markdown
---
name: v0.95.0 ship log
description: Cognithor v0.95.0 (Trace-UI) release record — 5 WPs, 4 PRs, direct-commit
type: project
---
# v0.95.0 ship log

Cognithor v0.95.0 released 2026-MM-DD. Trace-UI ships:
- WP1 TraceBus + Owner-Gating (PR 1)
- WP2 REST API for Traces (PR 2)
- WP3 WebSocket Live-Stream (PR 3)
- WP4 Flutter Trace-Screen + WP5 AutoGen-Shim verification (PR 4)
- Direct-Commit version bump + tag

Pattern reused from v0.94.x release pipeline. Post-release stable; if a v0.95.1
hotfix is needed, mirror the v0.94.1 dry-run-audit flow.

## How to apply
- Subsequent v0.95.x hotfixes: same Direct-Commit-on-main + tag-push pattern.
- Path D candidates for v0.96.0+: F6 Flows (conditional crew chaining), streaming
  token-events for Trace-UI (drop-oldest at higher event rates), per-user multi-tenant
  Trace-UI access.
```

Save to `C:/Users/ArtiCall/.claude/projects/D--Jarvis/memory/project_v0950_ship_log.md` and append a one-line entry to `MEMORY.md`.

- [ ] **Step 8: Final main check**

```bash
git checkout main && git pull --ff-only
git log --oneline -10
```

Expected: clean tree; recent log shows the version-bump commits and the tag.

---

# Plan-end checkpoint

If all of the above is green, **v0.95.0 is LIVE** and matches spec §10 acceptance.





