# Fix O365 Search & Add Sender/Domain Filtering — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Fix O365 search (HTTP 400) and add `--from`/`--domain` filtering to `updates` and `list` commands so agents can find messages by sender across all platforms.

**Architecture:** Add `sender`/`domain` kwargs to the base adapter interface. Each adapter translates these to provider-native query syntax (Gmail: query string operators, O365: `$filter` expressions). The command layer threads these through `_fetch_for_source` and `_fetch_messages`. CLI adds `--from`/`--domain` argparse flags. Continuation hints replace the generic "more available" footer.

**Tech Stack:** Python, httpx (O365), Google API (Gmail), pytest

---

### Task 1: Fix O365 `$search` — add ConsistencyLevel header and remove `$orderby`

**Files:**
- Modify: `src/ts4k/adapters/o365.py:248-298`
- Test: `tests/test_o365_adapter.py`

**Step 1: Write failing tests**

Add to `tests/test_o365_adapter.py` class `TestO365AdapterListMessages`:

```python
@pytest.mark.asyncio
async def test_search_adds_consistency_header(self):
    adapter = _make_adapter()
    adapter._client.get = AsyncMock(
        return_value=_mock_response(LIST_RESPONSE_EMPTY)
    )
    await adapter.list_messages(query="budget review", count=10)

    call_args = adapter._client.get.call_args
    assert call_args[1].get("headers", {}).get("ConsistencyLevel") == "eventual"

@pytest.mark.asyncio
async def test_search_removes_orderby(self):
    adapter = _make_adapter()
    adapter._client.get = AsyncMock(
        return_value=_mock_response(LIST_RESPONSE_EMPTY)
    )
    await adapter.list_messages(query="budget review", count=10)

    call_args = adapter._client.get.call_args
    assert "$orderby" not in call_args[1]["params"]

@pytest.mark.asyncio
async def test_no_search_keeps_orderby(self):
    adapter = _make_adapter()
    adapter._client.get = AsyncMock(
        return_value=_mock_response(LIST_RESPONSE_EMPTY)
    )
    await adapter.list_messages(count=10)

    call_args = adapter._client.get.call_args
    assert "$orderby" in call_args[1]["params"]

@pytest.mark.asyncio
async def test_no_search_no_consistency_header(self):
    adapter = _make_adapter()
    adapter._client.get = AsyncMock(
        return_value=_mock_response(LIST_RESPONSE_EMPTY)
    )
    await adapter.list_messages(count=10)

    call_args = adapter._client.get.call_args
    assert "headers" not in call_args[1] or "ConsistencyLevel" not in call_args[1].get("headers", {})
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_o365_adapter.py::TestO365AdapterListMessages -v`
Expected: 3 new tests FAIL (consistency header missing, orderby still present)

**Step 3: Implement the fix**

In `src/ts4k/adapters/o365.py`, modify `_get` to accept optional headers, and modify `list_messages` to handle `$search` properly:

```python
async def _get(
    self,
    path: str,
    params: dict[str, str] | None = None,
    headers: dict[str, str] | None = None,
) -> dict:
    """Issue a GET request to Graph API and return the parsed JSON."""
    client = self._require_client()
    kwargs: dict[str, Any] = {}
    if params:
        kwargs["params"] = params
    if headers:
        kwargs["headers"] = headers
    resp = await client.get(path, **kwargs)
    resp.raise_for_status()
    return resp.json()
```

In `list_messages`, when `query` is set:
- Remove `$orderby` from params (Graph API does not allow `$orderby` with `$search`)
- Pass `ConsistencyLevel: eventual` header
- Sort results client-side after fetch

```python
async def list_messages(
    self,
    query: str | None = None,
    count: int = 20,
    page_token: str | None = None,
) -> list[dict]:
    params: dict[str, str] = {
        "$select": _LIST_SELECT,
        "$top": str(count),
        "$orderby": "receivedDateTime desc",
    }

    if page_token:
        params["$skip"] = page_token

    headers: dict[str, str] | None = None
    if query:
        params["$search"] = f'"{query}"'
        # Graph API: $search requires ConsistencyLevel header
        # and cannot combine with $orderby
        del params["$orderby"]
        headers = {"ConsistencyLevel": "eventual"}

    data = await self._get(
        f"{self._base_url()}/messages", params, headers=headers
    )
    results = _list_response_to_dicts(data, self.source_prefix)

    # Client-side sort when $orderby was removed for $search
    if query:
        results.sort(key=lambda m: m.get("date", ""), reverse=True)

    # Attach next page token if we got a full page
    if results and len(results) == count:
        next_offset = int(page_token or 0) + len(results)
        results[-1]["_next_page_token"] = str(next_offset)

    return results
```

**Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_o365_adapter.py::TestO365AdapterListMessages -v`
Expected: ALL PASS

Also update the existing `test_passes_search_query` test — it currently checks for `$search` but now also needs to verify `$orderby` is absent:

The existing test at line 429 will still pass since it only checks `$search` and `$top`.

**Step 5: Commit**

```bash
git add src/ts4k/adapters/o365.py tests/test_o365_adapter.py
git commit -m "Fix O365 search: add ConsistencyLevel header, remove \$orderby with \$search"
```

---

### Task 2: Add `sender`/`domain` kwargs to base adapter and O365 adapter

**Files:**
- Modify: `src/ts4k/adapters/base.py:47-72`
- Modify: `src/ts4k/adapters/o365.py:257-298`
- Test: `tests/test_o365_adapter.py`

**Step 1: Write failing tests**

Add new test class to `tests/test_o365_adapter.py`:

```python
class TestO365AdapterSenderFilter:
    """Test sender/domain filtering on O365 adapter."""

    @pytest.mark.asyncio
    async def test_sender_filter_on_whatsnew(self):
        adapter = _make_adapter()
        adapter._client.get = AsyncMock(
            return_value=_mock_response(LIST_RESPONSE_EMPTY)
        )
        await adapter.whatsnew(since="2026-01-01T00:00:00Z", sender="alice@contoso.com")

        call_args = adapter._client.get.call_args
        filt = call_args[1]["params"]["$filter"]
        assert "alice@contoso.com" in filt
        assert "receivedDateTime ge" in filt

    @pytest.mark.asyncio
    async def test_domain_filter_on_whatsnew(self):
        adapter = _make_adapter()
        adapter._client.get = AsyncMock(
            return_value=_mock_response(LIST_RESPONSE_EMPTY)
        )
        await adapter.whatsnew(since="2026-01-01T00:00:00Z", domain="contoso.com")

        call_args = adapter._client.get.call_args
        filt = call_args[1]["params"]["$filter"]
        assert "contoso.com" in filt
        assert "receivedDateTime ge" in filt

    @pytest.mark.asyncio
    async def test_sender_filter_on_list(self):
        adapter = _make_adapter()
        adapter._client.get = AsyncMock(
            return_value=_mock_response(LIST_RESPONSE_EMPTY)
        )
        await adapter.list_messages(sender="alice@contoso.com")

        call_args = adapter._client.get.call_args
        filt = call_args[1]["params"]["$filter"]
        assert "alice@contoso.com" in filt

    @pytest.mark.asyncio
    async def test_domain_filter_on_list(self):
        adapter = _make_adapter()
        adapter._client.get = AsyncMock(
            return_value=_mock_response(LIST_RESPONSE_EMPTY)
        )
        await adapter.list_messages(domain="contoso.com")

        call_args = adapter._client.get.call_args
        filt = call_args[1]["params"]["$filter"]
        assert "contoso.com" in filt

    @pytest.mark.asyncio
    async def test_sender_filter_with_query(self):
        """Sender filter + free-text query should both be present."""
        adapter = _make_adapter()
        adapter._client.get = AsyncMock(
            return_value=_mock_response(LIST_RESPONSE_EMPTY)
        )
        await adapter.list_messages(query="budget", sender="alice@contoso.com")

        call_args = adapter._client.get.call_args
        params = call_args[1]["params"]
        assert "$search" in params
        assert "$filter" in params
        assert "alice@contoso.com" in params["$filter"]
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_o365_adapter.py::TestO365AdapterSenderFilter -v`
Expected: FAIL — `sender`/`domain` kwargs not accepted

**Step 3: Update base adapter interface**

In `src/ts4k/adapters/base.py`, add `sender` and `domain` to both `whatsnew` and `list_messages`:

```python
@abstractmethod
async def whatsnew(
    self,
    since: str | None = None,
    sender: str | None = None,
    domain: str | None = None,
) -> list[dict]:
    """Return new activity since *since* (ISO-8601 timestamp).

    If *since* is ``None``, the adapter should fall back to its own
    stored watermark or a sensible default (e.g. last 24 h).

    Optional *sender* filters to a specific email address.
    Optional *domain* filters to a specific domain (e.g. ``example.com``).
    """

@abstractmethod
async def list_messages(
    self,
    query: str | None = None,
    count: int = 20,
    page_token: str | None = None,
    sender: str | None = None,
    domain: str | None = None,
) -> list[dict]:
    """Return message-header dicts matching *query*.

    Optional *sender* filters to a specific email address.
    Optional *domain* filters to a specific domain.
    """
```

**Step 4: Implement O365 sender/domain filtering**

In `src/ts4k/adapters/o365.py`, add a helper and update both methods:

```python
def _build_sender_filter(sender: str | None, domain: str | None) -> str | None:
    """Build a Graph API $filter clause for sender/domain."""
    if sender:
        return f"from/emailAddress/address eq '{sender}'"
    if domain:
        return f"endsWith(from/emailAddress/address, '@{domain}')"
    return None
```

In `whatsnew`:
```python
async def whatsnew(
    self,
    since: str | None = None,
    sender: str | None = None,
    domain: str | None = None,
) -> list[dict]:
    if not since:
        yesterday = datetime.now(timezone.utc) - timedelta(days=1)
        since = yesterday.strftime("%Y-%m-%dT%H:%M:%SZ")

    filter_parts = [f"receivedDateTime ge {since}"]
    sender_filter = _build_sender_filter(sender, domain)
    if sender_filter:
        filter_parts.append(sender_filter)

    params = {
        "$filter": " and ".join(filter_parts),
        "$select": _LIST_SELECT,
        "$orderby": "receivedDateTime desc",
        "$top": "200",
    }

    data = await self._get(f"{self._base_url()}/messages", params)
    return _list_response_to_dicts(data, self.source_prefix)
```

In `list_messages`:
```python
async def list_messages(
    self,
    query: str | None = None,
    count: int = 20,
    page_token: str | None = None,
    sender: str | None = None,
    domain: str | None = None,
) -> list[dict]:
    params: dict[str, str] = {
        "$select": _LIST_SELECT,
        "$top": str(count),
        "$orderby": "receivedDateTime desc",
    }

    if page_token:
        params["$skip"] = page_token

    # Sender/domain filter via $filter
    sender_filter = _build_sender_filter(sender, domain)
    if sender_filter:
        params["$filter"] = sender_filter

    headers: dict[str, str] | None = None
    if query:
        params["$search"] = f'"{query}"'
        del params["$orderby"]
        headers = {"ConsistencyLevel": "eventual"}

    data = await self._get(
        f"{self._base_url()}/messages", params, headers=headers
    )
    results = _list_response_to_dicts(data, self.source_prefix)

    if query:
        results.sort(key=lambda m: m.get("date", ""), reverse=True)

    if results and len(results) == count:
        next_offset = int(page_token or 0) + len(results)
        results[-1]["_next_page_token"] = str(next_offset)

    return results
```

**Step 5: Update WhatsApp adapter signature**

The WhatsApp adapter must also accept the new kwargs (even if it ignores them). Check `src/ts4k/adapters/whatsapp.py` and add `sender: str | None = None, domain: str | None = None` to both `whatsnew` and `list_messages` signatures. The WhatsApp adapter can ignore these params for now (no email addresses).

**Step 6: Run tests to verify they pass**

Run: `uv run pytest tests/test_o365_adapter.py -v`
Expected: ALL PASS

**Step 7: Commit**

```bash
git add src/ts4k/adapters/base.py src/ts4k/adapters/o365.py src/ts4k/adapters/whatsapp.py tests/test_o365_adapter.py
git commit -m "Add sender/domain filtering to adapter interface and O365 implementation"
```

---

### Task 3: Add sender/domain filtering to Gmail adapter

**Files:**
- Modify: `src/ts4k/adapters/gmail.py`
- Test: `tests/test_gmail_adapter.py` (if exists) or add to `tests/test_o365_adapter.py` as a note

**Step 1: Write failing test**

Check if `tests/test_gmail_adapter.py` exists. If so, add tests there. If not, this is a signature-only change that composes query strings — test at the command layer (Task 5).

Gmail adapter's `whatsnew` calls `list_messages` with a query string. The implementation is simple — append `from:` to the query:

**Step 2: Implement**

In `src/ts4k/adapters/gmail.py`, update signatures:

```python
async def whatsnew(
    self,
    since: str | None = None,
    sender: str | None = None,
    domain: str | None = None,
) -> list[dict]:
    if since:
        query = f"after:{since}"
    else:
        query = "newer_than:1d"
    return await self.list_messages(
        query=query, sender=sender, domain=domain,
    )

async def list_messages(
    self,
    query: str | None = None,
    count: int = 20,
    page_token: str | None = None,
    sender: str | None = None,
    domain: str | None = None,
) -> list[dict]:
    # Build query with sender/domain
    parts = []
    if query:
        parts.append(query)
    if sender:
        parts.append(f"from:{sender}")
    elif domain:
        parts.append(f"from:@{domain}")

    effective_query = " ".join(parts) if parts else "in:inbox"

    # ... rest of method uses effective_query instead of `query or "in:inbox"`
```

The key change is replacing `query or "in:inbox"` in the list_args dict (around line 343) with `effective_query`.

**Step 3: Run full test suite**

Run: `uv run pytest tests/ -v`
Expected: ALL PASS (no regressions)

**Step 4: Commit**

```bash
git add src/ts4k/adapters/gmail.py
git commit -m "Add sender/domain filtering to Gmail adapter via query string"
```

---

### Task 4: Thread `--from`/`--domain` through commands layer

**Files:**
- Modify: `src/ts4k/commands.py:308-449` (`_fetch_for_source`, `_fetch_messages`, `updates`, `list_messages`)
- Test: `tests/test_commands.py`

**Step 1: Update `_fetch_for_source`**

Add `sender` and `domain` params:

```python
async def _fetch_for_source(
    prefix: str, cfg: dict[str, Any], since: str | None, count: int,
    sender: str | None = None, domain: str | None = None,
) -> list[dict]:
    adapter = _make_adapter(prefix, cfg)
    if adapter is None:
        return []

    provider = cfg.get("provider", "").lower()
    try:
        async with adapter:
            if provider == "gmail":
                query = _utc_to_gmail_query(since)
                listing = await adapter.list_messages(
                    query=query, count=count,
                    sender=sender, domain=domain,
                )
            else:
                listing = await adapter.whatsnew(
                    since=since, sender=sender, domain=domain,
                )
            # ... rest unchanged
```

**Step 2: Update `_fetch_messages`**

Add `sender` and `domain` params, pass through to `_fetch_for_source`:

```python
async def _fetch_messages(
    since: dict[str, str | None],
    count: int = 20,
    source: str | None = None,
    fmt: str = "pipe",
    filter: bool = False,
    ref_table: RefTable | None = None,
    stat_cmd: str = "wn",
    sender: str | None = None,
    domain: str | None = None,
) -> CommandResult:
    # ... in the task creation loop:
    tasks.append(
        asyncio.create_task(
            _fetch_for_source(
                prefix, cfg, since[prefix], count,
                sender=sender, domain=domain,
            )
        )
    )
    # ... rest unchanged
```

**Step 3: Update `updates` command**

```python
async def updates(
    source: str | None = None,
    since: str | None = None,
    count: int = 20,
    fmt: str = "pipe",
    filter: bool = False,
    ref_table: RefTable | None = None,
    sender: str | None = None,
    domain: str | None = None,
) -> CommandResult:
    active_prefixes = _resolve_prefixes(source)
    resolved_ts = _resolve_since_to_utc(since)
    since_map = {p: resolved_ts for p in active_prefixes}
    return await _fetch_messages(
        since=since_map,
        count=count,
        source=source,
        fmt=fmt,
        filter=filter,
        ref_table=ref_table,
        stat_cmd="u",
        sender=sender,
        domain=domain,
    )
```

**Step 4: Update `list_messages` command**

```python
async def list_messages(
    source: str | None = None,
    query: str | None = None,
    count: int = 20,
    fmt: str = "pipe",
    filter: bool = False,
    ref_table: RefTable | None = None,
    sender: str | None = None,
    domain: str | None = None,
) -> CommandResult:
    active_prefixes = _resolve_prefixes(source)
    all_cfg = _ensure_sources()

    all_messages: list[dict] = []

    for prefix in active_prefixes:
        cfg = all_cfg.get(prefix)
        if not cfg:
            continue
        adapter = _make_adapter(prefix, cfg)
        if adapter is None:
            continue

        try:
            async with adapter:
                listing = await adapter.list_messages(
                    query=query, count=count,
                    sender=sender, domain=domain,
                )
                # ... rest unchanged
```

**Step 5: Run tests**

Run: `uv run pytest tests/ -v`
Expected: ALL PASS

**Step 6: Commit**

```bash
git add src/ts4k/commands.py
git commit -m "Thread --from/--domain through command layer to adapters"
```

---

### Task 5: Add CLI `--from`/`--domain` flags and wire to commands

**Files:**
- Modify: `src/ts4k/cli.py:90-109` (`_cmd_updates`), `src/ts4k/cli.py:177-193` (`_cmd_list`)
- Modify: `src/ts4k/cli.py:700-719` (updates argparse), `src/ts4k/cli.py:778-795` (list argparse)

**Step 1: Add argparse flags to `updates` parser (around line 718)**

After `up.add_argument("--key", "-k", ...)`:
```python
up.add_argument("--from", dest="sender", help="Filter by sender email address")
up.add_argument("--domain", help="Filter by sender domain (e.g. example.com)")
```

**Step 2: Add argparse flags to `list` parser (around line 793)**

After `ls.add_argument("--source", "-s", ...)`:
```python
ls.add_argument("--from", dest="sender", help="Filter by sender email address")
ls.add_argument("--domain", help="Filter by sender domain (e.g. example.com)")
```

**Step 3: Update `_cmd_updates` (line 90)**

```python
async def _cmd_updates(args: argparse.Namespace) -> None:
    key = getattr(args, "key", None)
    refs = _new_ref_table()
    result = await commands.updates(
        source=getattr(args, "source", None),
        since=getattr(args, "since", None),
        count=getattr(args, "count", 20) or 20,
        fmt=getattr(args, "format", "pipe") or "pipe",
        filter=getattr(args, "filter", False),
        ref_table=refs,
        sender=getattr(args, "sender", None),
        domain=getattr(args, "domain", None),
    )
    # ... rest unchanged
```

**Step 4: Update `_cmd_list` (line 177)**

```python
async def _cmd_list(args: argparse.Namespace) -> None:
    refs = _new_ref_table()
    result = await commands.list_messages(
        source=getattr(args, "source", None),
        query=getattr(args, "query", None),
        count=getattr(args, "count", 20) or 20,
        fmt=getattr(args, "format", "pipe") or "pipe",
        filter=getattr(args, "filter", False),
        ref_table=refs,
        sender=getattr(args, "sender", None),
        domain=getattr(args, "domain", None),
    )
    # ... rest unchanged
```

**Step 5: Update epilog examples**

Add examples to the updates and list parser epilogs:
- `ts4k u --from alice@example.com --since 1w`
- `ts4k l --domain example.com -n 50`

**Step 6: Run tests + smoke test**

Run: `uv run pytest tests/ -v`
Run: `uv run ts4k updates --help` (verify --from and --domain appear)
Run: `uv run ts4k list --help` (verify --from and --domain appear)
Expected: ALL PASS, help shows new flags

**Step 7: Commit**

```bash
git add src/ts4k/cli.py
git commit -m "Add --from/--domain CLI flags to updates and list commands"
```

---

### Task 6: Continuation hint with actionable command

**Files:**
- Modify: `src/ts4k/commands.py:344-421` (`_fetch_messages`)
- Modify: `src/ts4k/cli.py:90-109` (`_cmd_updates`), `src/ts4k/cli.py:177-193` (`_cmd_list`)

**Step 1: Store original args in CommandResult for hint construction**

Add to `CommandResult` dataclass:
```python
_continuation_hint: str = ""  # internal: actionable "get more" command
```

**Step 2: Build continuation hint in `_fetch_messages`**

The hint needs the oldest message's date and the original flags. Add params to `_fetch_messages` to receive the original CLI args needed for hint construction:

```python
async def _fetch_messages(
    since: dict[str, str | None],
    count: int = 20,
    source: str | None = None,
    fmt: str = "pipe",
    filter: bool = False,
    ref_table: RefTable | None = None,
    stat_cmd: str = "wn",
    sender: str | None = None,
    domain: str | None = None,
) -> CommandResult:
    # ... existing code through truncation ...

    if has_more:
        output += f"\n--- {remaining} more messages available ---"

    # Build continuation hint
    continuation_hint = ""
    if has_more and truncated:
        oldest_date = truncated[-1].get("date", "")[:10]  # YYYY-MM-DD
        if oldest_date:
            cmd = stat_cmd == "u" and "updates" or "whatsnew"
            parts = [f"ts4k {cmd}"]
            if source and source != "all":
                parts.append(f"--source {source}")
            parts.append(f"--since {oldest_date}")
            parts.append(f"-n {count}")
            if sender:
                parts.append(f"--from {sender}")
            if domain:
                parts.append(f"--domain {domain}")
            continuation_hint = " ".join(parts)

    _record_stats(stat_cmd, truncated, output)

    return CommandResult(
        output=output,
        messages_processed=len(truncated),
        ref_map=ref_map,
        has_more=has_more,
        remaining=remaining,
        _messages=truncated,
        _continuation_hint=continuation_hint,
    )
```

**Step 3: Print continuation hint in CLI handlers**

In `_cmd_updates` and `_cmd_list`, after printing the ref hint, also print the continuation hint if present:

```python
# In _cmd_updates, after the "→ ts4k get N" line:
if result._continuation_hint:
    print(f"→ {result._continuation_hint}  (older messages)")
```

```python
# In _cmd_list, after the "→ ts4k get N" line:
if result.has_more and result._messages:
    oldest_date = result._messages[-1].get("date", "")[:10]
    if oldest_date:
        parts = ["ts4k list"]
        q = getattr(args, "query", None)
        if q:
            parts.append(f'-q "{q}"')
        s = getattr(args, "source", None)
        if s and s != "all":
            parts.append(f"--source {s}")
        sender = getattr(args, "sender", None)
        if sender:
            parts.append(f"--from {sender}")
        domain = getattr(args, "domain", None)
        if domain:
            parts.append(f"--domain {domain}")
        parts.append(f"-n {getattr(args, 'count', 20)}")
        # For list, hint would use a different approach since list is search-based
        # The agent can add date constraints to narrow results
        print(f"→ {' '.join(parts)}  (refine with date or query)")
```

**Step 4: Run tests**

Run: `uv run pytest tests/ -v`
Expected: ALL PASS

**Step 5: Commit**

```bash
git add src/ts4k/commands.py src/ts4k/cli.py
git commit -m "Add continuation hints to updates and list output"
```

---

### Task 7: Update skill reference output

**Files:**
- Modify: `src/ts4k/commands.py:1717-1755` (`skill_reference`)

**Step 1: Update the basic skill reference**

Change the `updates` and `list` lines to include new flags:

```python
return (
    "ts4k — token-efficient messaging gateway\n"
    "updates [--source S] [--since T] [-n N] [-k KEY] [--from ADDR] [--domain D]|Fetch messages by time (stateless)\n"
    "whatsnew KEY [-n N] [--source S]|New msgs since last check (all sources unless --source)\n"
    "list [-q Q] [--source S] [-n N] [--from ADDR] [--domain D]|Search messages\n"
    "get [-k KEY] ID|Read message (e.g. get g:abc123 or get -k life 7)\n"
    "thread [-k KEY] TID|Read thread (e.g. thread g:abc123)\n"
    "overview [--source S] [--contact C] [--period P]|Cache summary\n"
    "status|Health + stats\n"
    "Keys: user-defined labels (life, work). Each key tracks its own read position.\n"
    "  whatsnew life → shows unseen msgs, advances watermark. Next call shows only newer.\n"
    "  whatsnew life -n 50 → all sources, one call. --source S narrows to one source.\n"
    "Refs: listings assign numbers (1, 2, ...). Use with get/thread.\n"
    "  updates/list refs are global: get N. whatsnew/updates -k refs are keyed: get -k KEY N.\n"
    "IDs: g:xxx o:xxx w:xxx. --since: 2d, 6h, ISO. -f p|j|x. -F filters.\n"
    "Source is a FLAG (--source g), not a subcommand.\n"
    "Sender/domain: --from alice@co.com or --domain co.com. Works on updates + list.\n"
    "  Find all from a domain: updates --domain co.com --since 2023-01-01 -n 200\n"
    "  Truncated results show a continuation command — copy-paste to get older messages.\n"
    "Do NOT pipe through head/grep/awk. Use built-in flags instead:\n"
    "  Limit results: -n 20 (not | head). Search: list -q \"name\" (not | grep).\n"
    "  One call: whatsnew KEY -n 50 (not per-source calls).\n"
    "More: ts4k skill more | Setup: ts4k skill setup"
)
```

**Step 2: Run tests**

Run: `uv run pytest tests/ -v`
Expected: ALL PASS

**Step 3: Commit**

```bash
git add src/ts4k/commands.py
git commit -m "Update skill reference with --from/--domain guidance"
```

---

### Task 8: Run full test suite and verify

**Step 1: Run all tests**

Run: `uv run pytest tests/ -v`
Expected: ALL PASS

**Step 2: Verify CLI help**

Run: `uv run ts4k updates --help`
Run: `uv run ts4k list --help`
Run: `uv run ts4k skill`
Expected: New flags visible in help and skill output

**Step 3: Final commit if any fixups needed**

```bash
git add -A
git commit -m "Fix O365 search and add sender/domain filtering (closes #15)"
```
