# Task: Fix watch mode — detect resets when API drops the window

## Bug description

The watch mode (`aichecker --watch --once`) fails to trigger a ping when a 5h window resets. Root cause:

When a 5h window resets (used_pct drops to 0%), the API stops returning `resets_at` for that window (or stops returning the window entirely). The current `collect_5h_windows()` only includes windows that have `resets_at`, so the reset window disappears from `current`. Then `check_resets()` only loops over `current` keys — it never sees the window that was in `state` but is now gone from `current`, so it never detects the reset.

**Reproduction (real data from 2026-06-29):**

State file had:
```
claude_5h: used=23.0%, resets_at=2026-06-29T14:29:59Z
agy_Gemini Models_5h: used=3.8%, resets_at=2026-06-29T17:37:23Z
agy_Claude and GPT models_5h: used=0.0%, resets_at=2026-06-29T20:35:46Z
```

Current API call returned:
```
agy_Gemini Models_5h: used=3.8%, resets_at=2026-06-29T17:37:23Z
agy_Claude and GPT models_5h: used=0.0%, resets_at=2026-06-29T20:38:27Z
(claude_5h is MISSING — API returned used=0% and no resets_at)
```

Result: `claude_5h` was in state (used=23%, should have triggered at 14:29+120s = 14:31Z) but now is past 15:38Z. The window is missing from `current`, so `check_resets()` never checks it. No ping sent.

## The fix

In `watch_5h_resets()` (function `watch_5h_resets` in `src/ai_limit_checker/watch.py`), the reset detection loop currently only iterates over `current`:

```python
for key, _window in current.items():  # BUG: misses windows that disappeared from current
    prev = state.get(key)
    if not prev or prev.get("used_pct", 0) <= 0:
        continue
    ...
```

Fix it to also check `state` keys that are **missing from current** — those are windows that reset (used dropped to 0, API dropped them):

```python
# Check windows in current (normal case: window still exists)
for key, _window in current.items():
    prev = state.get(key)
    if not prev or prev.get("used_pct", 0) <= 0:
        continue
    prev_reset = _parse_iso(prev.get("resets_at"))
    if not prev_reset:
        continue
    if ref >= prev_reset + timedelta(seconds=delay):
        reset_keys.append(key)

# Also check windows in state but MISSING from current
# (window reset to 0% — API dropped it because no resets_at)
for key, prev in state.items():
    if key in current:
        continue  # already checked above
    if not isinstance(prev, dict) or prev.get("used_pct", 0) <= 0:
        continue
    prev_reset = _parse_iso(prev.get("resets_at"))
    if not prev_reset:
        continue
    if ref >= prev_reset + timedelta(seconds=delay):
        reset_keys.append(key)
```

Also apply the same fix to the standalone `check_resets()` function — it has the same bug (only loops over `current`). The fix there: also check state keys missing from current.

```python
def check_resets(
    current: dict[str, dict],
    state: dict[str, dict],
    now: datetime | None = None,
    delay: int = DEFAULT_DELAY,
) -> list[str]:
    ref = now or datetime.now(timezone.utc)
    reset_labels: list[str] = []

    # Windows still in current
    for key, window in current.items():
        prev = state.get(key)
        if not prev or prev.get("used_pct", 0) <= 0:
            continue
        prev_reset = _parse_iso(prev.get("resets_at"))
        if not prev_reset:
            continue
        if ref >= prev_reset + timedelta(seconds=delay):
            reset_labels.append(window["label"])

    # Windows in state but MISSING from current (reset to 0%, API dropped them)
    for key, prev in state.items():
        if key in current:
            continue
        if not isinstance(prev, dict) or prev.get("used_pct", 0) <= 0:
            continue
        prev_reset = _parse_iso(prev.get("resets_at"))
        if not prev_reset:
            continue
        if ref >= prev_reset + timedelta(seconds=delay):
            reset_labels.append(prev.get("label", key))

    return reset_labels
```

For `trigger_pings()`: when a window is missing from `current` (because it reset), we still need to ping. The function currently does `current.get(key)` which returns None for missing keys. Fix: fall back to `state` for the tool/label info:

```python
def trigger_pings(
    current: dict[str, dict],
    reset_keys: list[str],
    dry_run: bool = False,
    state: dict[str, dict] | None = None,  # NEW param
) -> dict[str, str]:
    results: dict[str, str] = {}
    seen_tools: set[str] = set()

    for key in reset_keys:
        window = current.get(key)
        if not window and state:
            window = state.get(key)  # fall back to state for reset windows
        if not window:
            continue
        tool = window.get("tool", "")
        label = window.get("label", key)
        ...
```

And in `watch_5h_resets()`, pass `state` to `trigger_pings`:
```python
ping_results = trigger_pings(current, reset_keys, dry_run=dry_run, state=state)
```

After triggering pings for reset windows, **clean up state** — remove the reset windows from state (they'll be re-added fresh next cycle if the API returns them again):

```python
# After pinging, remove reset windows from state so they don't re-trigger
for key in reset_keys:
    state.pop(key, None)
_save_state(state)
```

Actually, this is already partially handled by `state.update(current)` which only updates keys that exist in current. The reset keys (missing from current) stay in state with stale data. So explicitly remove them after pinging.

## Testing

In `tests/test_watch.py`, add tests:

1. **test_reset_detected_when_window_dropped_from_api** — the main bug: state has a window with used>0 and resets_at in the past, but current does NOT contain that window (API dropped it). Assert `check_resets()` returns the label, and `watch_5h_resets(once=True)` triggers a ping.

2. **test_reset_not_triggered_for_zero_usage** — state has window with used=0, current doesn't have it. Should NOT trigger (never was used, nothing to reset).

3. **test_trigger_pings_falls_back_to_state** — reset_keys contains a key not in current but in state. trigger_pings should still ping using state's tool/label.

4. **test_state_cleaned_after_reset** — after a reset triggers, the reset window should be removed from state (so it doesn't re-trigger next cycle).

Mock `gather()` to return controlled data. Mock `subprocess.run` for ping tests.

## File to edit
- `src/ai_limit_checker/watch.py` — fix `check_resets()`, `trigger_pings()`, `watch_5h_resets()`
- `tests/test_watch.py` — add 4 tests

## Constraints
- Zero external deps (stdlib only)
- Type hints on all functions
- ruff clean (E, F, W, I, UP, B, SIM)
- All tests pass
- Do NOT publish to PyPI
- Do NOT push to main — commit to this branch only
- Do NOT bump version (this is a bug fix for existing v0.10.1; we'll publish v0.10.2 separately)

## Verification
```bash
PYTHONPATH="src" python -m pytest tests/ -q
PYTHONPATH="src" python -m ruff check src/ tests/
PYTHONPATH="src" python -c "from ai_limit_checker.watch import check_resets; print(check_resets({}, {'claude_5h': {'label': 'Claude 5h', 'used_pct': 23, 'resets_at': '2026-06-29T14:29:59Z', 'tool': 'claude'}}, now=__import__('datetime').datetime.fromisoformat('2026-06-29T15:40:00+00:00')))"
# Should print: ['Claude 5h']
```

## Report
1. Files modified
2. Test results
3. ruff results
4. Smoke test output
5. Commit hash