# Training-Trace Capture & Export 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:** Capture full agent trajectories (assistant turns, thinking, tool calls, tool results) into the existing `events` table during `claw-forge run`, then expose `claw-forge export training` to emit the corpus as JSONL for fine-tuning a smaller code-specific LLM.

**Architecture:** Caller-side tee — `run_agent()` stays a pure SDK wrapper; a new `record_messages()` helper wraps the message stream at four call sites and POSTs each SDK Message to a new `/sessions/{id}/events` endpoint. The existing `events.payload` JSON column is the storage. Export reads `state.db` directly via `sqlite3` (no service dep), filters by task outcome, projects to OpenAI tool-use JSONL by default (with ShareGPT / ChatML / raw-jsonl alternatives), redacts at export time, and emits a single file or train/val/test split.

**Tech Stack:** Python 3.11, asyncio, SQLAlchemy + aiosqlite (state), sqlite3 (export reader), httpx (recorder POSTs), Typer (CLI), pytest. No `transformers` / `peft` / `unsloth` deps — those live in the user's external training repo.

---

## Spec Reference

This plan implements `docs/superpowers/specs/2026-05-06-training-trace-capture-design.md`. Read it first for architectural rationale.

## File Structure

| File | Responsibility |
|---|---|
| `claw_forge/training/__init__.py` | Module root |
| `claw_forge/training/recorder.py` | `record_messages()` tee + `RecordingDestination` dataclass + `_serialize_message()` |
| `claw_forge/training/exporter.py` | Read events → filter → project → JSONL writer |
| `claw_forge/training/projectors.py` | Format-specific projectors: `openai_tools`, `sharegpt`, `chatml`, `raw_jsonl` |
| `claw_forge/training/redact.py` | `Redactor` + `SecretsRule` / `UsernamesRule` / `CustomPatternsRule` |
| `claw_forge/training/cli.py` | Typer subapp: `claw-forge traces status \| prune` |
| `claw_forge/state/models.py` | Add `Task.merge_commit_sha` column |
| `claw_forge/state/service.py` | Add `POST /sessions/{id}/events` + auto-prune startup hook |
| `claw_forge/cli.py` | Convert `export` to sub-Typer; add `training` subcommand; register `traces` subapp; add `training_traces` config validation |
| `claw_forge/agent/runner.py` | Add optional `record_to` param to `run_agent`/`collect_result`/`collect_structured_result` |
| `claw_forge/plugins/{coding,testing,reviewer,bugfix}.py` | Build `RecordingDestination` from `PluginContext` and pass `record_to` |
| `claw_forge/boundaries/{refactor,classifier}.py` | Same — pass `record_to` for boundaries trajectories |
| `claw_forge/git/merge.py` | After squash, PATCH `merge_commit_sha` back to task |
| `tests/training/test_recorder.py` | Tee semantics + best-effort failure |
| `tests/training/test_exporter.py` | Filter / projection / dedupe / split |
| `tests/training/test_redact.py` | Each rule + replacement markers |
| `tests/training/test_traces_cli.py` | `traces status` + `traces prune` |
| `docs/commands.md` | Document `claw-forge export training` + `claw-forge traces` |
| `docs/training/unsloth-recipe.md` | Worked Qwen2.5-Coder + LoRA recipe |
| `CLAUDE.md` | Append "Training-Trace Capture" architecture section |

---

# Phase A — Capture

### Task A1: Add `Task.merge_commit_sha` column to ORM

**Files:**
- Modify: `claw_forge/state/models.py` (around the `Task` class, line ~108)
- Test: `tests/training/test_recorder.py` (new — tests the column)

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

Create `tests/training/__init__.py` (empty) and `tests/training/test_recorder.py`:

```python
"""Tests for training-trace capture."""
from __future__ import annotations

import asyncio
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from claw_forge.state.models import Base, Session, Task


@pytest.mark.asyncio
async def test_task_has_merge_commit_sha_column() -> None:
    """Task ORM must expose a nullable merge_commit_sha column."""
    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)

    async with factory() as db:
        sess = Session(id="s1", project_path="/tmp/x", status="active")
        task = Task(
            id="t1", session_id="s1", plugin_name="coding",
            description="x", merge_commit_sha="abc1234",
        )
        db.add_all([sess, task])
        await db.commit()

    async with factory() as db:
        loaded = (await db.execute(select(Task).where(Task.id == "t1"))).scalar_one()
        assert loaded.merge_commit_sha == "abc1234"

    async with factory() as db:
        sess2 = Session(id="s2", project_path="/tmp/y", status="active")
        task2 = Task(id="t2", session_id="s2", plugin_name="coding")
        db.add_all([sess2, task2])
        await db.commit()
        loaded2 = (await db.execute(select(Task).where(Task.id == "t2"))).scalar_one()
        assert loaded2.merge_commit_sha is None
```

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

Run: `uv run pytest tests/training/test_recorder.py::test_task_has_merge_commit_sha_column -v`
Expected: FAIL with `TypeError: 'merge_commit_sha' is an invalid keyword argument for Task`

- [ ] **Step 3: Add the column**

In `claw_forge/state/models.py`, inside the `Task` class, immediately after the existing `bugfix_retry_count` column (around line 138):

```python
    merge_commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
```

The state service uses `CREATE TABLE IF NOT EXISTS`, so adding a column to a fresh DB just works. For DBs created by older versions, the missing column is the user's problem to migrate (consistent with the project's "no migrations" convention — see `CLAUDE.md` Key Conventions).

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/training/test_recorder.py::test_task_has_merge_commit_sha_column -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add claw_forge/state/models.py tests/training/__init__.py tests/training/test_recorder.py
git commit -m "feat(state): add Task.merge_commit_sha for training-trace ground truth"
```

---

### Task A2: Add `POST /sessions/{id}/events` endpoint

**Files:**
- Modify: `claw_forge/state/service.py` (new endpoint near the existing `GET /sessions/{id}/events` at line ~1023)
- Test: `tests/training/test_recorder.py` (extend)

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

Append to `tests/training/test_recorder.py`:

```python
import json
import httpx
from fastapi.testclient import TestClient


@pytest.fixture
def state_client(tmp_path, monkeypatch):
    """Spin up a real state service against a temp SQLite DB."""
    monkeypatch.setenv("CLAW_FORGE_STATE_DB", str(tmp_path / "state.db"))
    from claw_forge.state.service import build_app
    app = build_app(db_path=tmp_path / "state.db")
    with TestClient(app) as client:
        yield client


def test_post_event_persists_payload(state_client):
    sresp = state_client.post("/sessions", json={"project_path": "/tmp/p"})
    assert sresp.status_code == 201
    sid = sresp.json()["id"]
    tresp = state_client.post(
        f"/sessions/{sid}/tasks",
        json={"plugin_name": "coding", "description": "x"},
    )
    tid = tresp.json()["id"]

    payload = {"turn": 0, "kind": "AssistantMessage", "content": [{"type": "text", "text": "hi"}]}
    eresp = state_client.post(
        f"/sessions/{sid}/events",
        json={"event_type": "agent_message", "task_id": tid, "payload": payload},
    )
    assert eresp.status_code == 201
    assert eresp.json() == {"status": "recorded"}

    # Round-trip via SSE-listing route's underlying table
    list_resp = state_client.get(f"/sessions/{sid}/events/all")
    assert list_resp.status_code == 200
    rows = list_resp.json()
    assert any(
        r["event_type"] == "agent_message" and r["payload"]["content"][0]["text"] == "hi"
        for r in rows
    )
```

You will also need to add a small helper endpoint `GET /sessions/{id}/events/all` for the test (the existing `GET /sessions/{id}/events` is SSE-streaming). Add it in the same task — see Step 3.

The fixture assumes `build_app(db_path=...)` exists. Check `state/service.py` — if there is no `build_app` function with this signature, adapt the fixture to whatever factory the service exposes (search for `def build_app` or `create_app` in `service.py`). If neither exists, add a thin `build_app(db_path: Path) -> FastAPI` shim that the existing entry point also calls.

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

Run: `uv run pytest tests/training/test_recorder.py::test_post_event_persists_payload -v`
Expected: FAIL with 404 or 405 on the POST.

- [ ] **Step 3: Add the endpoint + the test helper**

In `claw_forge/state/service.py`, near the existing `GET /sessions/{session_id}/events` (line ~1023), add:

```python
        @app.post("/sessions/{session_id}/events", status_code=201)
        async def post_event(
            session_id: str,
            body: dict[str, Any],
        ) -> dict[str, str]:
            """Record an event for a session/task.

            Used by the training-trace recorder to persist agent messages.
            Body: {event_type: str, task_id: str | None, payload: dict}.
            Best-effort by design: no validation beyond required keys.
            """
            event_type = body.get("event_type")
            if not event_type:
                raise HTTPException(status_code=400, detail="event_type required")
            await self._emit_event(
                session_id=session_id,
                task_id=body.get("task_id"),
                event_type=event_type,
                payload=body.get("payload") or {},
            )
            return {"status": "recorded"}

        @app.get("/sessions/{session_id}/events/all")
        async def list_events(session_id: str) -> list[dict[str, Any]]:
            """Return all events for a session as a JSON array.

            Used by tests; the streaming /events route stays the canonical UI feed.
            """
            async with self._session_factory() as db:
                rows = (await db.execute(
                    select(Event).where(Event.session_id == session_id).order_by(Event.created_at)
                )).scalars().all()
                return [
                    {"id": r.id, "task_id": r.task_id, "event_type": r.event_type,
                     "payload": r.payload, "created_at": r.created_at.isoformat()}
                    for r in rows
                ]
```

`HTTPException` is already imported in `service.py`; if not, add `from fastapi import HTTPException` to the import block.

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/training/test_recorder.py -v`
Expected: PASS for both tests in this file.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/state/service.py tests/training/test_recorder.py
git commit -m "feat(state): POST /sessions/{id}/events endpoint for trace recorder"
```

---

### Task A3: Create `claw_forge/training/recorder.py`

**Files:**
- Create: `claw_forge/training/__init__.py`
- Create: `claw_forge/training/recorder.py`
- Test: `tests/training/test_recorder.py` (extend)

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

Append to `tests/training/test_recorder.py`:

```python
from dataclasses import dataclass
from claw_forge.training.recorder import RecordingDestination, record_messages, _serialize_message


@dataclass
class _FakeTextBlock:
    text: str
    type: str = "text"


@dataclass
class _FakeAssistantMessage:
    content: list  # list of blocks
    model: str = "claude-sonnet-4-5"

    @property
    def __class__(self):
        # Mimic the SDK's class-name-based dispatch: tests check kind=AssistantMessage
        class _Cls:
            __name__ = "AssistantMessage"
        return _Cls


def test_serialize_message_preserves_content_blocks():
    msg = _FakeAssistantMessage(content=[_FakeTextBlock(text="hello")])
    out = _serialize_message(msg, turn=3)
    assert out["turn"] == 3
    assert out["kind"] == "AssistantMessage"
    assert out["content"][0]["type"] == "text"
    assert out["content"][0]["text"] == "hello"


@pytest.mark.asyncio
async def test_record_messages_tees_and_yields(state_client):
    """Every message yielded upstream is also POSTed to /events."""
    sid = state_client.post("/sessions", json={"project_path": "/tmp"}).json()["id"]
    tid = state_client.post(
        f"/sessions/{sid}/tasks", json={"plugin_name": "coding", "description": "x"}
    ).json()["id"]

    async def upstream():
        yield _FakeAssistantMessage(content=[_FakeTextBlock(text="m1")])
        yield _FakeAssistantMessage(content=[_FakeTextBlock(text="m2")])

    dest = RecordingDestination(
        session_id=sid, task_id=tid, state_url=str(state_client.base_url)
    )

    received = []
    async for msg in record_messages(upstream(), dest=dest):
        received.append(msg)
    assert len(received) == 2

    rows = state_client.get(f"/sessions/{sid}/events/all").json()
    agent_msgs = [r for r in rows if r["event_type"] == "agent_message"]
    assert len(agent_msgs) == 2
    assert agent_msgs[0]["payload"]["turn"] == 0
    assert agent_msgs[1]["payload"]["turn"] == 1


@pytest.mark.asyncio
async def test_record_messages_swallows_post_failures():
    """Recorder failures must never abort the upstream stream."""
    async def upstream():
        yield _FakeAssistantMessage(content=[_FakeTextBlock(text="m")])

    dest = RecordingDestination(
        session_id="nonexistent", task_id="nonexistent",
        state_url="http://127.0.0.1:1",  # nothing listening — connection refused
    )
    received = []
    async for msg in record_messages(upstream(), dest=dest):
        received.append(msg)
    assert len(received) == 1
```

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

Run: `uv run pytest tests/training/test_recorder.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'claw_forge.training'`

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

Create `claw_forge/training/__init__.py`:

```python
"""Training-trace capture & export.

Captures full agent trajectories from claw-forge runs into the events table,
then exports them as JSONL for fine-tuning smaller code-specific LLMs.

Disabled by default — opt in via `training_traces.enabled` in claw-forge.yaml.
"""
```

Create `claw_forge/training/recorder.py`:

```python
"""Tee recorder: persist each SDK Message to the state-service events table.

Caller-side tap. Wrap an async iterator from `run_agent()` (or its
`collect_result` / `collect_structured_result` callers) before consuming:

    stream = run_agent(prompt, ...)
    if dest is not None:
        stream = record_messages(stream, dest=dest)
    async for message in stream:
        ...

Best-effort by design — recorder failures never abort the agent.
"""
from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any, AsyncIterator

import httpx

log = logging.getLogger(__name__)


@dataclass(frozen=True)
class RecordingDestination:
    """Where to send agent_message events.

    All three fields are required; absence of a destination means
    "don't record" (the wrapper is skipped at the call site).
    """
    session_id: str
    task_id: str
    state_url: str


async def record_messages(
    messages: AsyncIterator[Any],
    *,
    dest: RecordingDestination,
) -> AsyncIterator[Any]:
    """Tee: POST each message to /events, then re-yield to caller.

    The HTTP client is created per call (cheap; recorder is invoked once per
    task). Connection / timeout errors are caught and logged so a transient
    state-service hiccup never aborts an in-flight task.
    """
    turn = 0
    async with httpx.AsyncClient(timeout=5.0) as client:
        async for message in messages:
            try:
                payload = _serialize_message(message, turn=turn)
                await client.post(
                    f"{dest.state_url}/sessions/{dest.session_id}/events",
                    json={
                        "event_type": "agent_message",
                        "task_id": dest.task_id,
                        "payload": payload,
                    },
                )
            except Exception:
                log.warning(
                    "trace recording failed for task %s turn %d; continuing",
                    dest.task_id, turn, exc_info=True,
                )
            turn += 1
            yield message


def _serialize_message(message: Any, *, turn: int) -> dict[str, Any]:
    """Serialize an SDK Message to a JSON-safe dict.

    Faithful to whatever shape the SDK returns — uses dataclasses.asdict
    when applicable, otherwise __dict__ traversal. The export-time projector
    re-interprets these blocks.
    """
    kind = type(message).__name__
    out: dict[str, Any] = {
        "turn": turn,
        "kind": kind,
    }
    # Per-attribute extraction — robust to SDK changes
    for attr in ("model", "stop_reason", "usage"):
        val = getattr(message, attr, None)
        if val is not None:
            out[attr] = _to_jsonable(val)
    content = getattr(message, "content", None)
    if content is not None:
        out["content"] = [_block_to_jsonable(b) for b in content]
    # ResultMessage carries `.result` (final text); preserve it
    result = getattr(message, "result", None)
    if result is not None:
        out["result"] = result
    return out


def _block_to_jsonable(block: Any) -> dict[str, Any]:
    """Convert one content block (TextBlock / ThinkingBlock / ToolUseBlock /
    ToolResultBlock) into a JSON dict that mirrors the SDK's wire format."""
    btype = getattr(block, "type", None) or type(block).__name__.lower().replace("block", "")
    out: dict[str, Any] = {"type": btype}
    for attr in ("text", "thinking", "id", "name", "input", "tool_use_id", "content", "is_error"):
        val = getattr(block, attr, None)
        if val is not None:
            out[attr] = _to_jsonable(val)
    return out


def _to_jsonable(val: Any) -> Any:
    """Recursive shallow JSON-ification: dicts/lists/scalars pass through,
    objects with __dict__ are flattened."""
    if isinstance(val, (str, int, float, bool, type(None))):
        return val
    if isinstance(val, dict):
        return {k: _to_jsonable(v) for k, v in val.items()}
    if isinstance(val, (list, tuple)):
        return [_to_jsonable(v) for v in val]
    if hasattr(val, "__dict__"):
        return {k: _to_jsonable(v) for k, v in val.__dict__.items() if not k.startswith("_")}
    return str(val)
```

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/training/test_recorder.py -v`
Expected: PASS for all four tests in this file.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/__init__.py claw_forge/training/recorder.py tests/training/test_recorder.py
git commit -m "feat(training): record_messages tee + RecordingDestination"
```

---

### Task A4: Wire `record_to` into `run_agent` / `collect_result` / `collect_structured_result`

**Files:**
- Modify: `claw_forge/agent/runner.py` (around lines 29, 257, 272)
- Test: `tests/training/test_recorder.py` (extend)

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

Append to `tests/training/test_recorder.py`:

```python
@pytest.mark.asyncio
async def test_collect_result_threads_record_to(monkeypatch, state_client):
    """collect_result should pass record_to through to run_agent and tee."""
    sid = state_client.post("/sessions", json={"project_path": "/tmp"}).json()["id"]
    tid = state_client.post(
        f"/sessions/{sid}/tasks", json={"plugin_name": "coding", "description": "x"}
    ).json()["id"]

    async def fake_run_agent(prompt, **kwargs):
        rec = kwargs.get("record_to")
        # Verify the param threaded through
        assert rec is not None
        assert rec.session_id == sid

        class _Result:
            __class__ = type("ResultMessage", (), {"__name__": "ResultMessage"})
            result = "done"
            content = []
        yield _Result()

    monkeypatch.setattr("claw_forge.agent.runner.query", lambda *a, **k: fake_run_agent("p"))

    from claw_forge.agent.runner import collect_result
    from claw_forge.training.recorder import RecordingDestination

    dest = RecordingDestination(
        session_id=sid, task_id=tid, state_url=str(state_client.base_url)
    )
    result = await collect_result("hello", record_to=dest)
    assert result == "done"
```

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

Run: `uv run pytest tests/training/test_recorder.py::test_collect_result_threads_record_to -v`
Expected: FAIL — `record_to` is not a valid parameter of `collect_result`.

- [ ] **Step 3: Add the param to all three functions**

In `claw_forge/agent/runner.py`, modify `run_agent`:

After the existing parameter list (the line `auto_inject_skills: bool = True,` around line 55), add:

```python
    record_to: "RecordingDestination | None" = None,
```

At the top of the file, add the conditional import:

```python
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from claw_forge.training.recorder import RecordingDestination
```

Then replace the existing trailing block:

```python
    async for message in query(prompt=prompt, options=options):
        yield message
```

with:

```python
    stream = query(prompt=prompt, options=options)
    if record_to is not None:
        from claw_forge.training.recorder import record_messages
        stream = record_messages(stream, dest=record_to)
    async for message in stream:
        yield message
```

In `collect_result` (around line 257), add `record_to=None` to the keyword args and pass it through:

```python
async def collect_result(
    prompt: str,
    *,
    max_turns: int = 300,
    edit_mode: str = "str_replace",
    record_to: "RecordingDestination | None" = None,
    **kwargs: Any,
) -> str:
    """Run agent and return the final text result."""
    result_text = ""
    async for message in run_agent(
        prompt, max_turns=max_turns, edit_mode=edit_mode,
        record_to=record_to, **kwargs,
    ):
        if message.__class__.__name__ == "ResultMessage":
            result_text = message.result or ""  # type: ignore[union-attr]
    return result_text
```

Same change to `collect_structured_result` (around line 272): add `record_to: "RecordingDestination | None" = None` to its kwargs and forward to the inner `run_agent` call (line ~307).

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/training/test_recorder.py -v`
Expected: PASS.

Run: `uv run pytest tests/agent/ -v` (smoke check that existing runner tests still pass)
Expected: PASS for all existing tests.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/agent/runner.py tests/training/test_recorder.py
git commit -m "feat(agent): thread record_to through run_agent/collect_result/collect_structured_result"
```

---

### Task A5: Add `training_traces` config block + opt-in validation

**Files:**
- Modify: `claw_forge/cli.py` near `_load_config` (line ~244)
- Modify: `claw_forge/cli.py` `_scaffold_config` (line ~220 — extend the YAML template emitted by `claw-forge init`)
- Test: new `tests/training/test_config.py`

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

Create `tests/training/test_config.py`:

```python
"""Tests for the training_traces config block."""
from __future__ import annotations

import pytest

from claw_forge.training.config import (
    TrainingTracesConfig,
    extract_training_traces_config,
)


def test_default_off():
    cfg = extract_training_traces_config({})
    assert cfg.enabled is False
    assert cfg.acknowledged_terms is False
    assert cfg.recording_active is False  # both flags must be true to record


def test_enabled_only_does_not_activate():
    cfg = extract_training_traces_config({"training_traces": {"enabled": True}})
    assert cfg.enabled is True
    assert cfg.acknowledged_terms is False
    assert cfg.recording_active is False


def test_both_flags_activate():
    cfg = extract_training_traces_config({
        "training_traces": {"enabled": True, "acknowledged_terms": True},
    })
    assert cfg.recording_active is True


def test_redaction_defaults():
    cfg = extract_training_traces_config({"training_traces": {"enabled": True}})
    assert cfg.redaction_secrets is True
    assert cfg.redaction_usernames is True
    assert cfg.redaction_paths is False
    assert cfg.redaction_custom_patterns == []


def test_retention_defaults():
    cfg = extract_training_traces_config({})
    assert cfg.retention_keep_days == 0
    assert cfg.retention_on_startup is True
```

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

Run: `uv run pytest tests/training/test_config.py -v`
Expected: FAIL — module `claw_forge.training.config` does not exist.

- [ ] **Step 3: Implement the config module**

Create `claw_forge/training/config.py`:

```python
"""Config schema for training_traces in claw-forge.yaml.

Two-key opt-in by design: enabled + acknowledged_terms must both be true
before any traces are recorded.  See the spec at
docs/superpowers/specs/2026-05-06-training-trace-capture-design.md.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any


@dataclass(frozen=True)
class TrainingTracesConfig:
    enabled: bool = False
    acknowledged_terms: bool = False
    redaction_secrets: bool = True
    redaction_usernames: bool = True
    redaction_paths: bool = False
    redaction_custom_patterns: list[str] = field(default_factory=list)
    retention_keep_days: int = 0
    retention_on_startup: bool = True

    @property
    def recording_active(self) -> bool:
        """Both flags must flip on for recording to actually happen."""
        return self.enabled and self.acknowledged_terms


def extract_training_traces_config(raw: dict[str, Any]) -> TrainingTracesConfig:
    """Extract the training_traces block from a loaded claw-forge.yaml dict.

    Missing keys take their dataclass defaults (everything OFF).
    """
    block = raw.get("training_traces") or {}
    redaction = block.get("redaction") or {}
    retention = block.get("retention") or {}
    return TrainingTracesConfig(
        enabled=bool(block.get("enabled", False)),
        acknowledged_terms=bool(block.get("acknowledged_terms", False)),
        redaction_secrets=bool(redaction.get("secrets", True)),
        redaction_usernames=bool(redaction.get("usernames", True)),
        redaction_paths=bool(redaction.get("paths", False)),
        redaction_custom_patterns=list(redaction.get("custom_patterns") or []),
        retention_keep_days=int(retention.get("keep_days", 0)),
        retention_on_startup=bool(retention.get("on_state_service_startup", True)),
    )
```

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/training/test_config.py -v`
Expected: PASS for all five tests.

- [ ] **Step 5: Update the config scaffold + commit**

Open `claw_forge/cli.py`, find `_scaffold_config` near line 220. The function writes a template `claw-forge.yaml`. Find the part that writes the YAML body (search for the multi-line string starting with `state:` or similar). After the existing top-level blocks, append:

```yaml
# Training-trace capture (OFF by default).
# When enabled, claw-forge records every agent message during `claw-forge run`
# into the state.db events table for export-and-fine-tune workflows.
# Both 'enabled' AND 'acknowledged_terms' must be true to actually record.
# See docs/training/unsloth-recipe.md for the export → train flow.
training_traces:
  enabled: false
  acknowledged_terms: false       # set to true after reviewing Anthropic's Usage Policies
  redaction:
    secrets: true                 # scrub well-known token / API-key patterns
    usernames: true               # /Users/<you>/ → /Users/<USER>/
    paths: false                  # full path anonymization (signal-destroying; off)
    custom_patterns: []
  retention:
    keep_days: 0                  # 0 = forever; otherwise prune agent_message events older than N days
    on_state_service_startup: true
```

Then commit:

```bash
git add claw_forge/training/config.py claw_forge/cli.py tests/training/test_config.py
git commit -m "feat(training): training_traces config block + two-key opt-in"
```

---

### Task A6: Banner on `enabled-but-not-acknowledged`

**Files:**
- Modify: `claw_forge/state/service.py` (lifespan startup, search for `async def lifespan` or the `init_db` block around line 100-300)
- Test: `tests/training/test_config.py` (extend)

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

Append to `tests/training/test_config.py`:

```python
def test_banner_message_quotes_policy():
    from claw_forge.training.config import banner_message_for
    cfg = TrainingTracesConfig(enabled=True, acknowledged_terms=False)
    msg = banner_message_for(cfg)
    assert msg is not None
    assert "training_traces.enabled=true" in msg
    assert "acknowledged_terms" in msg
    assert "Anthropic" in msg


def test_no_banner_when_off_or_active():
    from claw_forge.training.config import banner_message_for
    assert banner_message_for(TrainingTracesConfig()) is None
    assert banner_message_for(
        TrainingTracesConfig(enabled=True, acknowledged_terms=True)
    ) is None
```

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

Run: `uv run pytest tests/training/test_config.py -v`
Expected: FAIL — `banner_message_for` doesn't exist.

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

Append to `claw_forge/training/config.py`:

```python
def banner_message_for(cfg: TrainingTracesConfig) -> str | None:
    """If the user enabled traces but hasn't acknowledged terms, return the
    banner text to log once at state-service startup. Otherwise None.
    """
    if not cfg.enabled or cfg.acknowledged_terms:
        return None
    return (
        "\n"
        "─── Training-trace capture is GATED ───\n"
        "  training_traces.enabled=true but acknowledged_terms=false.\n"
        "  No traces will be recorded until you set acknowledged_terms=true.\n"
        "\n"
        "  Reminder: Anthropic's Usage Policies prohibit using Claude outputs\n"
        "  to develop models that compete with Anthropic's services.  This\n"
        "  feature is intended for personal/internal distillation on your own\n"
        "  code, for your own internal use.  Distribution of derived models\n"
        "  is your responsibility.\n"
        "──────────────────────────────────────────"
    )
```

Wire it into `state/service.py` startup. Find the lifespan block and add (somewhere logical near the WAL-checkpoint logic):

```python
        # Training-trace banner (one-time per service start)
        try:
            from claw_forge.training.config import (
                banner_message_for,
                extract_training_traces_config,
            )
            tt_cfg = extract_training_traces_config(raw_config)
            banner = banner_message_for(tt_cfg)
            if banner:
                logger.warning(banner)
        except Exception:
            logger.debug("training_traces banner check failed", exc_info=True)
```

The `raw_config` variable name varies — search `service.py` for where the YAML config dict is loaded (look for `yaml.safe_load` or the `config_path` parameter handling). Use whatever the local name is.

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/training/test_config.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/config.py claw_forge/state/service.py tests/training/test_config.py
git commit -m "feat(training): startup banner when enabled-but-not-acknowledged"
```

---

### Task A7: Wire recording into the four `run_agent` callers

**Files:**
- Modify: `claw_forge/plugins/coding.py` (line ~133)
- Modify: `claw_forge/plugins/testing.py` (line ~63)
- Modify: `claw_forge/plugins/reviewer.py` (line ~111)
- Modify: `claw_forge/plugins/bugfix.py` (line ~97 — uses run_agent directly)
- Modify: `claw_forge/boundaries/refactor.py` (line ~50)
- Modify: `claw_forge/boundaries/classifier.py` (line ~77)
- Modify: `claw_forge/cli.py` (where `PluginContext` is built for the dispatcher's `task_handler`, search for `PluginContext(` usages around line 4003 + the inline construction in the run command)

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

Append to `tests/training/test_recorder.py`:

```python
@pytest.mark.asyncio
async def test_plugin_passes_record_to_when_enabled(monkeypatch, state_client):
    """A plugin should build a RecordingDestination from PluginContext.config
    when training_traces.recording_active is true, and pass it to collect_result."""
    sid = state_client.post("/sessions", json={"project_path": "/tmp"}).json()["id"]
    tid = state_client.post(
        f"/sessions/{sid}/tasks", json={"plugin_name": "coding", "description": "x"}
    ).json()["id"]

    captured: dict = {}

    async def fake_collect_result(prompt, **kwargs):
        captured["record_to"] = kwargs.get("record_to")
        return "ok"

    monkeypatch.setattr("claw_forge.plugins.coding.collect_result", fake_collect_result)

    from claw_forge.plugins.base import PluginContext
    from claw_forge.plugins.coding import CodingPlugin

    ctx = PluginContext(
        project_path="/tmp",
        session_id=sid,
        task_id=tid,
        config={
            "state_url": str(state_client.base_url),
            "training_traces": {"enabled": True, "acknowledged_terms": True},
        },
    )
    plugin = CodingPlugin()
    await plugin.execute(ctx)

    assert captured["record_to"] is not None
    assert captured["record_to"].session_id == sid
    assert captured["record_to"].task_id == tid


@pytest.mark.asyncio
async def test_plugin_omits_record_to_when_disabled(monkeypatch):
    """When training_traces.recording_active is false, record_to is None."""
    captured: dict = {}

    async def fake_collect_result(prompt, **kwargs):
        captured["record_to"] = kwargs.get("record_to")
        return "ok"

    monkeypatch.setattr("claw_forge.plugins.coding.collect_result", fake_collect_result)

    from claw_forge.plugins.base import PluginContext
    from claw_forge.plugins.coding import CodingPlugin

    ctx = PluginContext(
        project_path="/tmp", session_id="s", task_id="t",
        config={"state_url": "http://x", "training_traces": {}},
    )
    await CodingPlugin().execute(ctx)
    assert captured["record_to"] is None
```

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

Run: `uv run pytest tests/training/test_recorder.py::test_plugin_passes_record_to_when_enabled tests/training/test_recorder.py::test_plugin_omits_record_to_when_disabled -v`
Expected: FAIL — plugin doesn't yet build/pass the destination.

- [ ] **Step 3: Add a small helper + wire each call site**

First, add to `claw_forge/training/recorder.py`:

```python
def destination_from_context(
    *,
    session_id: str,
    task_id: str,
    config: dict[str, Any],
) -> "RecordingDestination | None":
    """Build a RecordingDestination from a PluginContext.config dict.

    Returns None when traces are not active (the caller should pass
    record_to=None and skip the wrap entirely).
    """
    from claw_forge.training.config import extract_training_traces_config

    tt = extract_training_traces_config(config)
    if not tt.recording_active:
        return None
    state_url = config.get("state_url")
    if not state_url:
        return None
    return RecordingDestination(
        session_id=session_id, task_id=task_id, state_url=state_url,
    )
```

Then at each of the six call sites, before the `run_agent` / `collect_result` / `collect_structured_result` call, add:

```python
from claw_forge.training.recorder import destination_from_context
record_to = destination_from_context(
    session_id=context.session_id,
    task_id=context.task_id,
    config=context.config,
)
```

…and pass `record_to=record_to` as a kwarg into the call. Concrete edits:

**`claw_forge/plugins/coding.py:133`** (inside `execute`):
```python
result = await collect_result(prompt, ..., record_to=record_to)
```

**`claw_forge/plugins/testing.py:63`** — same pattern, in `execute`.

**`claw_forge/plugins/reviewer.py:111`** — same.

**`claw_forge/plugins/bugfix.py:97`** — uses `run_agent` directly:
```python
async for message in run_agent(prompt, ..., record_to=record_to):
    ...
```

**`claw_forge/boundaries/refactor.py:50`** — uses `run_agent` directly. Build `record_to` from whatever context this function receives. If it does not currently receive a config dict, add an optional `record_to: RecordingDestination | None = None` parameter and require the caller in `boundaries/apply.py` to pass it through (search for the call site and thread it).

**`claw_forge/boundaries/classifier.py:77`** — uses `collect_structured_result`; add `record_to=record_to` after building from context.

Finally — and this is the part the dispatcher engineer must not miss — `PluginContext.config` does not currently carry `state_url` or `training_traces`. Find the `task_handler` in `claw_forge/cli.py` (around line 1181) and the `PluginContext(` construction it does. Wherever `PluginContext` is built for runtime task dispatch, add to the `config` dict:

```python
config={
    ...existing keys,
    "state_url": _state_base,                            # the local var holding http://localhost:<port>
    "training_traces": cfg.get("training_traces", {}),   # the raw config block; lazily parsed in the helper
},
```

The three other `PluginContext(` constructions in `cli.py` (lines 2471, 2797, 4003) are for non-runtime contexts (`plan`, `add`); they do not need `state_url` or `training_traces`. Leave those alone.

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

Run: `uv run pytest tests/training/test_recorder.py -v`
Expected: PASS for every test in this file.

Run: `uv run pytest tests/ -q -x` (full smoke, fail fast)
Expected: PASS — no regression in existing tests.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/recorder.py claw_forge/plugins/coding.py claw_forge/plugins/testing.py claw_forge/plugins/reviewer.py claw_forge/plugins/bugfix.py claw_forge/boundaries/refactor.py claw_forge/boundaries/classifier.py claw_forge/cli.py tests/training/test_recorder.py
git commit -m "feat(training): wire record_to through six run_agent call sites"
```

---

### Task A8: PATCH `merge_commit_sha` after squash merge

**Files:**
- Modify: `claw_forge/git/merge.py:303` (where `squash_merge` returns the success dict)
- Modify: `claw_forge/cli.py` task_handler (the post-merge PATCH call site)
- Modify: `claw_forge/state/service.py` PATCH `/tasks/{task_id}` (line ~879 — accept the new field)
- Test: extend `tests/training/test_recorder.py`

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

Append to `tests/training/test_recorder.py`:

```python
def test_patch_task_accepts_merge_commit_sha(state_client):
    sid = state_client.post("/sessions", json={"project_path": "/tmp"}).json()["id"]
    tid = state_client.post(
        f"/sessions/{sid}/tasks", json={"plugin_name": "coding", "description": "x"}
    ).json()["id"]
    resp = state_client.patch(
        f"/tasks/{tid}",
        json={"merge_commit_sha": "abc1234"},
    )
    assert resp.status_code == 200
    got = state_client.get(f"/tasks/{tid}").json()
    assert got["merge_commit_sha"] == "abc1234"
```

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

Run: `uv run pytest tests/training/test_recorder.py::test_patch_task_accepts_merge_commit_sha -v`
Expected: FAIL — `merge_commit_sha` is not in the PATCH allow-list, or the GET response doesn't include it.

- [ ] **Step 3: Extend the PATCH endpoint and GET serializer**

In `state/service.py` find the `PATCH /tasks/{task_id}` handler (around line 879). It applies fields from the body to the Task; the allowed-field list will need `"merge_commit_sha"` added. Add it.

Find the GET `/tasks/{task_id}` (line ~953) and the to-dict serializer used by both GET and the list endpoint (line ~993). Add `"merge_commit_sha": task.merge_commit_sha` to the dict.

In `claw_forge/git/merge.py`, find `squash_merge` (line 195). After the success branch (line ~303 `return {"merged": True, "commit_hash": short_hash}`), the *caller* — not this function — needs to PATCH the task. Find the call site in `claw_forge/cli.py` task_handler (search for `squash_merge(` usage). After a successful return, PATCH the task:

```python
if merge_result.get("merged") and merge_result.get("commit_hash"):
    try:
        await client.patch(
            f"{state_url}/tasks/{task_id}",
            json={"merge_commit_sha": merge_result["commit_hash"]},
        )
    except Exception:
        log.warning("failed to PATCH merge_commit_sha for task %s", task_id, exc_info=True)
```

If the cli.py squash-merge call site already has an httpx client in scope (likely yes — task_handler is full of them), reuse it.

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/training/test_recorder.py::test_patch_task_accepts_merge_commit_sha -v`
Expected: PASS.

Manual smoke (optional): run `uv run pytest tests/test_cli_merge_gating.py -q` to make sure existing merge gating still passes.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/state/service.py claw_forge/cli.py tests/training/test_recorder.py
git commit -m "feat(state): PATCH merge_commit_sha after squash; expose on GET"
```

---

# Phase B — Export

### Task B1: Exporter reader + filter (success / recovered / both)

**Files:**
- Create: `claw_forge/training/exporter.py`
- Test: new `tests/training/test_exporter.py`

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

Create `tests/training/test_exporter.py`:

```python
"""Tests for training-trace export."""
from __future__ import annotations

import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path

import pytest


def _seed_db(db: Path) -> None:
    """Build a state.db with three tasks (success, recovered, failed) and
    a few agent_message events for each."""
    conn = sqlite3.connect(db)
    conn.executescript(
        """
        CREATE TABLE sessions (id TEXT PRIMARY KEY, project_path TEXT, status TEXT,
                               created_at TEXT, manifest_json TEXT);
        CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT, plugin_name TEXT,
                            description TEXT, status TEXT, priority INTEGER,
                            depends_on TEXT, category TEXT, steps TEXT,
                            result_json TEXT, error_message TEXT,
                            human_question TEXT, human_answer TEXT,
                            input_tokens INTEGER, output_tokens INTEGER,
                            cost_usd REAL, active_subagents INTEGER,
                            parent_task_id TEXT, bugfix_retry_count INTEGER,
                            shape TEXT, plugin TEXT, touches_files TEXT,
                            merged_to_target_branch INTEGER,
                            merge_commit_sha TEXT,
                            created_at TEXT, started_at TEXT, completed_at TEXT);
        CREATE TABLE events (id INTEGER PRIMARY KEY AUTOINCREMENT,
                             session_id TEXT, task_id TEXT, event_type TEXT,
                             payload TEXT, created_at TEXT);
        """
    )
    now = datetime.now(UTC).isoformat()
    conn.execute(
        "INSERT INTO sessions VALUES (?,?,?,?,?)",
        ("s", "/tmp/p", "active", now, "{}"),
    )
    # success: completed + merged + retry=0
    conn.execute(
        "INSERT INTO tasks VALUES (?,?,?,?,?,0,'[]','auth','[]',NULL,NULL,NULL,NULL,0,0,0.0,0,NULL,0,NULL,NULL,'[]',1,'abc1234',?,?,?)",
        ("t-success", "s", "coding", "ok", "completed", now, now, now),
    )
    # recovered: completed + merged + retry=1
    conn.execute(
        "INSERT INTO tasks VALUES (?,?,?,?,?,0,'[]','auth','[]',NULL,NULL,NULL,NULL,0,0,0.0,0,NULL,1,NULL,NULL,'[]',1,'def5678',?,?,?)",
        ("t-recovered", "s", "coding", "took two tries", "completed", now, now, now),
    )
    # failed: status=failed
    conn.execute(
        "INSERT INTO tasks VALUES (?,?,?,?,?,0,'[]','auth','[]',NULL,'boom',NULL,NULL,0,0,0.0,0,NULL,0,NULL,NULL,'[]',0,NULL,?,?,?)",
        ("t-failed", "s", "coding", "did not work", "failed", now, now, now),
    )
    # one agent_message event per task
    for tid, text in [("t-success", "s-msg"), ("t-recovered", "r-msg"), ("t-failed", "f-msg")]:
        conn.execute(
            "INSERT INTO events (session_id, task_id, event_type, payload, created_at) "
            "VALUES (?,?,?,?,?)",
            ("s", tid, "agent_message",
             json.dumps({"turn": 0, "kind": "AssistantMessage",
                         "content": [{"type": "text", "text": text}]}),
             now),
        )
    conn.commit()
    conn.close()


@pytest.fixture
def seeded_db(tmp_path):
    db = tmp_path / "state.db"
    _seed_db(db)
    return db


def test_filter_success_only(seeded_db):
    from claw_forge.training.exporter import iter_training_records
    records = list(iter_training_records(seeded_db, scope="all", filter_="success"))
    ids = {r["task_id"] for r in records}
    assert ids == {"t-success"}


def test_filter_recovered_only(seeded_db):
    from claw_forge.training.exporter import iter_training_records
    records = list(iter_training_records(seeded_db, scope="all", filter_="recovered"))
    assert {r["task_id"] for r in records} == {"t-recovered"}


def test_filter_both(seeded_db):
    from claw_forge.training.exporter import iter_training_records
    records = list(iter_training_records(seeded_db, scope="all", filter_="both"))
    assert {r["task_id"] for r in records} == {"t-success", "t-recovered"}


def test_include_failed(seeded_db):
    from claw_forge.training.exporter import iter_training_records
    records = list(iter_training_records(
        seeded_db, scope="all", filter_="both", include_failed=True,
    ))
    assert {r["task_id"] for r in records} == {"t-success", "t-recovered", "t-failed"}
    fail = next(r for r in records if r["task_id"] == "t-failed")
    assert fail["outcome"] == "failed"


def test_outcome_labels(seeded_db):
    from claw_forge.training.exporter import iter_training_records
    by_id = {r["task_id"]: r for r in iter_training_records(
        seeded_db, scope="all", filter_="both", include_failed=True,
    )}
    assert by_id["t-success"]["outcome"] == "success"
    assert by_id["t-recovered"]["outcome"] == "recovered"
    assert by_id["t-failed"]["outcome"] == "failed"
```

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

Run: `uv run pytest tests/training/test_exporter.py -v`
Expected: FAIL — `iter_training_records` doesn't exist.

- [ ] **Step 3: Implement the reader + filter**

Create `claw_forge/training/exporter.py`:

```python
"""Read agent_message events from state.db and emit per-task training records.

Reads the DB directly via sqlite3 — no state-service dependency, safe to
run while a session is active.

Pipeline: reader → filter → projector → redactor → splitter → JSONL.
This file owns reader + filter + record assembly. Projection lives in
projectors.py; redaction in redact.py.
"""
from __future__ import annotations

import json
import sqlite3
from collections.abc import Iterator
from pathlib import Path
from typing import Any, Literal

Filter = Literal["success", "recovered", "both"]


def iter_training_records(
    db: Path,
    *,
    scope: Literal["session", "all"] = "all",
    session: str | None = None,
    filter_: Filter = "both",
    include_failed: bool = False,
) -> Iterator[dict[str, Any]]:
    """Yield one assembled record per task that passes the filter."""
    conn = sqlite3.connect(str(db))
    conn.row_factory = sqlite3.Row
    try:
        for task in _iter_tasks(conn, scope=scope, session=session,
                                filter_=filter_, include_failed=include_failed):
            events = list(_iter_events_for(conn, task["id"]))
            yield _assemble_record(task, events)
    finally:
        conn.close()


def _iter_tasks(
    conn: sqlite3.Connection,
    *,
    scope: str,
    session: str | None,
    filter_: Filter,
    include_failed: bool,
) -> Iterator[sqlite3.Row]:
    where: list[str] = []
    args: list[Any] = []

    if scope == "session" and session:
        where.append("session_id = ?")
        args.append(session)

    outcome_clauses: list[str] = []
    if filter_ in ("success", "both"):
        outcome_clauses.append(
            "(status='completed' AND merged_to_target_branch=1 AND bugfix_retry_count=0)"
        )
    if filter_ in ("recovered", "both"):
        outcome_clauses.append(
            "(status='completed' AND merged_to_target_branch=1 AND bugfix_retry_count>0)"
        )
    if include_failed:
        outcome_clauses.append("status='failed'")
    if outcome_clauses:
        where.append("(" + " OR ".join(outcome_clauses) + ")")

    where_sql = " AND ".join(where) if where else "1=1"
    sql = f"SELECT * FROM tasks WHERE {where_sql} ORDER BY created_at"
    yield from conn.execute(sql, args)


def _iter_events_for(conn: sqlite3.Connection, task_id: str) -> Iterator[dict[str, Any]]:
    sql = (
        "SELECT payload FROM events "
        "WHERE task_id = ? AND event_type = 'agent_message' "
        "ORDER BY created_at, id"
    )
    for row in conn.execute(sql, (task_id,)):
        try:
            yield json.loads(row[0])
        except json.JSONDecodeError:
            continue   # skip malformed payloads


def _label(task: sqlite3.Row) -> str:
    if task["status"] == "failed":
        return "failed"
    if int(task["bugfix_retry_count"] or 0) > 0:
        return "recovered"
    return "success"


def _assemble_record(task: sqlite3.Row, events: list[dict[str, Any]]) -> dict[str, Any]:
    """Build the per-task JSONL record (without projection — that runs later)."""
    return {
        "task_id": task["id"],
        "session_id": task["session_id"],
        "outcome": _label(task),
        "plugin": task["plugin_name"],
        "category": task["category"],
        "shape": task["shape"],
        "retry_count": int(task["bugfix_retry_count"] or 0),
        "metrics": {
            "input_tokens": int(task["input_tokens"] or 0),
            "output_tokens": int(task["output_tokens"] or 0),
            "cost_usd": float(task["cost_usd"] or 0.0),
        },
        "description": task["description"],
        "merge_commit_sha": task["merge_commit_sha"],
        "raw_events": events,    # projector consumes these
    }
```

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/training/test_exporter.py -v`
Expected: PASS for all five tests.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/exporter.py tests/training/test_exporter.py
git commit -m "feat(training): exporter reader + filter (success/recovered/both/failed)"
```

---

### Task B2: OpenAI tool-use projector (default format)

**Files:**
- Create: `claw_forge/training/projectors.py`
- Test: extend `tests/training/test_exporter.py`

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

Append to `tests/training/test_exporter.py`:

```python
def test_openai_tools_projector_basic():
    from claw_forge.training.projectors import to_openai_tools
    raw = {
        "task_id": "t1", "session_id": "s",
        "outcome": "success", "plugin": "coding",
        "category": "auth", "shape": None, "retry_count": 0,
        "metrics": {"input_tokens": 10, "output_tokens": 5, "cost_usd": 0.01},
        "description": "Implement JWT validation",
        "merge_commit_sha": "abc1234",
        "raw_events": [
            {"turn": 0, "kind": "AssistantMessage", "model": "claude-sonnet-4-5",
             "content": [
                 {"type": "thinking", "thinking": "Let me think..."},
                 {"type": "text", "text": "I'll read the auth module."},
                 {"type": "tool_use", "id": "tu_1", "name": "Read",
                  "input": {"file_path": "src/auth.py"}},
             ]},
            {"turn": 1, "kind": "UserMessage",
             "content": [
                 {"type": "tool_result", "tool_use_id": "tu_1",
                  "content": "<contents of auth.py>"},
             ]},
            {"turn": 2, "kind": "AssistantMessage",
             "content": [{"type": "text", "text": "Done."}]},
            {"turn": 3, "kind": "ResultMessage", "result": "Done."},
        ],
    }
    out = to_openai_tools(raw, include_thinking=True)
    msgs = out["messages"]
    # system + user + asst1 (thinking + text + tool_call) + tool + asst2
    assert msgs[0]["role"] == "system"
    assert msgs[1]["role"] == "user"
    assert msgs[1]["content"] == "Implement JWT validation"
    asst1 = msgs[2]
    assert asst1["role"] == "assistant"
    assert asst1["content"] == "I'll read the auth module."
    assert asst1["thinking"] == "Let me think..."
    assert asst1["tool_calls"][0]["function"]["name"] == "Read"
    assert json.loads(asst1["tool_calls"][0]["function"]["arguments"]) == {"file_path": "src/auth.py"}
    tool = msgs[3]
    assert tool["role"] == "tool"
    assert tool["tool_call_id"] == "tu_1"
    asst2 = msgs[4]
    assert asst2["role"] == "assistant"
    assert asst2["content"] == "Done."


def test_openai_tools_drops_thinking_when_disabled():
    from claw_forge.training.projectors import to_openai_tools
    raw = {
        "task_id": "t1", "session_id": "s",
        "outcome": "success", "plugin": "coding",
        "category": None, "shape": None, "retry_count": 0,
        "metrics": {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0},
        "description": "x",
        "merge_commit_sha": None,
        "raw_events": [
            {"turn": 0, "kind": "AssistantMessage",
             "content": [{"type": "thinking", "thinking": "x"},
                         {"type": "text", "text": "y"}]},
        ],
    }
    out = to_openai_tools(raw, include_thinking=False)
    asst = next(m for m in out["messages"] if m["role"] == "assistant")
    assert "thinking" not in asst
    assert asst["content"] == "y"


def test_openai_tools_record_top_level_fields():
    from claw_forge.training.projectors import to_openai_tools
    raw = {
        "task_id": "t1", "session_id": "s",
        "outcome": "recovered", "plugin": "coding",
        "category": "auth", "shape": "plugin", "retry_count": 1,
        "metrics": {"input_tokens": 100, "output_tokens": 50, "cost_usd": 0.02},
        "description": "x",
        "merge_commit_sha": "abc",
        "raw_events": [],
    }
    out = to_openai_tools(raw, include_thinking=True)
    assert out["task_id"] == "t1"
    assert out["outcome"] == "recovered"
    assert out["retry_count"] == 1
    assert out["merge_commit"] == "abc"
    assert out["metrics"]["input_tokens"] == 100
```

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

Run: `uv run pytest tests/training/test_exporter.py -k openai_tools -v`
Expected: FAIL — `claw_forge.training.projectors` doesn't exist.

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

Create `claw_forge/training/projectors.py`:

```python
"""Per-format projectors: turn an assembled record into a JSONL-ready dict.

Each projector takes the dict that exporter.iter_training_records yields
and returns the OpenAI / ShareGPT / ChatML / raw shape.
"""
from __future__ import annotations

import json
from typing import Any


def to_openai_tools(rec: dict[str, Any], *, include_thinking: bool = True) -> dict[str, Any]:
    """OpenAI tool-use chat format. Default and most expressive."""
    messages: list[dict[str, Any]] = []
    messages.append({"role": "system", "content": _system_for(rec)})
    messages.append({"role": "user", "content": rec.get("description", "")})

    for ev in rec.get("raw_events", []):
        kind = ev.get("kind")
        if kind == "AssistantMessage":
            asst = _assistant_from_blocks(ev.get("content", []), include_thinking=include_thinking)
            if asst:
                messages.append(asst)
        elif kind == "UserMessage":
            messages.extend(_tool_results_from_blocks(ev.get("content", [])))
        # SystemMessage / ResultMessage skipped — SDK plumbing

    out = {
        "task_id": rec["task_id"],
        "session_id": rec["session_id"],
        "outcome": rec["outcome"],
        "plugin": rec["plugin"],
        "category": rec.get("category"),
        "shape": rec.get("shape"),
        "retry_count": rec.get("retry_count", 0),
        "metrics": rec.get("metrics", {}),
        "messages": messages,
    }
    if rec.get("merge_commit_sha"):
        out["merge_commit"] = rec["merge_commit_sha"]
    return out


def _system_for(rec: dict[str, Any]) -> str:
    # Reconstructed at projection time from task metadata.  The actual
    # system prompt sent to the agent isn't stored on the Event; we
    # reconstruct a faithful approximation from plugin / category / shape.
    plugin = rec.get("plugin", "coding")
    cat = rec.get("category") or ""
    shape = rec.get("shape") or ""
    bits = [f"You are a claw-forge {plugin} agent."]
    if cat:
        bits.append(f"Category: {cat}.")
    if shape:
        bits.append(f"Shape: {shape}.")
    return " ".join(bits)


def _assistant_from_blocks(
    blocks: list[dict[str, Any]],
    *,
    include_thinking: bool,
) -> dict[str, Any] | None:
    """Fold thinking + text + tool_use blocks into one OpenAI assistant msg."""
    text_parts: list[str] = []
    thinking_parts: list[str] = []
    tool_calls: list[dict[str, Any]] = []
    for b in blocks:
        btype = b.get("type")
        if btype == "text":
            text_parts.append(b.get("text", ""))
        elif btype == "thinking":
            thinking_parts.append(b.get("thinking", ""))
        elif btype == "tool_use":
            tool_calls.append({
                "id": b.get("id", ""),
                "type": "function",
                "function": {
                    "name": b.get("name", ""),
                    "arguments": json.dumps(b.get("input") or {}),
                },
            })
    if not text_parts and not tool_calls:
        return None
    msg: dict[str, Any] = {"role": "assistant", "content": "".join(text_parts)}
    if include_thinking and thinking_parts:
        msg["thinking"] = "".join(thinking_parts)
    if tool_calls:
        msg["tool_calls"] = tool_calls
    return msg


def _tool_results_from_blocks(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Each tool_result block becomes its own OpenAI tool-role message."""
    out: list[dict[str, Any]] = []
    for b in blocks:
        if b.get("type") != "tool_result":
            continue
        content = b.get("content")
        if isinstance(content, list):
            content = "".join(
                c.get("text", "") if isinstance(c, dict) else str(c) for c in content
            )
        elif not isinstance(content, str):
            content = str(content) if content is not None else ""
        out.append({
            "role": "tool",
            "tool_call_id": b.get("tool_use_id", ""),
            "content": content,
        })
    return out
```

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

Run: `uv run pytest tests/training/test_exporter.py -v`
Expected: PASS for all tests in the file.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/projectors.py tests/training/test_exporter.py
git commit -m "feat(training): OpenAI tool-use projector (default format)"
```

---

### Task B3: ShareGPT, ChatML, raw-jsonl projectors

**Files:**
- Modify: `claw_forge/training/projectors.py`
- Test: extend `tests/training/test_exporter.py`

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

Append to `tests/training/test_exporter.py`:

```python
def test_sharegpt_projector_flattens_tools():
    from claw_forge.training.projectors import to_sharegpt
    raw = {
        "task_id": "t1", "session_id": "s",
        "outcome": "success", "plugin": "coding", "category": None, "shape": None,
        "retry_count": 0, "metrics": {}, "description": "do it", "merge_commit_sha": None,
        "raw_events": [
            {"turn": 0, "kind": "AssistantMessage",
             "content": [
                 {"type": "text", "text": "calling Read"},
                 {"type": "tool_use", "id": "tu1", "name": "Read",
                  "input": {"file_path": "x.py"}},
             ]},
            {"turn": 1, "kind": "UserMessage",
             "content": [{"type": "tool_result", "tool_use_id": "tu1", "content": "FILE"}]},
        ],
    }
    out = to_sharegpt(raw, include_thinking=False)
    convos = out["conversations"]
    assert convos[0]["from"] == "system"
    assert convos[1]["from"] == "human"
    asst = next(c for c in convos if c["from"] == "gpt")
    # Tool call flattened into the assistant text
    assert "Read" in asst["value"]
    assert "x.py" in asst["value"]
    assert any(c["from"] == "tool" and c["value"] == "FILE" for c in convos)


def test_chatml_projector_emits_im_tags():
    from claw_forge.training.projectors import to_chatml
    raw = {
        "task_id": "t1", "session_id": "s",
        "outcome": "success", "plugin": "coding", "category": None, "shape": None,
        "retry_count": 0, "metrics": {}, "description": "do it", "merge_commit_sha": None,
        "raw_events": [
            {"turn": 0, "kind": "AssistantMessage",
             "content": [{"type": "text", "text": "ok"}]},
        ],
    }
    out = to_chatml(raw, include_thinking=False)
    text = out["text"]
    assert "<|im_start|>system" in text
    assert "<|im_start|>user\ndo it<|im_end|>" in text
    assert "<|im_start|>assistant\nok<|im_end|>" in text


def test_raw_jsonl_dumps_events_unchanged():
    from claw_forge.training.projectors import to_raw_jsonl
    raw = {
        "task_id": "t1", "session_id": "s",
        "outcome": "success", "plugin": "coding", "category": None, "shape": None,
        "retry_count": 0, "metrics": {}, "description": "do it", "merge_commit_sha": None,
        "raw_events": [
            {"turn": 0, "kind": "AssistantMessage",
             "content": [{"type": "text", "text": "ok"}]},
        ],
    }
    out = to_raw_jsonl(raw, include_thinking=True)
    assert out["task_id"] == "t1"
    assert out["events"] == raw["raw_events"]
```

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

Run: `uv run pytest tests/training/test_exporter.py -k "sharegpt or chatml or raw_jsonl" -v`
Expected: FAIL — projectors don't exist.

- [ ] **Step 3: Implement the three projectors**

Append to `claw_forge/training/projectors.py`:

```python
def to_sharegpt(rec: dict[str, Any], *, include_thinking: bool) -> dict[str, Any]:
    """ShareGPT format. Tool calls flatten to text — lossy but maximally compatible."""
    convos: list[dict[str, str]] = []
    convos.append({"from": "system", "value": _system_for(rec)})
    convos.append({"from": "human", "value": rec.get("description", "")})
    for ev in rec.get("raw_events", []):
        kind = ev.get("kind")
        if kind == "AssistantMessage":
            text = _flatten_assistant(ev.get("content", []), include_thinking=include_thinking)
            if text:
                convos.append({"from": "gpt", "value": text})
        elif kind == "UserMessage":
            for b in ev.get("content", []):
                if b.get("type") == "tool_result":
                    content = b.get("content")
                    if isinstance(content, list):
                        content = "".join(c.get("text", "") if isinstance(c, dict) else str(c) for c in content)
                    convos.append({"from": "tool", "value": str(content or "")})
    return {
        "task_id": rec["task_id"], "outcome": rec["outcome"],
        "conversations": convos,
        **({"merge_commit": rec["merge_commit_sha"]} if rec.get("merge_commit_sha") else {}),
    }


def _flatten_assistant(blocks: list[dict[str, Any]], *, include_thinking: bool) -> str:
    parts: list[str] = []
    for b in blocks:
        btype = b.get("type")
        if btype == "text":
            parts.append(b.get("text", ""))
        elif btype == "thinking" and include_thinking:
            parts.append(f"<thinking>{b.get('thinking', '')}</thinking>")
        elif btype == "tool_use":
            args = json.dumps(b.get("input") or {})
            parts.append(f"<tool_call name=\"{b.get('name', '')}\">{args}</tool_call>")
    return "".join(parts)


def to_chatml(rec: dict[str, Any], *, include_thinking: bool) -> dict[str, Any]:
    """Generic ChatML format. Single 'text' field; no tool-use semantics."""
    parts: list[str] = []
    parts.append(f"<|im_start|>system\n{_system_for(rec)}<|im_end|>")
    parts.append(f"<|im_start|>user\n{rec.get('description', '')}<|im_end|>")
    for ev in rec.get("raw_events", []):
        if ev.get("kind") != "AssistantMessage":
            continue
        text = _flatten_assistant(ev.get("content", []), include_thinking=include_thinking)
        if text:
            parts.append(f"<|im_start|>assistant\n{text}<|im_end|>")
    return {
        "task_id": rec["task_id"],
        "outcome": rec["outcome"],
        "text": "\n".join(parts),
        **({"merge_commit": rec["merge_commit_sha"]} if rec.get("merge_commit_sha") else {}),
    }


def to_raw_jsonl(rec: dict[str, Any], *, include_thinking: bool) -> dict[str, Any]:
    """Direct dump: no projection, full SDK message stream preserved."""
    return {
        "task_id": rec["task_id"],
        "session_id": rec["session_id"],
        "outcome": rec["outcome"],
        "plugin": rec["plugin"],
        "category": rec.get("category"),
        "shape": rec.get("shape"),
        "retry_count": rec.get("retry_count", 0),
        "metrics": rec.get("metrics", {}),
        "description": rec.get("description", ""),
        "events": rec.get("raw_events", []),
        **({"merge_commit": rec["merge_commit_sha"]} if rec.get("merge_commit_sha") else {}),
    }
```

`include_thinking` is a parameter on every projector for API consistency, even when (as in `to_raw_jsonl`) it's a no-op — the events already include thinking blocks.

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

Run: `uv run pytest tests/training/test_exporter.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/projectors.py tests/training/test_exporter.py
git commit -m "feat(training): ShareGPT + ChatML + raw-jsonl projectors"
```

---

### Task B4: Final-diff attachment + provenance stamping

**Files:**
- Modify: `claw_forge/training/exporter.py`
- Test: extend `tests/training/test_exporter.py`

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

Append to `tests/training/test_exporter.py`:

```python
def test_include_diff_attaches_git_show_output(seeded_db, monkeypatch):
    from claw_forge.training import exporter

    captured: dict = {}
    def fake_git_show(repo, sha):
        captured["sha"] = sha
        return f"diff for {sha}"
    monkeypatch.setattr(exporter, "_git_show", fake_git_show)

    records = list(exporter.iter_training_records(
        seeded_db, scope="all", filter_="success",
        include_diff=True, repo_dir=Path("/tmp/repo"),
    ))
    rec = records[0]
    assert rec["final_diff"] == "diff for abc1234"
    assert captured["sha"] == "abc1234"


def test_provenance_stamped(seeded_db):
    from claw_forge.training.exporter import iter_training_records
    rec = next(iter_training_records(seeded_db, scope="all", filter_="success"))
    assert "provenance" in rec
    assert "claw_forge_version" in rec["provenance"]
    assert "captured_at" in rec["provenance"]


def test_include_diff_off_by_default_skips_git_show(seeded_db, monkeypatch):
    from claw_forge.training import exporter

    def boom(*a, **k):
        raise AssertionError("git show should not be called when include_diff=False")
    monkeypatch.setattr(exporter, "_git_show", boom)
    list(exporter.iter_training_records(seeded_db, scope="all", filter_="success"))
```

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

Run: `uv run pytest tests/training/test_exporter.py -k "diff or provenance" -v`
Expected: FAIL — params and `_git_show` don't exist.

- [ ] **Step 3: Add diff lookup + provenance**

Modify `claw_forge/training/exporter.py`. Add at the top:

```python
import subprocess
from datetime import UTC, datetime

try:
    from importlib.metadata import version as _pkg_version
    _CLAW_FORGE_VERSION = _pkg_version("claw-forge")
except Exception:
    _CLAW_FORGE_VERSION = "0.0.0.dev0"
```

Replace `iter_training_records` signature with:

```python
def iter_training_records(
    db: Path,
    *,
    scope: Literal["session", "all"] = "all",
    session: str | None = None,
    filter_: Filter = "both",
    include_failed: bool = False,
    include_diff: bool = False,
    repo_dir: Path | None = None,
    redaction_rules: list[str] | None = None,
) -> Iterator[dict[str, Any]]:
    """Yield one assembled record per task that passes the filter."""
    conn = sqlite3.connect(str(db))
    conn.row_factory = sqlite3.Row
    try:
        for task in _iter_tasks(conn, scope=scope, session=session,
                                filter_=filter_, include_failed=include_failed):
            events = list(_iter_events_for(conn, task["id"]))
            rec = _assemble_record(task, events)
            if include_diff and task["merge_commit_sha"] and repo_dir:
                rec["final_diff"] = _git_show(repo_dir, task["merge_commit_sha"])
            rec["provenance"] = {
                "claude_model": _detect_model_from_events(events),
                "captured_at": datetime.now(UTC).isoformat(),
                "claw_forge_version": _CLAW_FORGE_VERSION,
                "redaction_rules": list(redaction_rules or []),
            }
            yield rec
    finally:
        conn.close()


def _git_show(repo: Path, sha: str) -> str:
    """Return the diff for one commit."""
    try:
        out = subprocess.run(
            ["git", "show", "--pretty=format:", sha],
            cwd=str(repo), capture_output=True, text=True, timeout=10,
        )
        return out.stdout
    except Exception:
        return ""


def _detect_model_from_events(events: list[dict[str, Any]]) -> str | None:
    for ev in events:
        if ev.get("model"):
            return ev["model"]
    return None
```

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

Run: `uv run pytest tests/training/test_exporter.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/exporter.py tests/training/test_exporter.py
git commit -m "feat(training): include_diff via git show + provenance stamping"
```

---

### Task B5: Dedupe + train/val/test split

**Files:**
- Modify: `claw_forge/training/exporter.py` (add `dedupe_records` + `split_records`)
- Test: extend `tests/training/test_exporter.py`

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

Append to `tests/training/test_exporter.py`:

```python
def test_dedupe_by_prompt():
    from claw_forge.training.exporter import dedupe_records
    recs = [
        {"task_id": "a", "description": "X", "merge_commit_sha": "a1"},
        {"task_id": "b", "description": "X", "merge_commit_sha": "a2"},
        {"task_id": "c", "description": "Y", "merge_commit_sha": "a3"},
    ]
    out = list(dedupe_records(recs, by="prompt"))
    assert [r["task_id"] for r in out] == ["a", "c"]


def test_dedupe_by_diff():
    from claw_forge.training.exporter import dedupe_records
    recs = [
        {"task_id": "a", "description": "X", "merge_commit_sha": "h1"},
        {"task_id": "b", "description": "Y", "merge_commit_sha": "h1"},
        {"task_id": "c", "description": "Z", "merge_commit_sha": None},
    ]
    out = list(dedupe_records(recs, by="diff"))
    # sha None records are kept (cannot collide)
    assert [r["task_id"] for r in out] == ["a", "c"]


def test_dedupe_by_both_drops_only_when_both_match():
    from claw_forge.training.exporter import dedupe_records
    recs = [
        {"task_id": "a", "description": "X", "merge_commit_sha": "h1"},
        {"task_id": "b", "description": "X", "merge_commit_sha": "h2"},  # same prompt, different diff
        {"task_id": "c", "description": "X", "merge_commit_sha": "h1"},  # full duplicate of a
    ]
    out = list(dedupe_records(recs, by="both"))
    assert [r["task_id"] for r in out] == ["a", "b"]


def test_dedupe_none_passes_through():
    from claw_forge.training.exporter import dedupe_records
    recs = [{"task_id": "a", "description": "X", "merge_commit_sha": "h"}] * 3
    out = list(dedupe_records(recs, by="none"))
    assert len(out) == 3


def test_split_deterministic_by_task_id():
    from claw_forge.training.exporter import split_records
    recs = [{"task_id": f"t{i}", "description": "x", "merge_commit_sha": None} for i in range(100)]
    train, val, test = split_records(recs, ratios=(0.8, 0.1, 0.1))
    assert len(train) + len(val) + len(test) == 100
    # Determinism: same input → same partition
    train2, val2, test2 = split_records(recs, ratios=(0.8, 0.1, 0.1))
    assert {r["task_id"] for r in train} == {r["task_id"] for r in train2}
    assert {r["task_id"] for r in val} == {r["task_id"] for r in val2}
    # Roughly 80/10/10
    assert 70 <= len(train) <= 90
    assert 5 <= len(val) <= 15
    assert 5 <= len(test) <= 15


def test_split_ratios_must_sum_to_one():
    from claw_forge.training.exporter import split_records
    with pytest.raises(ValueError):
        split_records([], ratios=(0.5, 0.4, 0.2))
```

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

Run: `uv run pytest tests/training/test_exporter.py -k "dedupe or split" -v`
Expected: FAIL — functions don't exist.

- [ ] **Step 3: Implement dedupe + split**

Append to `claw_forge/training/exporter.py`:

```python
import hashlib
import math
from collections.abc import Iterable

DedupeMode = Literal["prompt", "diff", "both", "none"]


def dedupe_records(
    records: Iterable[dict[str, Any]],
    *,
    by: DedupeMode,
) -> Iterator[dict[str, Any]]:
    """Drop trajectories that already appear in the corpus under the chosen key.

    - prompt: dedupe by description
    - diff: dedupe by merge_commit_sha (None never collides)
    - both: dedupe only when (description, merge_commit_sha) matches exactly
    - none: pass-through
    """
    if by == "none":
        yield from records
        return
    seen: set[Any] = set()
    for r in records:
        if by == "prompt":
            key: Any = ("p", r.get("description") or "")
        elif by == "diff":
            sha = r.get("merge_commit_sha")
            if sha is None:
                yield r
                continue
            key = ("d", sha)
        else:  # both
            key = ("b", r.get("description") or "", r.get("merge_commit_sha"))
        if key in seen:
            continue
        seen.add(key)
        yield r


def split_records(
    records: Iterable[dict[str, Any]],
    *,
    ratios: tuple[float, float, float],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
    """Deterministic split (seeded by task_id hash) into train/val/test.
    Ratios must sum to 1.0 (±1e-6)."""
    if not math.isclose(sum(ratios), 1.0, abs_tol=1e-6):
        raise ValueError(f"ratios must sum to 1.0, got {ratios} (sum={sum(ratios)})")
    train_r, val_r, _ = ratios
    train: list[dict[str, Any]] = []
    val: list[dict[str, Any]] = []
    test: list[dict[str, Any]] = []
    for r in records:
        h = int(hashlib.sha256(r["task_id"].encode()).hexdigest()[:8], 16) / (16**8)
        if h < train_r:
            train.append(r)
        elif h < train_r + val_r:
            val.append(r)
        else:
            test.append(r)
    return train, val, test
```

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

Run: `uv run pytest tests/training/test_exporter.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/exporter.py tests/training/test_exporter.py
git commit -m "feat(training): dedupe + deterministic train/val/test split"
```

---

### Task B6: Convert top-level `export` to a Typer subapp; add `training` subcommand

**Files:**
- Modify: `claw_forge/cli.py` (the existing `@app.command() def export(...)` at line ~2965)
- Test: new `tests/training/test_export_cli.py`

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

Create `tests/training/test_export_cli.py`:

```python
"""Tests for `claw-forge export training` CLI."""
from __future__ import annotations

import json
from pathlib import Path

import pytest
from typer.testing import CliRunner

from claw_forge.cli import app


def test_existing_export_csv_still_works(tmp_path):
    """Backward-compat: `claw-forge export` (no subcommand) still writes CSV."""
    # Seed a minimal state.db
    db = tmp_path / ".claw-forge" / "state.db"
    db.parent.mkdir(parents=True)
    import sqlite3
    conn = sqlite3.connect(db)
    conn.executescript("""
        CREATE TABLE sessions (id TEXT PRIMARY KEY, project_path TEXT, status TEXT,
                               created_at TEXT, manifest_json TEXT);
        CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT, plugin_name TEXT,
                            description TEXT, status TEXT, priority INTEGER,
                            depends_on TEXT, category TEXT, steps TEXT,
                            result_json TEXT, error_message TEXT,
                            input_tokens INTEGER, output_tokens INTEGER,
                            cost_usd REAL, merged_to_target_branch INTEGER,
                            merge_commit_sha TEXT, bugfix_retry_count INTEGER,
                            shape TEXT, plugin TEXT, touches_files TEXT,
                            created_at TEXT, started_at TEXT, completed_at TEXT,
                            human_question TEXT, human_answer TEXT,
                            active_subagents INTEGER, parent_task_id TEXT);
        CREATE TABLE events (id INTEGER PRIMARY KEY AUTOINCREMENT,
                             session_id TEXT, task_id TEXT, event_type TEXT,
                             payload TEXT, created_at TEXT);
        INSERT INTO sessions VALUES ('s', ?, 'active', '2026-01-01', '{}');
    """, (str(tmp_path),))
    conn.commit()
    conn.close()

    out = tmp_path / "out.csv"
    runner = CliRunner()
    res = runner.invoke(app, [
        "export", "--scope", "all", "--out", str(out), "--project", str(tmp_path),
    ])
    assert res.exit_code == 0, res.output
    assert out.exists()


def test_export_training_emits_jsonl(tmp_path):
    """`claw-forge export training` writes one JSONL line per record."""
    db = tmp_path / ".claw-forge" / "state.db"
    db.parent.mkdir(parents=True)
    # Reuse the seeder helper from the exporter test
    from tests.training.test_exporter import _seed_db
    _seed_db(db)

    out = tmp_path / "train.jsonl"
    runner = CliRunner()
    res = runner.invoke(app, [
        "export", "training",
        "--scope", "all", "--filter", "both",
        "--format", "openai-tools",
        "--out", str(out),
        "--project", str(tmp_path),
    ])
    assert res.exit_code == 0, res.output
    lines = out.read_text().splitlines()
    assert len(lines) == 2  # success + recovered (no failed by default)
    parsed = [json.loads(line) for line in lines]
    assert {p["outcome"] for p in parsed} == {"success", "recovered"}
    assert all("messages" in p for p in parsed)


def test_export_training_split_writes_three_files(tmp_path):
    db = tmp_path / ".claw-forge" / "state.db"
    db.parent.mkdir(parents=True)
    from tests.training.test_exporter import _seed_db
    _seed_db(db)

    out_dir = tmp_path / "split"
    runner = CliRunner()
    res = runner.invoke(app, [
        "export", "training",
        "--scope", "all",
        "--split", "80/10/10",
        "--out", str(out_dir),
        "--project", str(tmp_path),
    ])
    assert res.exit_code == 0, res.output
    assert (out_dir / "train.jsonl").exists()
    assert (out_dir / "val.jsonl").exists()
    assert (out_dir / "test.jsonl").exists()
```

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

Run: `uv run pytest tests/training/test_export_cli.py -v`
Expected: FAIL — `claw-forge export training` is not a registered subcommand.

- [ ] **Step 3: Convert `export` to a sub-Typer + add `training`**

In `claw_forge/cli.py`, find `@app.command() def export(...)` at line ~2965. Replace it with a Typer subapp pattern.

Above the existing `def export`, add:

```python
export_app = typer.Typer(
    invoke_without_command=True,
    no_args_is_help=False,
    help=(
        "Export session/task data. Without a subcommand, exports CSV/SQL/JSON. "
        "Use 'claw-forge export training' for fine-tuning JSONL."
    ),
)
app.add_typer(export_app, name="export")
```

Change `@app.command() def export(...)` to `@export_app.callback() def _export_default(...)` so the existing flag-based behaviour runs as the default. Make sure `invoke_without_command=True` is set on the subapp (already added above) and add this guard at the top of the callback so it short-circuits when a subcommand is invoked:

```python
def _export_default(
    ctx: typer.Context,
    format_: str = typer.Option("csv", "--format", "-f", ...),
    # ... existing params unchanged
) -> None:
    if ctx.invoked_subcommand is not None:
        return
    # ... existing body unchanged
```

Then add the `training` subcommand at the bottom (after the existing body):

```python
@export_app.command("training")
def export_training(
    scope: str = typer.Option("all", "--scope"),
    session: str = typer.Option("", "--session"),
    filter_: str = typer.Option("both", "--filter"),
    include_failed: bool = typer.Option(False, "--include-failed"),
    format_: str = typer.Option("openai-tools", "--format"),
    include_diff: bool = typer.Option(True, "--include-diff/--no-include-diff"),
    include_thinking: bool = typer.Option(True, "--include-thinking/--no-include-thinking"),
    min_turns: int = typer.Option(4, "--min-turns"),
    max_turns: int = typer.Option(200, "--max-turns"),
    redact: str = typer.Option("both", "--redact"),
    dedupe_by: str = typer.Option("both", "--dedupe-by"),
    split: str = typer.Option("", "--split", help="e.g. 80/10/10"),
    out: str = typer.Option("", "--out", "-o"),
    project: str = typer.Option(".", "--project", "-p"),
) -> None:
    """Export agent trajectories as JSONL for fine-tuning."""
    from claw_forge.training import exporter as exp
    from claw_forge.training import projectors as proj

    if filter_ not in ("success", "recovered", "both"):
        console.print(f"[red]✖  Unknown --filter {filter_!r}[/red]")
        raise typer.Exit(1)
    if format_ not in ("openai-tools", "sharegpt", "chatml", "raw-jsonl"):
        console.print(f"[red]✖  Unknown --format {format_!r}[/red]")
        raise typer.Exit(1)
    if dedupe_by not in ("prompt", "diff", "both", "none"):
        console.print(f"[red]✖  Unknown --dedupe-by {dedupe_by!r}[/red]")
        raise typer.Exit(1)

    project_path = Path(project).resolve()
    db_path = project_path / ".claw-forge" / "state.db"
    if not db_path.exists():
        console.print(f"[red]✖  State database not found at {db_path}[/red]")
        raise typer.Exit(1)

    sess_id = session or None
    if scope == "session" and not sess_id:
        sess_id = _resolve_latest_session(db_path, project_path)

    redaction_rules = _redaction_rule_names(redact)
    redactor = _build_redactor(redact)

    raw_iter = exp.iter_training_records(
        db_path,
        scope=scope, session=sess_id,
        filter_=filter_, include_failed=include_failed,
        include_diff=include_diff, repo_dir=project_path,
        redaction_rules=redaction_rules,
    )

    # Turn-count filter
    def _passes_turn_filter(rec):
        n = sum(1 for ev in rec.get("raw_events", []) if ev.get("kind") == "AssistantMessage")
        return min_turns <= n <= max_turns

    filtered = (r for r in raw_iter if _passes_turn_filter(r))

    # Dedupe BEFORE projection (cheaper)
    deduped = exp.dedupe_records(filtered, by=dedupe_by)

    project_fn = {
        "openai-tools": proj.to_openai_tools,
        "sharegpt": proj.to_sharegpt,
        "chatml": proj.to_chatml,
        "raw-jsonl": proj.to_raw_jsonl,
    }[format_]

    # Materialize projections (split needs a list)
    projected = [redactor.apply(project_fn(r, include_thinking=include_thinking)) for r in deduped]

    if split:
        parts = [int(x) for x in split.split("/")]
        if len(parts) != 3 or sum(parts) != 100:
            console.print(f"[red]✖  --split must be like 80/10/10[/red]")
            raise typer.Exit(1)
        ratios = tuple(p / 100 for p in parts)
        train, val, test = exp.split_records(projected, ratios=ratios)
        out_dir = Path(out) if out else project_path / "claw-forge-training"
        out_dir.mkdir(parents=True, exist_ok=True)
        for name, recs in [("train", train), ("val", val), ("test", test)]:
            (out_dir / f"{name}.jsonl").write_text(
                "\n".join(json.dumps(r) for r in recs) + ("\n" if recs else "")
            )
        console.print(f"[green]✓ wrote {len(train)}/{len(val)}/{len(test)} records to {out_dir}[/green]")
    else:
        out_path = Path(out) if out else None
        body = "\n".join(json.dumps(r) for r in projected) + ("\n" if projected else "")
        if out_path:
            out_path.parent.mkdir(parents=True, exist_ok=True)
            out_path.write_text(body)
            console.print(f"[green]✓ wrote {len(projected)} records to {out_path}[/green]")
        else:
            console.print(body, end="")


def _redaction_rule_names(redact: str) -> list[str]:
    names: list[str] = []
    if redact in ("secrets", "both"):
        names.append("SecretsRule")
    if redact in ("usernames", "both"):
        names.append("UsernamesRule")
    return names


def _build_redactor(redact: str):
    """Stub — real implementation lands in Task C1.

    Returns an object with `.apply(record) -> record` that is the identity
    by default. Task C1 swaps this in for the real `Redactor`."""
    class _Identity:
        def apply(self, rec):
            return rec
    return _Identity()
```

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

Run: `uv run pytest tests/training/test_export_cli.py -v`
Expected: PASS for all three tests.

Run: `uv run pytest tests/test_exporter.py -v` (existing CSV/SQL/JSON tests)
Expected: PASS — the sub-Typer conversion preserves the default behaviour.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/cli.py tests/training/test_export_cli.py
git commit -m "feat(cli): claw-forge export training subcommand (JSONL emission)"
```

---

# Phase C — Privacy + Retention

### Task C1: `Redactor` + `SecretsRule`

**Files:**
- Create: `claw_forge/training/redact.py`
- Modify: `claw_forge/cli.py` (replace the `_build_redactor` stub from Task B6 with the real wiring)
- Test: new `tests/training/test_redact.py`

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

Create `tests/training/test_redact.py`:

```python
"""Tests for export-time redaction."""
from __future__ import annotations

from claw_forge.training.redact import Redactor, SecretsRule


def test_secrets_rule_redacts_aws_key():
    rule = SecretsRule()
    text = "AWS key is AKIAIOSFODNN7EXAMPLE in prod"
    out = rule.apply_text(text)
    assert "AKIA" not in out
    assert "<REDACTED:secret>" in out


def test_secrets_rule_redacts_github_token():
    rule = SecretsRule()
    text = "token: ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789"
    out = rule.apply_text(text)
    assert "ghp_" not in out
    assert "<REDACTED:secret>" in out


def test_secrets_rule_redacts_authorization_header_value():
    rule = SecretsRule()
    text = "Authorization: Bearer sk-abc123def456ghi789jkl012mno345pqr678"
    out = rule.apply_text(text)
    assert "sk-abc123" not in out


def test_secrets_rule_redacts_anthropic_key():
    rule = SecretsRule()
    text = "ANTHROPIC_API_KEY=sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789ABCDEF"
    out = rule.apply_text(text)
    assert "sk-ant-api03" not in out


def test_redactor_walks_nested_record():
    redactor = Redactor(rules=[SecretsRule()])
    rec = {
        "messages": [
            {"role": "user", "content": "AKIAIOSFODNN7EXAMPLE in env"},
            {"role": "tool", "content": [{"text": "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789"}]},
        ],
        "task_id": "t1",
    }
    out = redactor.apply(rec)
    assert "AKIA" not in str(out)
    assert "ghp_" not in str(out)
    assert out["task_id"] == "t1"


def test_redactor_no_rules_is_identity():
    redactor = Redactor(rules=[])
    rec = {"a": "b", "c": [1, 2]}
    assert redactor.apply(rec) == rec
```

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

Run: `uv run pytest tests/training/test_redact.py -v`
Expected: FAIL — module doesn't exist.

- [ ] **Step 3: Implement `Redactor` + `SecretsRule`**

Create `claw_forge/training/redact.py`:

```python
"""Export-time redaction.

Applied AFTER projection (so it sees the final JSONL shape) and BEFORE
serialization (so the regex sweeps cover every string field). Walks records
recursively; non-string values pass through.
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Any, Protocol


class RedactionRule(Protocol):
    name: str
    def apply_text(self, text: str) -> str: ...


# Common secret patterns. Patterns are deliberately conservative — better to
# miss some than to mangle innocuous strings that look secret-shaped.
_SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [
    (re.compile(r"AKIA[0-9A-Z]{16}"), "<REDACTED:secret>"),                  # AWS access key
    (re.compile(r"(?i)aws_secret[_a-z0-9]*\s*[:=]\s*\S+"), "<REDACTED:secret>"),
    (re.compile(r"ghp_[A-Za-z0-9]{36}"), "<REDACTED:secret>"),               # GitHub PAT
    (re.compile(r"github_pat_[A-Za-z0-9_]{82}"), "<REDACTED:secret>"),       # GitHub fine-grained PAT
    (re.compile(r"sk-ant-(api|admin)\d+-[A-Za-z0-9_-]{40,}"), "<REDACTED:secret>"),  # Anthropic
    (re.compile(r"sk-[A-Za-z0-9]{20,}"), "<REDACTED:secret>"),               # OpenAI / generic sk-
    (re.compile(r"sk_(live|test)_[A-Za-z0-9]{24,}"), "<REDACTED:secret>"),   # Stripe
    (re.compile(r"AIza[0-9A-Za-z_-]{35}"), "<REDACTED:secret>"),             # GCP API key
    (re.compile(r"(?i)authorization\s*:\s*\S+"), "Authorization: <REDACTED:secret>"),
]


@dataclass
class SecretsRule:
    name: str = "SecretsRule"

    def apply_text(self, text: str) -> str:
        for pat, repl in _SECRET_PATTERNS:
            text = pat.sub(repl, text)
        return text


@dataclass
class Redactor:
    rules: list[RedactionRule] = field(default_factory=list)

    def apply(self, record: Any) -> Any:
        if not self.rules:
            return record
        return self._walk(record)

    def _walk(self, val: Any) -> Any:
        if isinstance(val, str):
            for rule in self.rules:
                val = rule.apply_text(val)
            return val
        if isinstance(val, dict):
            return {k: self._walk(v) for k, v in val.items()}
        if isinstance(val, list):
            return [self._walk(v) for v in val]
        if isinstance(val, tuple):
            return tuple(self._walk(v) for v in val)
        return val
```

In `cli.py`, replace the stub `_build_redactor` from Task B6 with:

```python
def _build_redactor(redact: str):
    from claw_forge.training.redact import Redactor, SecretsRule
    rules = []
    if redact in ("secrets", "both"):
        rules.append(SecretsRule())
    return Redactor(rules=rules)
```

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

Run: `uv run pytest tests/training/test_redact.py -v`
Expected: PASS for all six tests.

Manual smoke: `uv run pytest tests/training/test_export_cli.py -v` — re-running the CLI tests confirms `_build_redactor` swap didn't break anything.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/redact.py claw_forge/cli.py tests/training/test_redact.py
git commit -m "feat(training): Redactor + SecretsRule (AWS/GH/Stripe/OpenAI/Anthropic/GCP)"
```

---

### Task C2: `UsernamesRule` + `CustomPatternsRule`

**Files:**
- Modify: `claw_forge/training/redact.py`
- Modify: `claw_forge/cli.py` (extend `_build_redactor`)
- Test: extend `tests/training/test_redact.py`

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

Append to `tests/training/test_redact.py`:

```python
def test_usernames_rule_substitutes_home_dir():
    from claw_forge.training.redact import UsernamesRule
    rule = UsernamesRule(username="bowenli")
    text = "see /Users/bowenli/development/claw-forge/src/auth.py for context"
    out = rule.apply_text(text)
    assert "bowenli" not in out
    assert "<REDACTED:username>" in out
    assert "/development/claw-forge/src/auth.py" in out


def test_usernames_rule_handles_linux_home():
    from claw_forge.training.redact import UsernamesRule
    rule = UsernamesRule(username="alice")
    text = "logs at /home/alice/.local/state/claw-forge.log"
    out = rule.apply_text(text)
    assert "alice" not in out
    assert "/home/<REDACTED:username>/.local/state/claw-forge.log" in out


def test_custom_patterns_rule():
    from claw_forge.training.redact import CustomPatternsRule
    rule = CustomPatternsRule(patterns=[r"customer-\d+", r"INTERNAL-[A-Z0-9]+"])
    text = "ticket customer-12345 and order INTERNAL-ABC1"
    out = rule.apply_text(text)
    assert "customer-12345" not in out
    assert "INTERNAL-ABC1" not in out


def test_redactor_with_all_rules_chains():
    from claw_forge.training.redact import (
        CustomPatternsRule, Redactor, SecretsRule, UsernamesRule,
    )
    redactor = Redactor(rules=[
        SecretsRule(),
        UsernamesRule(username="bowenli"),
        CustomPatternsRule(patterns=[r"FOO-\d+"]),
    ])
    rec = {"x": "AKIAIOSFODNN7EXAMPLE in /Users/bowenli/dir for FOO-9"}
    out = redactor.apply(rec)
    assert "AKIA" not in out["x"]
    assert "bowenli" not in out["x"]
    assert "FOO-9" not in out["x"]
```

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

Run: `uv run pytest tests/training/test_redact.py -v`
Expected: FAIL — `UsernamesRule` and `CustomPatternsRule` don't exist.

- [ ] **Step 3: Implement the two rules**

Append to `claw_forge/training/redact.py`:

```python
import getpass


@dataclass
class UsernamesRule:
    """Substitute the OS username in absolute home-directory paths.

    Targets: /Users/<user>/, /home/<user>/, C:\\Users\\<user>\\
    Preserves directory structure after the username.
    """
    username: str | None = None
    name: str = "UsernamesRule"

    def __post_init__(self) -> None:
        if self.username is None:
            try:
                self.username = getpass.getuser()
            except Exception:
                self.username = ""

    def apply_text(self, text: str) -> str:
        if not self.username:
            return text
        u = re.escape(self.username)
        text = re.sub(rf"/Users/{u}\b", "/Users/<REDACTED:username>", text)
        text = re.sub(rf"/home/{u}\b", "/home/<REDACTED:username>", text)
        text = re.sub(rf"C:\\\\Users\\\\{u}\b", r"C:\\Users\\<REDACTED:username>", text)
        return text


@dataclass
class CustomPatternsRule:
    """User-supplied regex list. Each match becomes <REDACTED:custom>."""
    patterns: list[str] = field(default_factory=list)
    name: str = "CustomPatternsRule"
    _compiled: list[re.Pattern[str]] = field(default_factory=list, init=False, repr=False)

    def __post_init__(self) -> None:
        self._compiled = [re.compile(p) for p in self.patterns]

    def apply_text(self, text: str) -> str:
        for pat in self._compiled:
            text = pat.sub("<REDACTED:custom>", text)
        return text
```

In `cli.py`, extend `_build_redactor`:

```python
def _build_redactor(redact: str, custom_patterns: list[str] | None = None):
    from claw_forge.training.redact import (
        CustomPatternsRule, Redactor, SecretsRule, UsernamesRule,
    )
    rules: list[Any] = []
    if redact in ("secrets", "both"):
        rules.append(SecretsRule())
    if redact in ("usernames", "both"):
        rules.append(UsernamesRule())
    if custom_patterns:
        rules.append(CustomPatternsRule(patterns=custom_patterns))
    return Redactor(rules=rules)
```

And update the call site in the `export training` command to pass `custom_patterns` from the loaded config:

```python
cfg_block = {}
try:
    cfg_block = _load_config(str(project_path / "claw-forge.yaml")).get("training_traces", {}).get("redaction", {})
except Exception:
    pass
custom = list(cfg_block.get("custom_patterns") or [])
redactor = _build_redactor(redact, custom_patterns=custom)
```

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

Run: `uv run pytest tests/training/test_redact.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/redact.py claw_forge/cli.py tests/training/test_redact.py
git commit -m "feat(training): UsernamesRule + CustomPatternsRule"
```

---

### Task C3: `claw-forge traces` subapp (`status` + `prune`)

**Files:**
- Create: `claw_forge/training/cli.py`
- Modify: `claw_forge/cli.py` (`app.add_typer(traces_app, name="traces")` near the existing `worktrees_app` registration)
- Test: new `tests/training/test_traces_cli.py`

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

Create `tests/training/test_traces_cli.py`:

```python
"""Tests for `claw-forge traces` subapp."""
from __future__ import annotations

from pathlib import Path

import pytest
from typer.testing import CliRunner

from claw_forge.cli import app


def _seed_db_with_events(db: Path, n_events: int = 5) -> None:
    import sqlite3
    from datetime import UTC, datetime, timedelta
    db.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(db)
    conn.executescript("""
        CREATE TABLE sessions (id TEXT PRIMARY KEY, project_path TEXT, status TEXT,
                               created_at TEXT, manifest_json TEXT);
        CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT, plugin_name TEXT,
                            status TEXT, merged_to_target_branch INTEGER,
                            bugfix_retry_count INTEGER, merge_commit_sha TEXT,
                            created_at TEXT);
        CREATE TABLE events (id INTEGER PRIMARY KEY AUTOINCREMENT,
                             session_id TEXT, task_id TEXT, event_type TEXT,
                             payload TEXT, created_at TEXT);
    """)
    now = datetime.now(UTC)
    conn.execute("INSERT INTO sessions VALUES ('s', '/tmp', 'active', ?, '{}')",
                 (now.isoformat(),))
    conn.execute("INSERT INTO tasks VALUES ('t', 's', 'coding', 'completed', 1, 0, 'abc', ?)",
                 (now.isoformat(),))
    for i in range(n_events):
        ts = (now - timedelta(days=i * 10)).isoformat()
        conn.execute(
            "INSERT INTO events (session_id, task_id, event_type, payload, created_at) VALUES (?,?,?,?,?)",
            ("s", "t", "agent_message", '{"turn": 0}', ts),
        )
    conn.commit()
    conn.close()


def test_traces_status_reports_counts(tmp_path):
    db = tmp_path / ".claw-forge" / "state.db"
    _seed_db_with_events(db, n_events=5)
    runner = CliRunner()
    res = runner.invoke(app, ["traces", "status", "--project", str(tmp_path)])
    assert res.exit_code == 0, res.output
    assert "5" in res.output  # event count
    assert "agent_message" in res.output


def test_traces_prune_keep_last(tmp_path):
    db = tmp_path / ".claw-forge" / "state.db"
    _seed_db_with_events(db, n_events=5)
    runner = CliRunner()
    res = runner.invoke(app, [
        "traces", "prune", "--keep-last", "2", "--project", str(tmp_path),
    ])
    assert res.exit_code == 0, res.output

    import sqlite3
    conn = sqlite3.connect(db)
    n = conn.execute(
        "SELECT COUNT(*) FROM events WHERE event_type='agent_message'"
    ).fetchone()[0]
    conn.close()
    assert n == 2


def test_traces_prune_dry_run_does_not_delete(tmp_path):
    db = tmp_path / ".claw-forge" / "state.db"
    _seed_db_with_events(db, n_events=5)
    runner = CliRunner()
    res = runner.invoke(app, [
        "traces", "prune", "--keep-last", "1", "--dry-run", "--project", str(tmp_path),
    ])
    assert res.exit_code == 0, res.output

    import sqlite3
    conn = sqlite3.connect(db)
    n = conn.execute(
        "SELECT COUNT(*) FROM events WHERE event_type='agent_message'"
    ).fetchone()[0]
    conn.close()
    assert n == 5
```

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

Run: `uv run pytest tests/training/test_traces_cli.py -v`
Expected: FAIL — `traces` is not a registered subcommand.

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

Create `claw_forge/training/cli.py`:

```python
"""Typer subapp: `claw-forge traces [status|prune]`.

Inspect and prune the agent_message events captured by training_traces.
Reads state.db directly via sqlite3 — no state-service dependency.
"""
from __future__ import annotations

import sqlite3
from pathlib import Path

import typer
from rich.console import Console

traces_app = typer.Typer(
    help="Inspect and prune training-trace agent_message events.",
    no_args_is_help=True,
)

console = Console()


def _db_path(project: str) -> Path:
    return Path(project).resolve() / ".claw-forge" / "state.db"


@traces_app.command("status")
def status_cmd(
    project: str = typer.Option(".", "--project", "-p"),
) -> None:
    """Report agent_message event counts and DB size."""
    db = _db_path(project)
    if not db.exists():
        console.print(f"[red]✖  State DB not found at {db}[/red]")
        raise typer.Exit(1)
    conn = sqlite3.connect(str(db))
    try:
        total = conn.execute(
            "SELECT COUNT(*) FROM events WHERE event_type='agent_message'"
        ).fetchone()[0]
        per_session = conn.execute(
            "SELECT session_id, COUNT(*) FROM events "
            "WHERE event_type='agent_message' GROUP BY session_id"
        ).fetchall()
    finally:
        conn.close()

    db_mb = db.stat().st_size / (1024 * 1024)
    console.print(f"[bold]agent_message events:[/bold] {total}")
    console.print(f"[bold]state.db size:[/bold] {db_mb:.1f} MB")
    if per_session:
        console.print("[bold]Per session:[/bold]")
        for sid, count in per_session:
            console.print(f"  {sid}: {count}")


@traces_app.command("prune")
def prune_cmd(
    project: str = typer.Option(".", "--project", "-p"),
    before: str = typer.Option("", "--before",
        help="ISO date — delete agent_message events older than this"),
    keep_last: int = typer.Option(0, "--keep-last",
        help="Keep only the N newest agent_message events; delete the rest"),
    session: str = typer.Option("", "--session"),
    all_: bool = typer.Option(False, "--all", help="Delete every agent_message event"),
    dry_run: bool = typer.Option(False, "--dry-run"),
    keep_completed: bool = typer.Option(False, "--keep-completed",
        help="Spare events from tasks where status='completed' AND merged_to_target_branch=1"),
) -> None:
    """Delete agent_message events under the configured criteria."""
    db = _db_path(project)
    if not db.exists():
        console.print(f"[red]✖  State DB not found at {db}[/red]")
        raise typer.Exit(1)
    if not (before or keep_last or session or all_):
        console.print("[red]✖  Must pass one of --before / --keep-last / --session / --all[/red]")
        raise typer.Exit(1)

    conn = sqlite3.connect(str(db))
    try:
        ids = _select_prune_ids(
            conn, before=before, keep_last=keep_last,
            session=session, all_=all_, keep_completed=keep_completed,
        )
        n = len(ids)
        if dry_run:
            console.print(f"[yellow]would delete {n} agent_message events[/yellow]")
            return
        if not ids:
            console.print("nothing to delete")
            return
        # Chunked DELETE to keep parameter count reasonable
        for i in range(0, n, 500):
            chunk = ids[i:i + 500]
            conn.execute(
                f"DELETE FROM events WHERE id IN ({','.join('?' * len(chunk))})",
                chunk,
            )
        conn.commit()

        # VACUUM if reclaimable space exceeds 100 MB
        free_pages = conn.execute("PRAGMA freelist_count").fetchone()[0]
        page_size = conn.execute("PRAGMA page_size").fetchone()[0]
        reclaimable_mb = (free_pages * page_size) / (1024 * 1024)
        if reclaimable_mb > 100:
            conn.execute("VACUUM")
            console.print(f"[dim]VACUUMed (reclaimed ~{reclaimable_mb:.0f} MB)[/dim]")
    finally:
        conn.close()
    console.print(f"[green]✓ deleted {n} agent_message events[/green]")


def _select_prune_ids(
    conn: sqlite3.Connection,
    *,
    before: str,
    keep_last: int,
    session: str,
    all_: bool,
    keep_completed: bool,
) -> list[int]:
    where = ["event_type='agent_message'"]
    args: list = []
    if all_:
        pass  # no extra filter
    if before:
        where.append("created_at < ?")
        args.append(before)
    if session:
        where.append("session_id = ?")
        args.append(session)
    if keep_completed:
        where.append(
            "task_id NOT IN (SELECT id FROM tasks "
            "WHERE status='completed' AND merged_to_target_branch=1)"
        )
    sql = f"SELECT id FROM events WHERE {' AND '.join(where)} ORDER BY created_at"
    ids = [row[0] for row in conn.execute(sql, args)]
    if keep_last:
        # Keep the newest N globally, prune the rest.
        keep_sql = (
            f"SELECT id FROM events WHERE {' AND '.join(where)} "
            f"ORDER BY created_at DESC LIMIT {int(keep_last)}"
        )
        keep_ids = {row[0] for row in conn.execute(keep_sql, args)}
        ids = [i for i in ids if i not in keep_ids]
    return ids
```

In `claw_forge/cli.py` near the existing subapp registrations (line ~37-38), add:

```python
from claw_forge.training.cli import traces_app
app.add_typer(traces_app, name="traces")
```

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

Run: `uv run pytest tests/training/test_traces_cli.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/training/cli.py claw_forge/cli.py tests/training/test_traces_cli.py
git commit -m "feat(training): claw-forge traces [status|prune] subapp"
```

---

### Task C4: Auto-prune at state-service startup

**Files:**
- Modify: `claw_forge/state/service.py` (lifespan startup)
- Test: extend `tests/training/test_traces_cli.py`

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

Append to `tests/training/test_traces_cli.py`:

```python
@pytest.mark.asyncio
async def test_auto_prune_at_startup(tmp_path):
    """When training_traces.retention.keep_days > 0, startup deletes old events."""
    db = tmp_path / "state.db"
    _seed_db_with_events(db, n_events=5)  # events span 0, 10, 20, 30, 40 days ago

    config = {
        "training_traces": {
            "retention": {"keep_days": 25, "on_state_service_startup": True},
        },
    }
    from claw_forge.state.service import auto_prune_old_traces
    deleted = auto_prune_old_traces(db, config=config)
    assert deleted == 2  # 30-day and 40-day events

    import sqlite3
    conn = sqlite3.connect(str(db))
    n = conn.execute(
        "SELECT COUNT(*) FROM events WHERE event_type='agent_message'"
    ).fetchone()[0]
    conn.close()
    assert n == 3


def test_auto_prune_no_op_when_disabled(tmp_path):
    db = tmp_path / "state.db"
    _seed_db_with_events(db, n_events=3)
    from claw_forge.state.service import auto_prune_old_traces
    deleted = auto_prune_old_traces(db, config={})
    assert deleted == 0
```

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

Run: `uv run pytest tests/training/test_traces_cli.py::test_auto_prune_at_startup -v`
Expected: FAIL — `auto_prune_old_traces` doesn't exist.

- [ ] **Step 3: Add the function + wire into lifespan**

In `claw_forge/state/service.py`, add a module-level helper (near the other pruning / cleanup helpers, or near `init_db`):

```python
def auto_prune_old_traces(db_path: Path, *, config: dict[str, Any]) -> int:
    """If training_traces.retention.keep_days > 0, delete old agent_message events.

    Returns the number of rows deleted. Synchronous + sqlite3-direct so it
    can run in the lifespan startup before the async engine is up.
    """
    from claw_forge.training.config import extract_training_traces_config

    cfg = extract_training_traces_config(config)
    if not cfg.retention_on_startup or cfg.retention_keep_days <= 0:
        return 0
    if not db_path.exists():
        return 0
    cutoff = (datetime.now(UTC) - timedelta(days=cfg.retention_keep_days)).isoformat()
    conn = sqlite3.connect(str(db_path))
    try:
        cur = conn.execute(
            "DELETE FROM events WHERE event_type='agent_message' AND created_at < ?",
            (cutoff,),
        )
        deleted = cur.rowcount
        conn.commit()
        return deleted
    finally:
        conn.close()
```

Add the imports at the top of `service.py` if missing: `import sqlite3`, `from datetime import UTC, datetime, timedelta`.

In the lifespan startup function (search for `async def lifespan(`), after the existing `init_db()` / WAL-checkpoint sequence, add:

```python
    try:
        deleted = auto_prune_old_traces(db_path, config=raw_config)
        if deleted:
            logger.info("auto-pruned %d old agent_message events", deleted)
    except Exception:
        logger.warning("auto_prune_old_traces failed", exc_info=True)
```

`db_path` and `raw_config` are local names in lifespan; use whatever the actual variables are named (search for `init_db(` to find the right context).

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

Run: `uv run pytest tests/training/test_traces_cli.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/state/service.py tests/training/test_traces_cli.py
git commit -m "feat(state): auto-prune old agent_message events at startup"
```

---

# Phase D — Documentation

### Task D1: `docs/commands.md` updates

**Files:**
- Modify: `docs/commands.md`

- [ ] **Step 1: Read the current TOC and identify insertion points**

Run: `grep -n "^##\|^###" /Users/bowenli/development/claw-forge/docs/commands.md | head -40`

Find the existing `claw-forge export` section and the `claw-forge worktrees` section — insert the new content adjacent to those (`export training` near `export`, `traces` near `worktrees`).

- [ ] **Step 2: Add the `export training` reference**

After the existing `### claw-forge export` section, add:

```markdown
### claw-forge export training

Emit per-task agent trajectories as JSONL for fine-tuning a smaller code-specific LLM.
Reads `.claw-forge/state.db` directly — no state-service dependency, safe to run while
a session is active.

Off by default. Enable via `training_traces.enabled` and `training_traces.acknowledged_terms`
in `claw-forge.yaml` (both must be true to record).

#### Flags

| Flag | Default | Description |
|------|---------|-------------|
| `--scope` | `all` | `all` = every session in this DB; `session` = one session |
| `--session` | latest for project | Override session UUID |
| `--filter` | `both` | `success` / `recovered` / `both` — see filter table below |
| `--include-failed` | off | Adds `outcome='failed'` records (DPO/rejection-sampling negatives) |
| `--format` | `openai-tools` | `openai-tools` / `sharegpt` / `chatml` / `raw-jsonl` |
| `--include-diff/--no-include-diff` | on | Attach the squash-merge diff as `final_diff` (ground truth) |
| `--include-thinking/--no-include-thinking` | on | Preserve `ThinkingBlock` content (non-standard ext) |
| `--min-turns` | `4` | Skip trajectories shorter than this |
| `--max-turns` | `200` | Skip trajectories longer than this |
| `--redact` | `both` | `secrets` / `usernames` / `both` / `none` — applied at export time |
| `--dedupe-by` | `both` | `prompt` / `diff` / `both` / `none` |
| `--split` | none | e.g. `80/10/10` — emits `train.jsonl` + `val.jsonl` + `test.jsonl` |
| `--out`, `-o` | stdout | Output file (or directory for `--split`) |
| `--project`, `-p` | `.` | Project directory |

#### Filter semantics

| Filter | SQL predicate |
|---|---|
| `success` | `status='completed' AND merged_to_target_branch=1 AND bugfix_retry_count=0` |
| `recovered` | `status='completed' AND merged_to_target_branch=1 AND bugfix_retry_count>0` |
| `both` | union of the above |

#### Examples

```bash
claw-forge export training --out corpus.jsonl              # default settings
claw-forge export training --filter success --out clean.jsonl
claw-forge export training --split 80/10/10 --out ./split  # train/val/test
claw-forge export training --format raw-jsonl              # full SDK trace, no projection
claw-forge export training --include-failed --out all.jsonl  # for DPO
```

For the full pipeline (export → fine-tune Qwen2.5-Coder via Unsloth), see
[docs/training/unsloth-recipe.md](training/unsloth-recipe.md).
```

- [ ] **Step 3: Add the `traces` reference**

After the existing `### claw-forge worktrees` section, add:

```markdown
### claw-forge traces

Inspect and prune the agent_message events captured by `training_traces`.

#### Subcommands

| Command | Description |
|---|---|
| `traces status` | Count agent_message events and report state.db size |
| `traces prune --before <DATE>` | Delete events older than a date |
| `traces prune --keep-last N` | Keep only the N newest events; delete the rest |
| `traces prune --session UUID` | Delete events for one session |
| `traces prune --all` | Delete every agent_message event |
| `traces prune --keep-completed` | Spare events from completed/merged tasks |
| `traces prune --dry-run` | Report what would be deleted without modifying the DB |

`traces prune` runs `VACUUM` automatically when reclaimable space exceeds 100 MB.

#### Auto-prune at state-service startup

When `training_traces.retention.keep_days > 0`, the state service deletes
agent_message events older than that on startup. Set `keep_days: 0` (the
default) to disable.
```

- [ ] **Step 4: Verify the file still renders**

Run: `head -200 /Users/bowenli/development/claw-forge/docs/commands.md`

Spot-check that the new sections fit cleanly and don't break adjacent table rendering.

- [ ] **Step 5: Commit**

```bash
git add docs/commands.md
git commit -m "docs(commands): claw-forge export training + claw-forge traces"
```

---

### Task D2: `docs/training/unsloth-recipe.md`

**Files:**
- Create: `docs/training/unsloth-recipe.md`

- [ ] **Step 1: Create the file with the worked recipe**

Create `docs/training/unsloth-recipe.md`:

````markdown
# Fine-tuning a code-SLM from claw-forge traces with Unsloth

This is a **reference recipe**, not a hard requirement. Every piece is
swappable. The corpus that `claw-forge export training` produces is in OpenAI
tool-use chat format by default and works with any of: Unsloth, Axolotl, trl,
or vanilla Hugging Face `transformers.Trainer` with `apply_chat_template(tools=...)`.

## 1. Export the corpus

```bash
# Single file, default settings
claw-forge export training --out corpus.jsonl

# Or split for training-time eval
claw-forge export training --split 80/10/10 --out ./corpus
```

## 2. Pick a base model

| Model | VRAM (LoRA) | Why |
|---|---|---|
| `unsloth/Qwen2.5-Coder-7B-Instruct-bnb-4bit` | 24 GB | **Recommended.** Coder-specialized, native tool-use chat template, Apache 2.0. |
| `unsloth/Qwen2.5-Coder-14B-Instruct-bnb-4bit` | 40 GB | Same series, more capacity. |
| `unsloth/DeepSeek-Coder-V2-Lite-Instruct-bnb-4bit` | 24 GB | Slightly stronger raw eval scores. MoE — finickier for LoRA. |

## 3. LoRA configuration

```python
from unsloth import FastLanguageModel
import torch

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/Qwen2.5-Coder-7B-Instruct-bnb-4bit",
    max_seq_length=8192,
    dtype=None,            # autodetect
    load_in_4bit=True,
)

model = FastLanguageModel.get_peft_model(
    model,
    r=16, lora_alpha=32, lora_dropout=0.0,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    use_gradient_checkpointing="unsloth",
    random_state=3407,
)
```

## 4. Load the corpus

```python
from datasets import load_dataset

ds = load_dataset("json", data_files="corpus.jsonl", split="train")

def formatter(example):
    return {
        "text": tokenizer.apply_chat_template(
            example["messages"],
            tokenize=False,
            add_generation_prompt=False,
        ),
    }

ds = ds.map(formatter, remove_columns=ds.column_names)
```

The `thinking` field on assistant messages is silently dropped by
`apply_chat_template` — most templates don't have a slot for it. If you want
to train on chain-of-thought, export with `--format raw-jsonl` and write your
own template renderer.

## 5. Train

```python
from trl import SFTTrainer, SFTConfig

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=ds,
    args=SFTConfig(
        per_device_train_batch_size=2,
        gradient_accumulation_steps=8,
        num_train_epochs=3,
        learning_rate=2e-4,
        warmup_ratio=0.03,
        lr_scheduler_type="cosine",
        optim="adamw_8bit",
        max_seq_length=8192,
        bf16=torch.cuda.is_bf16_supported(),
        logging_steps=10,
        output_dir="./out",
        report_to="none",
    ),
)
trainer.train()
```

For ~500 trajectories at average 6 K tokens each, expect single-digit hours
on a 4090. Calibrate with a 50-task dry run before committing.

## 6. Eval

Two metrics:

1. **ROUGE-L on assistant content** — reasoning-quality proxy. Cheap, fast,
   weakly correlates with correctness.
2. **Diff similarity vs `final_diff`** — the real correctness signal.
   Reconstruct the model's proposed patch from its `tool_calls` (Edit / Write
   blocks) and score with line-level Jaccard plus
   `difflib.SequenceMatcher.ratio()` against the captured `final_diff`.

```python
import difflib

def diff_similarity(predicted_diff: str, ground_truth: str) -> float:
    pred_lines = set(predicted_diff.splitlines())
    truth_lines = set(ground_truth.splitlines())
    jaccard = len(pred_lines & truth_lines) / max(1, len(pred_lines | truth_lines))
    seq = difflib.SequenceMatcher(None, predicted_diff, ground_truth).ratio()
    return 0.5 * jaccard + 0.5 * seq
```

## 7. Phase 2 — DPO / rejection sampling (out of scope here)

Once the SFT model is reasonable, the captured `final_diff` is the reward
function. Generate K candidates per prompt, score by diff similarity, build
preference pairs, run a DPO step. The export schema is forward-compatible:
re-run `claw-forge export training --include-failed` to add explicit negatives.
````

- [ ] **Step 2: Verify it renders**

Run: `head -50 /Users/bowenli/development/claw-forge/docs/training/unsloth-recipe.md`

Confirm the front matter and code blocks aren't broken.

- [ ] **Step 3: Commit**

```bash
git add docs/training/unsloth-recipe.md
git commit -m "docs(training): worked Unsloth + Qwen2.5-Coder LoRA recipe"
```

---

### Task D3: `CLAUDE.md` architecture entry

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

- [ ] **Step 1: Find the insertion point**

Run: `grep -n "^### " /Users/bowenli/development/claw-forge/CLAUDE.md | head`

Identify the spot under "Architecture Overview" → "Three-Layer Stack" or near the existing `### Boundaries Harness` section.

- [ ] **Step 2: Append a `### Training-Trace Capture` section**

After the last `###` subsection under Architecture Overview, add:

```markdown
### Training-Trace Capture (`claw_forge/training/`)

Off by default. When enabled (two-key opt-in: `training_traces.enabled` +
`training_traces.acknowledged_terms`), every SDK message from `run_agent()`
is teed to the existing `events` table as `event_type="agent_message"`. The
recorder is a caller-side wrapper around the async iterator — `run_agent`
itself stays a pure SDK wrapper. Capture is best-effort: a state-service
hiccup never aborts an in-flight task.

`claw-forge export training` reads `.claw-forge/state.db` directly via
`sqlite3` (no state-service dep), filters by task outcome (`success` /
`recovered` / both, optionally `--include-failed` for DPO negatives),
projects to OpenAI tool-use JSONL by default (with ShareGPT, ChatML, and
raw alternatives), redacts secrets and usernames at export time, and emits
a single file or train/val/test split. The squash-merge commit SHA is
captured on `Task.merge_commit_sha` so `--include-diff` can attach the
canonical "ground-truth answer" diff to every successful trajectory.

`claw-forge traces [status|prune]` is the retention surface; auto-prune
at state-service startup is config-driven via
`training_traces.retention.keep_days`.

The downstream training pipeline (Qwen2.5-Coder + LoRA via Unsloth) is
documented in `docs/training/unsloth-recipe.md` and lives entirely outside
the harness — claw-forge owns the corpus, the user owns the training loop.
```

- [ ] **Step 3: Commit**

```bash
git add CLAUDE.md
git commit -m "docs(claude.md): training-trace capture architecture section"
```

---

# Wrap-up

### Task W1: Full test sweep + lint + type check

- [ ] **Step 1: Run the full test suite with coverage**

Run: `uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing`
Expected: PASS for all tests; coverage ≥ 90% (project gate).

- [ ] **Step 2: Lint**

Run: `uv run ruff check claw_forge/ tests/`
Expected: No errors. Run `--fix` if there are auto-fixable issues.

- [ ] **Step 3: Type check**

Run: `uv run mypy claw_forge/ --ignore-missing-imports`
Expected: No errors in the new modules. Pre-existing errors in untouched files are fine.

- [ ] **Step 4: Smoke-test the new commands end-to-end**

```bash
# In a project with an existing .claw-forge/state.db that has agent_message events:
uv run claw-forge traces status
uv run claw-forge export training --filter both --out /tmp/corpus.jsonl
head -1 /tmp/corpus.jsonl | python -m json.tool   # one well-formed record
```

- [ ] **Step 5: Final commit if anything was tweaked during the sweep**

If the sweep surfaced anything, commit those fixes. If not, skip.

---

# Self-Review Notes

A check of every spec section to a task that implements it:

| Spec section | Implemented by |
|---|---|
| § Capture / tap point | Task A4 (param), Task A7 (six call sites) |
| § Capture / record_messages helper | Task A3 |
| § Capture / Event row schema | Task A3 (`_serialize_message`), Task A2 (POST endpoint) |
| § Capture / `Task.merge_commit_sha` | Task A1 (column), Task A8 (PATCH after squash) |
| § Export / `claw-forge export training` command | Task B6 |
| § Export / filter semantics | Task B1 |
| § Export / per-task JSONL record | Task B2 (openai-tools), Task B4 (final_diff + provenance) |
| § Export / format choices | Task B2 (default), Task B3 (sharegpt / chatml / raw) |
| § Export / projection logic | Task B2 |
| § Export / dedupe + split | Task B5 |
| § Privacy / opt-in config | Task A5 (config block), Task A6 (banner) |
| § Privacy / Redactor | Task C1 (Secrets), Task C2 (Usernames + Custom) |
| § Retention / `traces` subapp | Task C3 |
| § Retention / auto-prune | Task C4 |
| § Recipe doc | Task D2 |
| § File-level changes | Tasks D1 (commands.md), D3 (CLAUDE.md) |

No placeholders. All steps contain concrete code, exact paths, and exact pytest commands.

Type / method consistency check:

- `RecordingDestination(session_id, task_id, state_url)` — used identically in Tasks A3, A4, A7.
- `record_to: RecordingDestination | None = None` — same param name in `run_agent`, `collect_result`, `collect_structured_result`.
- `iter_training_records(...)` — signature matches between Task B1 and B4 (extended with new optional kwargs).
- Projector signature `to_X(rec: dict, *, include_thinking: bool) -> dict` — consistent across Tasks B2 and B3.
- `Redactor.apply(record)` — the stub in Task B6 uses the same shape as the real one in Task C1.

