# Ref Affordances Implementation Plan

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

**Goal:** Make LLM agents use the right `get` command after listing messages, by adding inline usage hints and optional keyed refs for `updates`.

**Architecture:** Three changes to the CLI layer only. (1) Listing commands append a footer hint showing the exact `get` command to use. (2) `updates` gains an optional `--key/-k` flag to save refs under a key. (3) `_cmd_get` error message suggests the right ref table when lookup fails. All changes are in `cli.py` with help text updates in `commands.py`.

**Tech Stack:** Python, argparse, existing RefTable

---

### Task 1: Footer hint after `_cmd_updates` output

**Files:**
- Modify: `src/ts4k/cli.py:66-80`
- Test: `tests/test_ref_affordances.py` (create)

**Step 1: Write the failing test**

```python
"""Tests for ref affordance hints — inline usage guidance in listing output."""

from __future__ import annotations

import json

import pytest

from ts4k import commands
from ts4k.state.refs import RefTable


@pytest.fixture(autouse=True)
def mock_env(tmp_path, monkeypatch):
    monkeypatch.setenv("TS4K_CONFIG_DIR", str(tmp_path))

    from ts4k.state import sources, stats, cache, keyed_watermarks as kwm

    monkeypatch.setattr(sources, "_CONFIG_DIR", tmp_path)
    monkeypatch.setattr(sources, "_SOURCES_FILE", tmp_path / "sources.json")
    monkeypatch.setattr(stats, "_CONFIG_DIR", tmp_path)
    monkeypatch.setattr(stats, "_STATS_FILE", tmp_path / "stats.json")
    monkeypatch.setattr(cache, "_CONFIG_DIR", tmp_path)
    monkeypatch.setattr(kwm, "_CONFIG_DIR", tmp_path)

    cfg = {"g": {"provider": "gmail", "email": "t@t.com"}}
    (tmp_path / "sources.json").write_text(json.dumps(cfg))
    return tmp_path


def _fake_messages(prefix, count, base_hour=10):
    return [
        {
            "id": f"{prefix}:msg{i}",
            "source": prefix,
            "thread_id": f"{prefix}:t{i}",
            "from": f"s{i}@test.com",
            "subject": f"Subj {i}",
            "date": f"2026-03-08T{base_hour + i:02d}:00:00Z",
            "body": f"Body {i}",
        }
        for i in range(count)
    ]


class TestUpdatesHint:
    @pytest.mark.asyncio
    async def test_updates_no_key_shows_get_hint(self, monkeypatch):
        """updates output ends with 'ts4k get N' hint (no key)."""
        async def fake_fetch(prefix, cfg, since, count):
            return _fake_messages(prefix, 3)

        monkeypatch.setattr(commands, "_fetch_for_source", fake_fetch)

        result = await commands.updates(since="1d", count=20)
        assert result.output.rstrip().endswith("→ ts4k get N to read message N")
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_ref_affordances.py::TestUpdatesHint::test_updates_no_key_shows_get_hint -v`
Expected: FAIL — no hint in output yet

**Step 3: Add hint_suffix field to CommandResult and append it in _fetch_messages**

In `src/ts4k/commands.py`, add `hint_suffix` field to `CommandResult`:

```python
@dataclass
class CommandResult:
    """Return type for commands that process messages."""

    output: str = ""
    messages_processed: int = 0
    error: str = ""
    ref_map: dict | None = None
    has_more: bool = False
    remaining: int = 0
    _messages: list[dict] = field(default_factory=list, repr=False)
    hint_suffix: str = ""  # NEW
```

In `_fetch_messages` (around line 406-411), after building output, set `hint_suffix` but don't append it yet — let the caller (CLI) decide. Instead, return it on the result. Actually simpler: just let the CLI handlers append. The `commands` layer doesn't know about keys. So just pass hints through at the CLI layer.

In `src/ts4k/cli.py`, change `_cmd_updates`:

```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,
    )
    if result.error:
        print(result.error)
        return
    refs.save(_refs_path(key))
    print(result.output)
    if key:
        print(f"→ ts4k get -k {key} N to read message N")
    else:
        print("→ ts4k get N to read message N")
```

Wait — the test tests `commands.updates()`, not the CLI handler. The hint needs to be in the output that the CLI prints. But `commands.updates()` is shared by CLI and MCP. The hint is CLI-only.

Revised approach: test at the CLI handler level via capsys, or make the test check that `commands.updates` output does NOT have the hint (it's clean) and test the CLI handler separately.

Simpler: just test the hint as a CLI integration concern. But that's complex. Even simpler: append the hint inside `commands.updates` output directly — MCP callers can strip it if needed, but actually MCP callers call `commands.updates` too, so we don't want it there.

Best approach: append the hint in the CLI handlers only (they already print `result.output`). Test the CLI handlers with a subprocess or by calling them directly. Since the test fixture pattern in this project mocks at `_fetch_for_source`, let's test by calling `_cmd_updates` with a mock args namespace and capturing stdout.

**Revised Step 1: Write the failing test**

```python
class TestUpdatesHint:
    @pytest.mark.asyncio
    async def test_updates_no_key_shows_get_hint(self, monkeypatch, capsys):
        """updates output ends with 'ts4k get N' hint (no key)."""
        import argparse
        from ts4k.cli import _cmd_updates

        async def fake_fetch(prefix, cfg, since, count):
            return _fake_messages(prefix, 3)

        monkeypatch.setattr(commands, "_fetch_for_source", fake_fetch)

        args = argparse.Namespace(source=None, since="1d", count=20, format="pipe", filter=False, key=None)
        await _cmd_updates(args)
        out = capsys.readouterr().out
        assert "→ ts4k get N to read message N" in out
```

This won't work yet because `_cmd_updates` doesn't read `args.key`. Let's just do it clean.

**Step 4: Run test to verify it passes**

Run: `uv run pytest tests/test_ref_affordances.py::TestUpdatesHint::test_updates_no_key_shows_get_hint -v`
Expected: PASS

**Step 5: Commit**

```bash
git add tests/test_ref_affordances.py src/ts4k/cli.py
git commit -m "feat: add inline get-hint after updates/whatsnew/list output"
```

---

### Task 2: Footer hint after `_cmd_whatsnew` output

**Files:**
- Modify: `src/ts4k/cli.py:83-98`
- Test: `tests/test_ref_affordances.py`

**Step 1: Write the failing test**

```python
class TestWhatsnewHint:
    @pytest.mark.asyncio
    async def test_whatsnew_shows_keyed_get_hint(self, monkeypatch, capsys, tmp_path):
        """whatsnew output ends with 'ts4k get -k KEY N' hint."""
        import argparse
        from ts4k.cli import _cmd_whatsnew

        async def fake_fetch(prefix, cfg, since, count):
            return _fake_messages(prefix, 3)

        monkeypatch.setattr(commands, "_fetch_for_source", fake_fetch)

        args = argparse.Namespace(key="life", source=None, count=20, format="pipe", filter=False)
        await _cmd_whatsnew(args)
        out = capsys.readouterr().out
        assert "→ ts4k get -k life N to read message N" in out
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_ref_affordances.py::TestWhatsnewHint::test_whatsnew_shows_keyed_get_hint -v`
Expected: FAIL

**Step 3: Add hint to `_cmd_whatsnew`**

In `src/ts4k/cli.py`, after `print(result.output)` in `_cmd_whatsnew`:

```python
async def _cmd_whatsnew(args: argparse.Namespace) -> None:
    refs = RefTable()
    refs.load(_refs_path(args.key))
    result = await commands.whatsnew(
        key=args.key,
        source=getattr(args, "source", None),
        count=getattr(args, "count", 20) or 20,
        fmt=getattr(args, "format", "pipe") or "pipe",
        filter=getattr(args, "filter", False),
        ref_table=refs,
    )
    if result.error:
        print(result.error)
        return
    refs.save(_refs_path(args.key))
    print(result.output)
    print(f"→ ts4k get -k {args.key} N to read message N")
```

**Step 4: Run test to verify it passes**

Run: `uv run pytest tests/test_ref_affordances.py::TestWhatsnewHint -v`
Expected: PASS

**Step 5: Commit**

```bash
git add tests/test_ref_affordances.py src/ts4k/cli.py
git commit -m "feat: add keyed get-hint after whatsnew output"
```

---

### Task 3: Footer hint after `_cmd_list` output

**Files:**
- Modify: `src/ts4k/cli.py:145-159`
- Test: `tests/test_ref_affordances.py`

**Step 1: Write the failing test**

```python
class TestListHint:
    @pytest.mark.asyncio
    async def test_list_shows_get_hint(self, monkeypatch, capsys):
        """list output ends with 'ts4k get N' hint."""
        import argparse
        from ts4k.cli import _cmd_list

        async def fake_fetch(prefix, cfg, since, count):
            return _fake_messages(prefix, 3)

        monkeypatch.setattr(commands, "_fetch_for_source", fake_fetch)

        args = argparse.Namespace(source=None, query=None, count=20, format="pipe", filter=False)
        await _cmd_list(args)
        out = capsys.readouterr().out
        assert "→ ts4k get N to read message N" in out
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_ref_affordances.py::TestListHint -v`
Expected: FAIL

**Step 3: Add hint to `_cmd_list`**

```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,
    )
    if result.error:
        print(result.error)
        return
    refs.save(_refs_path())
    print(result.output)
    print("→ ts4k get N to read message N")
```

**Step 4: Run test to verify it passes**

Run: `uv run pytest tests/test_ref_affordances.py::TestListHint -v`
Expected: PASS

**Step 5: Commit**

```bash
git add tests/test_ref_affordances.py src/ts4k/cli.py
git commit -m "feat: add get-hint after list output"
```

---

### Task 4: Optional `--key/-k` on `updates` command

**Files:**
- Modify: `src/ts4k/cli.py:66-80` (handler) and `src/ts4k/cli.py:666-684` (argparse)
- Test: `tests/test_ref_affordances.py`

**Step 1: Write the failing test**

```python
class TestUpdatesWithKey:
    @pytest.mark.asyncio
    async def test_updates_with_key_saves_keyed_refs(self, monkeypatch, capsys, tmp_path):
        """updates -k saves refs under key and shows keyed hint."""
        import argparse
        from ts4k.cli import _cmd_updates, _refs_path

        async def fake_fetch(prefix, cfg, since, count):
            return _fake_messages(prefix, 3)

        monkeypatch.setattr(commands, "_fetch_for_source", fake_fetch)

        args = argparse.Namespace(source=None, since="1d", count=20, format="pipe", filter=False, key="myquery")
        await _cmd_updates(args)
        out = capsys.readouterr().out
        assert "→ ts4k get -k myquery N to read message N" in out

        # Verify refs saved under key
        from ts4k.state.refs import RefTable
        rt = RefTable()
        rt.load(_refs_path("myquery"))
        assert rt.resolve("1") is not None
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_ref_affordances.py::TestUpdatesWithKey -v`
Expected: FAIL — `_cmd_updates` doesn't read `args.key`

**Step 3: Modify `_cmd_updates` to support optional key**

```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,
    )
    if result.error:
        print(result.error)
        return
    refs.save(_refs_path(key))
    print(result.output)
    if key:
        print(f"→ ts4k get -k {key} N to read message N")
    else:
        print("→ ts4k get N to read message N")
```

Add `--key/-k` to the argparse `updates` subparser (around line 683):

```python
    up.add_argument("--key", "-k", help="Save refs under a key (use with get -k KEY N)")
```

**Step 4: Run test to verify it passes**

Run: `uv run pytest tests/test_ref_affordances.py::TestUpdatesWithKey -v`
Expected: PASS

**Step 5: Commit**

```bash
git add tests/test_ref_affordances.py src/ts4k/cli.py
git commit -m "feat: add optional --key/-k to updates command"
```

---

### Task 5: Better error message in `_cmd_get` when ref lookup fails

**Files:**
- Modify: `src/ts4k/cli.py:101-120`
- Test: `tests/test_ref_affordances.py`

**Step 1: Write the failing test**

```python
class TestGetRefError:
    @pytest.mark.asyncio
    async def test_get_wrong_key_suggests_correct_table(self, monkeypatch, capsys, tmp_path):
        """When ref exists in global but agent uses -k, suggest dropping -k."""
        import argparse
        from ts4k.cli import _cmd_get, _refs_path
        from ts4k.state.refs import RefTable

        # Save ref 1 in global table
        rt = RefTable()
        rt.assign([{"id": "g:abc123"}])
        rt.save(_refs_path(None))

        args = argparse.Namespace(id="1", key="life", format="pipe")
        with pytest.raises(SystemExit):
            await _cmd_get(args)
        out = capsys.readouterr().out
        assert "try: ts4k get 1" in out

    @pytest.mark.asyncio
    async def test_get_no_key_suggests_keyed_tables(self, monkeypatch, capsys, tmp_path):
        """When ref exists in a keyed table but agent uses no key, suggest -k."""
        import argparse
        from ts4k.cli import _cmd_get, _refs_path
        from ts4k.state.refs import RefTable

        # Save ref 1 in 'life' keyed table
        rt = RefTable()
        rt.assign([{"id": "g:abc123"}])
        rt.save(_refs_path("life"))

        args = argparse.Namespace(id="1", key=None, format="pipe")
        with pytest.raises(SystemExit):
            await _cmd_get(args)
        out = capsys.readouterr().out
        assert "-k life" in out
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_ref_affordances.py::TestGetRefError -v`
Expected: FAIL — current error message doesn't check other tables

**Step 3: Improve error message in `_cmd_get`**

```python
async def _cmd_get(args: argparse.Namespace) -> None:
    msg_id = args.id
    key = getattr(args, "key", None)
    if msg_id.lstrip("#").isdigit():
        rt = RefTable()
        rt.load(_refs_path(key))
        resolved = rt.resolve(msg_id)
        if resolved is None:
            label = f"key '{key}'" if key else "global refs"
            hint = _suggest_ref_table(msg_id, key)
            print(f"Ref {msg_id} not found in {label}.{hint}")
            sys.exit(1)
        msg_id = resolved
    result = await commands.get_message(
        id=msg_id,
        fmt=getattr(args, "format", "pipe") or "pipe",
    )
    if result.error:
        print(result.error)
        sys.exit(1)
    print(result.output)
```

Add helper `_suggest_ref_table`:

```python
def _suggest_ref_table(ref: str, current_key: str | None) -> str:
    """Check other ref tables for the ref and suggest the right command."""
    from ts4k import state

    base = state.get_config_dir().path

    # If agent used a key, check global
    if current_key is not None:
        rt = RefTable()
        rt.load(_refs_path(None))
        if rt.resolve(ref) is not None:
            return f" Found in global refs — try: ts4k get {ref}"

    # If agent used global (no key), check keyed tables
    import glob as _glob

    for path in sorted(base.glob("refs-*.json")):
        k = path.stem.removeprefix("refs-")
        rt = RefTable()
        rt.load(path)
        if rt.resolve(ref) is not None:
            if current_key is None:
                return f" Found in key '{k}' — try: ts4k get -k {k} {ref}"
            else:
                return f" Found in key '{k}' — try: ts4k get -k {k} {ref}"

    return " Run 'whatsnew' or 'updates' first."
```

**Step 4: Run test to verify it passes**

Run: `uv run pytest tests/test_ref_affordances.py::TestGetRefError -v`
Expected: PASS

**Step 5: Apply same fix to `_cmd_thread`**

Same pattern for `_cmd_thread` (lines 123-142) — use `_suggest_ref_table` there too.

**Step 6: Commit**

```bash
git add tests/test_ref_affordances.py src/ts4k/cli.py
git commit -m "feat: smarter ref-not-found error suggests correct table"
```

---

### Task 6: Update help text and skill reference

**Files:**
- Modify: `src/ts4k/commands.py:1657-1713` (help text)
- Modify: `src/ts4k/commands.py:1716-1754` (skill reference)
- Modify: `src/ts4k/cli.py:666-684` (updates epilog)

**Step 1: Update `_append_commands` to mention `-k` on updates**

In `src/ts4k/commands.py:1660`:

```python
lines.append("  ts4k updates [--source S] [--since T] [-n N] [-k K] Fetch messages by time (stateless)")
```

**Step 2: Update `_append_mistakes` with the new mistake pattern**

Add to `_append_mistakes`:

```python
lines.append("  WRONG: ts4k get -k life 7 (after updates) -> RIGHT: ts4k get 7 (or use updates -k life)")
```

**Step 3: Update `skill_reference` to mention updates -k**

In the skill_reference return string, update the updates line:

```python
"updates [--source S] [--since T] [-n N] [-k KEY]|Fetch messages by time (stateless)\n"
```

And update the Refs explanation:

```python
"Refs: listings assign numbers (1, 2, ...). Use with get/thread.\n"
"  updates refs are global unless -k KEY is used. whatsnew refs are always keyed.\n"
"  Global refs: get N. Keyed refs: get -k KEY N.\n"
```

**Step 4: Update updates epilog in argparse**

```python
epilog=(
    "examples:\n"
    "  ts4k updates --since 2d          # last 2 days, all sources\n"
    "  ts4k u --since 6h -s g           # last 6 hours, Gmail only\n"
    "  ts4k u --since 2025-01-01 -n 50  # since date, up to 50 msgs\n"
    "  ts4k u --since 1w -k research    # save refs under 'research' key\n"
    "  ts4k u --since 1w -f json        # last week, JSON output"
),
```

**Step 5: Update get epilog to show global ref example**

```python
epilog=(
    "examples:\n"
    "  ts4k get 3                        # ref #3 from last updates/list\n"
    "  ts4k g 7 -k life                  # ref #7 from 'life' whatsnew\n"
    "  ts4k get g:18f3a2b1c4d5e6f7       # by native Gmail ID\n"
    "  ts4k g 3 -k work -f json          # ref #3, JSON output"
),
```

**Step 6: Commit**

```bash
git add src/ts4k/commands.py src/ts4k/cli.py
git commit -m "docs: update help text for updates -k and ref affordances"
```

---

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

**Step 1: Run all tests**

Run: `uv run pytest -v`
Expected: All pass

**Step 2: Manual smoke test**

Run: `ts4k h --llm` and verify updated help text.

**Step 3: Final commit if needed**

Fix any issues, commit fixes.
