# CritMatic Patch Watcher (Phase 1) 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:** Stand up a local-only macOS bot that polls Wago.tools for new WoW builds, diffs the WoW global-function surface between the new and previous build, greps CritMatic Lua source for at-risk symbols, and posts a macOS notification linking to a structured Markdown report. No code edits, no git ops, no releases.

**Architecture:** Two Python entry points (`bot/detect.py`, `bot/analyze.py`) backed by small focused modules under `bot/lib/`. State and reports live under `bot/state/` and `bot/reports/`. A launchd plist (`bot/plist/com.critmatic.bot.plist`) drives `detect.py` every 4 hours. The analyzer's "API surface" source is Townlong-Yak's `wow-ui-source` GitHub repo (function names extracted from raw Lua via regex) which is stable, public, and addressable by tag. Wago.tools is used only for build detection; this avoids depending on undocumented Wago dump endpoints.

**Tech Stack:** Python 3 stdlib only (`urllib.request`, `json`, `re`, `subprocess`, `pathlib`, `unittest`). macOS `osascript` for notifications. `launchd` for scheduling. Optional `terminal-notifier` if already on PATH.

**Spec deviations called out** (vs `docs/superpowers/specs/2026-05-06-critmatic-patch-watcher-design.md`):

1. **API surface source.** Spec mentioned Wago dump endpoints as primary with Townlong-Yak as fallback. Plan uses Townlong-Yak as primary because Wago's per-build dump endpoints are not documented and would force reverse-engineering. Townlong-Yak is the canonical, stable source addon devs already use. The detector still uses Wago for build feed (which is documented and stable).
2. **Symbol set scope.** Phase 1 analyzes **global functions only** (regex over `function FuncName(` and `FuncName = function(`). Events, CVars, and `C_*` namespaced tables are deferred to Phase 1.5 because they need bespoke extractors. Spec listed these as informational; this plan makes the limitation explicit.
3. **`signature_changed` set.** Deferred to Phase 1.5. Phase 1 produces `removed` (high signal) and `renamed` (heuristic). Signature parsing of Lua is non-trivial and the noise/value tradeoff is poor for v1.
4. **Notifier is Python, not shell.** Spec named `bot/notify.sh`. Plan implements `bot/lib/notify.py` instead because it is unit-testable end to end (we can stub `subprocess.run`) and avoids quoting bugs around AppleScript escaping. Behavior is identical: `osascript` with optional `terminal-notifier` if on PATH.

The user approved Phase 1 in principle. These deviations keep Phase 1 shippable in one sitting; if the user objects to any, we adjust before Task 1.

---

## File Structure

**New files (under `bot/`):**

| Path | Responsibility |
|------|----------------|
| `bot/lib/__init__.py` | Package marker, empty. |
| `bot/lib/http.py` | `fetch_json(url)` and `fetch_text(url)` helpers with timeout + 1 retry. Pure stdlib. |
| `bot/lib/state.py` | Read/write `last-seen-builds.json`, kill-switch check, failure counter. |
| `bot/lib/wago.py` | `fetch_builds()` calling Wago's build feed, returns list of `Build` records. |
| `bot/lib/townlong.py` | `fetch_global_functions(tag)` returning a `set[str]` of WoW global function names from the Townlong-Yak `wow-ui-source` GitHub repo at a given tag. |
| `bot/lib/diff.py` | `compute_diff(old, new) -> Diff` producing `removed`, `renamed`, `added` sets. |
| `bot/lib/grep.py` | `find_call_sites(symbols, repo_root) -> list[CallSite]` using stdlib `re` over the addon's Lua files. |
| `bot/lib/report.py` | `render(report_data) -> str` producing Markdown. |
| `bot/lib/notify.py` | `notify(title, subtitle, body, open_path=None)` using `osascript`, optional `terminal-notifier`. |
| `bot/detect.py` | Entry point called by launchd: load state, fetch builds, fan out to analyzer, update state. |
| `bot/analyze.py` | Entry point: given branch + new build + previous build, produce report and notify. |
| `bot/state/.gitkeep` | Keeps directory in git. |
| `bot/state/.gitignore` | Excludes runtime files (`*.log`, `*.json`, `failure-count.txt`, `disabled`). |
| `bot/reports/.gitkeep` | Keeps directory in git. |
| `bot/reports/.gitignore` | Excludes `*.md`. |
| `bot/plist/com.critmatic.bot.plist` | launchd job template. |
| `bot/README.md` | Install, uninstall, pause, resume, where reports live, how to read a report. |
| `bot/tests/__init__.py` | Empty. |
| `bot/tests/test_state.py` | Unit tests for `lib/state.py`. |
| `bot/tests/test_wago.py` | Unit tests for `lib/wago.py` (using local fixture). |
| `bot/tests/test_townlong.py` | Unit tests for `lib/townlong.py` (using local fixture). |
| `bot/tests/test_diff.py` | Unit tests for `lib/diff.py`. |
| `bot/tests/test_grep.py` | Unit tests for `lib/grep.py`. |
| `bot/tests/test_report.py` | Unit tests for `lib/report.py`. |
| `bot/tests/test_notify.py` | Unit tests for `lib/notify.py`. |
| `bot/tests/fixtures/wago-builds.json` | Captured real Wago response, frozen for tests. |
| `bot/tests/fixtures/townlong-old.lua` | Tiny Lua file with 3 functions for diff tests. |
| `bot/tests/fixtures/townlong-new.lua` | Same with one removed, one renamed, one added. |

**Modified files:**

- `.gitignore`: append `bot/state/launchd.log` (the rest is handled by per-directory `.gitignore` files).

---

## Task 1: Project skeleton

**Files:**
- Create: `bot/lib/__init__.py`, `bot/tests/__init__.py`, `bot/state/.gitkeep`, `bot/state/.gitignore`, `bot/reports/.gitkeep`, `bot/reports/.gitignore`
- Modify: `.gitignore`

- [ ] **Step 1: Create empty package markers and gitkeeps**

```bash
mkdir -p bot/lib bot/tests/fixtures bot/state bot/reports bot/plist
touch bot/lib/__init__.py bot/tests/__init__.py bot/state/.gitkeep bot/reports/.gitkeep
```

- [ ] **Step 2: Write `bot/state/.gitignore`**

Create `bot/state/.gitignore`:

```gitignore
# Runtime state, never committed.
*.log
*.json
failure-count.txt
disabled
```

- [ ] **Step 3: Write `bot/reports/.gitignore`**

Create `bot/reports/.gitignore`:

```gitignore
# Generated patch reports, local-only by default.
# Force-add a specific report with `git add -f`.
*.md
```

- [ ] **Step 4: Append to repo `.gitignore`**

Append a single line to `.gitignore`:

```gitignore

# CritMatic patch watcher
bot/state/launchd.log
```

- [ ] **Step 5: Verify**

Run: `git status --short`
Expected output includes the new files and the modified `.gitignore`. No `bot/state/launchd.log`-style entries should be tracked.

- [ ] **Step 6: Commit**

```bash
git add bot/lib/__init__.py bot/tests/__init__.py bot/state/.gitkeep bot/state/.gitignore bot/reports/.gitkeep bot/reports/.gitignore .gitignore
git commit -m "chore: scaffold bot/ for patch watcher Phase 1"
```

---

## Task 2: HTTP helper

**Files:**
- Create: `bot/lib/http.py`, `bot/tests/test_http.py`

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

Create `bot/tests/test_http.py`:

```python
import json
import unittest
from unittest.mock import patch, MagicMock
from bot.lib import http


class TestFetchJson(unittest.TestCase):
    def test_returns_parsed_json_on_success(self):
        fake_resp = MagicMock()
        fake_resp.read.return_value = b'{"hello": "world"}'
        fake_resp.__enter__ = lambda s: s
        fake_resp.__exit__ = lambda *a: None
        with patch("urllib.request.urlopen", return_value=fake_resp):
            self.assertEqual(http.fetch_json("https://example/x"), {"hello": "world"})

    def test_retries_once_then_raises(self):
        with patch("urllib.request.urlopen", side_effect=OSError("boom")) as m:
            with patch("time.sleep") as sleep_mock:
                with self.assertRaises(http.HTTPError):
                    http.fetch_json("https://example/x")
                self.assertEqual(m.call_count, 2)
                sleep_mock.assert_called_once_with(http.RETRY_DELAY_SECONDS)


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_http -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'bot.lib.http'`.

- [ ] **Step 3: Write minimal implementation**

Create `bot/lib/http.py`:

```python
"""Tiny HTTP helpers around urllib.request: timeout + one retry, JSON or text."""
import json
import time
import urllib.request

TIMEOUT_SECONDS = 30
RETRY_DELAY_SECONDS = 5


class HTTPError(Exception):
    pass


def _fetch_bytes(url: str) -> bytes:
    last_exc: Exception | None = None
    for attempt in (1, 2):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "critmatic-patch-watcher/0.1"})
            with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as resp:
                return resp.read()
        except Exception as exc:
            last_exc = exc
            if attempt == 1:
                time.sleep(RETRY_DELAY_SECONDS)
    raise HTTPError(f"failed to fetch {url}: {last_exc}") from last_exc


def fetch_json(url: str):
    return json.loads(_fetch_bytes(url).decode("utf-8"))


def fetch_text(url: str) -> str:
    return _fetch_bytes(url).decode("utf-8")
```

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

Run: `python3 -m unittest bot.tests.test_http -v`
Expected: 2 tests pass.

- [ ] **Step 5: Commit**

```bash
git add bot/lib/http.py bot/tests/test_http.py
git commit -m "feat(bot): add stdlib HTTP helper with timeout and retry"
```

---

## Task 3: State module

**Files:**
- Create: `bot/lib/state.py`, `bot/tests/test_state.py`

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

Create `bot/tests/test_state.py`:

```python
import json
import tempfile
import unittest
from pathlib import Path
from bot.lib import state


class TestState(unittest.TestCase):
    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())

    def test_load_returns_empty_when_missing(self):
        self.assertEqual(state.load_last_seen(self.tmp / "missing.json"), {})

    def test_save_then_load_roundtrip(self):
        path = self.tmp / "s.json"
        data = {"wow": {"build": 110207, "version": "11.2.7", "seen_at": "2026-05-06T00:00:00Z"}}
        state.save_last_seen(path, data)
        self.assertEqual(state.load_last_seen(path), data)

    def test_save_is_atomic_via_tmp_then_replace(self):
        path = self.tmp / "s.json"
        state.save_last_seen(path, {"wow": {"build": 1, "version": "x", "seen_at": "y"}})
        self.assertTrue(path.exists())
        self.assertFalse((self.tmp / "s.json.tmp").exists())

    def test_kill_switch_present(self):
        (self.tmp / "disabled").touch()
        self.assertTrue(state.is_disabled(self.tmp))

    def test_kill_switch_absent(self):
        self.assertFalse(state.is_disabled(self.tmp))

    def test_failure_counter_increment_and_reset(self):
        state.bump_failure(self.tmp)
        state.bump_failure(self.tmp)
        self.assertEqual(state.read_failure_count(self.tmp), 2)
        state.reset_failures(self.tmp)
        self.assertEqual(state.read_failure_count(self.tmp), 0)


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_state -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'bot.lib.state'`.

- [ ] **Step 3: Write minimal implementation**

Create `bot/lib/state.py`:

```python
"""Persistent state for the patch watcher: last-seen builds, kill switch, failure counter."""
import json
import os
from pathlib import Path

DISABLED_FILENAME = "disabled"
FAILURE_COUNT_FILENAME = "failure-count.txt"


def load_last_seen(path: Path) -> dict:
    if not path.exists():
        return {}
    return json.loads(path.read_text(encoding="utf-8"))


def save_last_seen(path: Path, data: dict) -> None:
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
    os.replace(tmp, path)


def is_disabled(state_dir: Path) -> bool:
    return (state_dir / DISABLED_FILENAME).exists()


def read_failure_count(state_dir: Path) -> int:
    f = state_dir / FAILURE_COUNT_FILENAME
    if not f.exists():
        return 0
    try:
        return int(f.read_text(encoding="utf-8").strip() or "0")
    except ValueError:
        return 0


def bump_failure(state_dir: Path) -> int:
    n = read_failure_count(state_dir) + 1
    (state_dir / FAILURE_COUNT_FILENAME).write_text(str(n), encoding="utf-8")
    return n


def reset_failures(state_dir: Path) -> None:
    f = state_dir / FAILURE_COUNT_FILENAME
    if f.exists():
        f.unlink()
```

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

Run: `python3 -m unittest bot.tests.test_state -v`
Expected: 6 tests pass.

- [ ] **Step 5: Commit**

```bash
git add bot/lib/state.py bot/tests/test_state.py
git commit -m "feat(bot): add state module (last-seen builds, kill switch, failure counter)"
```

---

## Task 4: Wago build feed

**Files:**
- Create: `bot/lib/wago.py`, `bot/tests/test_wago.py`, `bot/tests/fixtures/wago-builds.json`

- [ ] **Step 1: Capture a real Wago response as fixture**

Run:

```bash
python3 -c "import urllib.request,json,sys; \
data=urllib.request.urlopen('https://wago.tools/api/builds', timeout=30).read(); \
sys.stdout.buffer.write(data)" > bot/tests/fixtures/wago-builds.json
head -c 500 bot/tests/fixtures/wago-builds.json
```

Expected: a JSON document begins. The shape will be confirmed in Step 2; Wago's response is roughly a map keyed by product (e.g., `"wow"`, `"wowt"`, `"wow_classic_era"`) with values that are arrays of build records like `{"version": "11.2.7.59185", "created_at": "..."}`.

If the shape returned is not a product-keyed map, adjust the parser in Step 3 accordingly, but the test in Step 2 should still load this exact fixture.

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

Create `bot/tests/test_wago.py`:

```python
import json
import unittest
from pathlib import Path
from unittest.mock import patch
from bot.lib import wago

FIXTURE = Path(__file__).parent / "fixtures" / "wago-builds.json"


class TestWago(unittest.TestCase):
    def test_parse_extracts_known_branches(self):
        raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
        builds = wago.parse_builds(raw)
        # Each branch should yield at least one build record.
        # We do not assert specific build numbers: those change daily.
        branches = {b.branch for b in builds}
        self.assertIn("wow", branches)
        self.assertIn("wow_classic_era", branches)

    def test_parse_build_record_shape(self):
        raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
        builds = wago.parse_builds(raw)
        b = builds[0]
        self.assertIsInstance(b.branch, str)
        self.assertIsInstance(b.version, str)
        self.assertIsInstance(b.build, int)
        self.assertGreater(b.build, 0)

    def test_latest_per_branch(self):
        raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
        latest = wago.latest_per_branch(wago.parse_builds(raw))
        self.assertIn("wow", latest)
        self.assertIsInstance(latest["wow"].build, int)

    def test_fetch_builds_calls_http(self):
        sample = json.loads(FIXTURE.read_text(encoding="utf-8"))
        with patch("bot.lib.wago.fetch_json", return_value=sample) as m:
            builds = wago.fetch_builds()
            m.assert_called_once_with(wago.BUILDS_URL)
            self.assertGreater(len(builds), 0)


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_wago -v`
Expected: FAIL with `ModuleNotFoundError`.

- [ ] **Step 4: Write minimal implementation**

Create `bot/lib/wago.py`:

```python
"""Fetch and parse Wago.tools build feed.

Response shape (as of 2026-05): a JSON object keyed by product
(e.g., "wow", "wow_classic_era") whose values are arrays of records
like `{"version": "11.2.7.59185", "created_at": "..."}`. The build
number is the integer suffix of `version`.

If Wago changes shape, adjust `parse_builds` and refresh the fixture.
"""
from dataclasses import dataclass
from typing import Iterable
from bot.lib.http import fetch_json

BUILDS_URL = "https://wago.tools/api/builds"


@dataclass(frozen=True)
class Build:
    branch: str
    version: str  # full string, e.g. "11.2.7.59185"
    build: int    # integer suffix, e.g. 59185


def _build_int(version: str) -> int:
    # version looks like "11.2.7.59185"; the build number is the last dotted segment.
    parts = version.split(".")
    return int(parts[-1])


def parse_builds(raw) -> list[Build]:
    out: list[Build] = []
    if isinstance(raw, dict):
        items: Iterable = raw.items()
    else:
        # Some responses are arrays of {"product": "...", ...}.
        items = ((row.get("product"), [row]) for row in raw)
    for branch, records in items:
        if not branch or not records:
            continue
        for rec in records:
            ver = rec.get("version")
            if not ver:
                continue
            try:
                out.append(Build(branch=branch, version=ver, build=_build_int(ver)))
            except (ValueError, IndexError):
                continue
    return out


def latest_per_branch(builds: list[Build]) -> dict[str, Build]:
    latest: dict[str, Build] = {}
    for b in builds:
        cur = latest.get(b.branch)
        if cur is None or b.build > cur.build:
            latest[b.branch] = b
    return latest


def fetch_builds() -> list[Build]:
    return parse_builds(fetch_json(BUILDS_URL))
```

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

Run: `python3 -m unittest bot.tests.test_wago -v`
Expected: 4 tests pass. If `test_parse_extracts_known_branches` fails because the live response shape differs from what `parse_builds` expects, adjust `parse_builds` (not the test's assertions) until the fixture parses, then re-run.

- [ ] **Step 6: Commit**

```bash
git add bot/lib/wago.py bot/tests/test_wago.py bot/tests/fixtures/wago-builds.json
git commit -m "feat(bot): add Wago build feed fetcher with frozen fixture"
```

---

## Task 5: Townlong-Yak global function extractor

**Files:**
- Create: `bot/lib/townlong.py`, `bot/tests/test_townlong.py`, `bot/tests/fixtures/townlong-old.lua`, `bot/tests/fixtures/townlong-new.lua`

- [ ] **Step 1: Write fixture Lua files**

Create `bot/tests/fixtures/townlong-old.lua`:

```lua
function GetSpellInfo(spellID)
    return spellID
end

GetSpellDescription = function(spellID)
    return spellID
end

function UnitName(unit)
    return unit
end
```

Create `bot/tests/fixtures/townlong-new.lua`:

```lua
function C_Spell.GetSpellInfo(spellID)
    return spellID
end

GetSpellDescription = function(spellID)
    return spellID
end

function UnitFullName(unit)
    return unit
end
```

(Both files cover the regex shapes the extractor must handle.)

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

Create `bot/tests/test_townlong.py`:

```python
import unittest
from pathlib import Path
from bot.lib import townlong

FIX = Path(__file__).parent / "fixtures"


class TestExtractGlobals(unittest.TestCase):
    def test_extracts_function_keyword_form(self):
        text = (FIX / "townlong-old.lua").read_text(encoding="utf-8")
        names = townlong.extract_global_function_names(text)
        self.assertIn("GetSpellInfo", names)
        self.assertIn("UnitName", names)

    def test_extracts_assignment_form(self):
        text = (FIX / "townlong-old.lua").read_text(encoding="utf-8")
        names = townlong.extract_global_function_names(text)
        self.assertIn("GetSpellDescription", names)

    def test_skips_dotted_or_namespaced_names(self):
        text = (FIX / "townlong-new.lua").read_text(encoding="utf-8")
        names = townlong.extract_global_function_names(text)
        # `C_Spell.GetSpellInfo` is namespaced and intentionally not captured in Phase 1.
        self.assertNotIn("GetSpellInfo", names)
        self.assertNotIn("C_Spell.GetSpellInfo", names)
        self.assertIn("UnitFullName", names)


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_townlong -v`
Expected: FAIL with `ModuleNotFoundError`.

- [ ] **Step 4: Write minimal implementation**

Create `bot/lib/townlong.py`:

```python
"""Pull WoW global function names from Townlong-Yak's wow-ui-source repo at a given tag.

Phase 1 captures only top-level globals (no `Foo.Bar` namespacing). The caller
uses the diff between two tags as the API surface diff.
"""
import re
from bot.lib.http import fetch_text

# https://www.townlong-yak.com/framexml/ — repo on GitHub: Gethe/wow-ui-source
# Raw file URL pattern. Tag examples: "live-11.2.7.59185", "classic_era-1.15.8.59000".
RAW_BASE = "https://raw.githubusercontent.com/Gethe/wow-ui-source/{tag}/{path}"

_FUNC_KEYWORD = re.compile(r"^\s*function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", re.MULTILINE)
_FUNC_ASSIGN = re.compile(
    r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*function\s*\(", re.MULTILINE
)


def extract_global_function_names(lua_source: str) -> set[str]:
    names: set[str] = set()
    for m in _FUNC_KEYWORD.finditer(lua_source):
        names.add(m.group(1))
    for m in _FUNC_ASSIGN.finditer(lua_source):
        names.add(m.group(1))
    return names


def fetch_raw_lua(tag: str, path: str) -> str:
    return fetch_text(RAW_BASE.format(tag=tag, path=path))
```

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

Run: `python3 -m unittest bot.tests.test_townlong -v`
Expected: 3 tests pass.

- [ ] **Step 6: Commit**

```bash
git add bot/lib/townlong.py bot/tests/test_townlong.py bot/tests/fixtures/townlong-old.lua bot/tests/fixtures/townlong-new.lua
git commit -m "feat(bot): add Townlong-Yak global-function extractor"
```

---

## Task 6: Diff computation

**Files:**
- Create: `bot/lib/diff.py`, `bot/tests/test_diff.py`

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

Create `bot/tests/test_diff.py`:

```python
import unittest
from bot.lib import diff


class TestDiff(unittest.TestCase):
    def test_removed_set(self):
        d = diff.compute_diff({"A", "B", "C"}, {"A", "C"})
        self.assertEqual(d.removed, {"B"})
        self.assertEqual(d.added, set())

    def test_added_set(self):
        d = diff.compute_diff({"A"}, {"A", "B"})
        self.assertEqual(d.added, {"B"})
        self.assertEqual(d.removed, set())

    def test_renamed_heuristic_within_distance(self):
        d = diff.compute_diff({"GetSpellInfo"}, {"GetSpellInfoX"})
        self.assertIn(("GetSpellInfo", "GetSpellInfoX"), d.renamed)

    def test_renamed_heuristic_too_far(self):
        d = diff.compute_diff({"GetSpellInfo"}, {"UnitName"})
        self.assertEqual(d.renamed, [])

    def test_renamed_max_distance_default_three(self):
        # Distance of exactly 3 should match.
        d = diff.compute_diff({"abcdef"}, {"abcXYZ"})
        self.assertIn(("abcdef", "abcXYZ"), d.renamed)
        # Distance of 4 should not.
        d2 = diff.compute_diff({"abcdef"}, {"WXYZef"})
        self.assertEqual(d2.renamed, [])


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_diff -v`
Expected: FAIL with `ModuleNotFoundError`.

- [ ] **Step 3: Write minimal implementation**

Create `bot/lib/diff.py`:

```python
"""Set diff between two snapshots of WoW global function names.

`removed` and `added` are exact set differences. `renamed` is a heuristic
that pairs items in `removed` with similarly-named items in `added` when
edit distance is within `MAX_RENAME_DISTANCE`. The heuristic is best-effort
and intended for human review, not auto-fix.
"""
from dataclasses import dataclass, field

MAX_RENAME_DISTANCE = 3


@dataclass
class Diff:
    removed: set[str] = field(default_factory=set)
    added: set[str] = field(default_factory=set)
    renamed: list[tuple[str, str]] = field(default_factory=list)


def _levenshtein(a: str, b: str) -> int:
    if a == b:
        return 0
    if not a:
        return len(b)
    if not b:
        return len(a)
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, start=1):
        curr = [i] + [0] * len(b)
        for j, cb in enumerate(b, start=1):
            curr[j] = min(
                prev[j] + 1,
                curr[j - 1] + 1,
                prev[j - 1] + (0 if ca == cb else 1),
            )
        prev = curr
    return prev[-1]


def compute_diff(old: set[str], new: set[str]) -> Diff:
    removed = old - new
    added = new - old
    renamed: list[tuple[str, str]] = []
    for r in removed:
        best = None
        best_dist = MAX_RENAME_DISTANCE + 1
        for a in added:
            d = _levenshtein(r, a)
            if d <= MAX_RENAME_DISTANCE and d < best_dist:
                best_dist = d
                best = a
        if best is not None:
            renamed.append((r, best))
    return Diff(removed=removed, added=added, renamed=renamed)
```

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

Run: `python3 -m unittest bot.tests.test_diff -v`
Expected: 5 tests pass.

- [ ] **Step 5: Commit**

```bash
git add bot/lib/diff.py bot/tests/test_diff.py
git commit -m "feat(bot): add API surface diff with rename heuristic"
```

---

## Task 7: Source greper

**Files:**
- Create: `bot/lib/grep.py`, `bot/tests/test_grep.py`

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

Create `bot/tests/test_grep.py`:

```python
import tempfile
import unittest
from pathlib import Path
from bot.lib import grep


class TestGrep(unittest.TestCase):
    def setUp(self):
        self.root = Path(tempfile.mkdtemp())
        (self.root / "Modules").mkdir()
        (self.root / "Libs").mkdir()
        (self.root / "CritMatic.lua").write_text(
            "local x = GetSpellInfo(123)\nlocal y = UnitName('player')\n",
            encoding="utf-8",
        )
        (self.root / "Modules" / "CritLog.lua").write_text(
            "GetSpellDescription(7)\n", encoding="utf-8"
        )
        (self.root / "Libs" / "Ace3.lua").write_text(
            "GetSpellInfo(999)\n", encoding="utf-8"
        )

    def test_finds_call_sites_in_main_and_modules(self):
        sites = grep.find_call_sites({"GetSpellInfo", "GetSpellDescription"}, self.root)
        files = {s.path for s in sites}
        self.assertIn("CritMatic.lua", files)
        self.assertIn("Modules/CritLog.lua", files)

    def test_excludes_libs(self):
        sites = grep.find_call_sites({"GetSpellInfo"}, self.root)
        for s in sites:
            self.assertFalse(s.path.startswith("Libs/"))

    def test_word_boundary(self):
        # "UnitNameOf" should NOT match "UnitName".
        (self.root / "X.lua").write_text("UnitNameOf(7)\n", encoding="utf-8")
        sites = grep.find_call_sites({"UnitName"}, self.root)
        self.assertEqual({s.path for s in sites}, {"CritMatic.lua"})

    def test_records_line_and_snippet(self):
        sites = grep.find_call_sites({"GetSpellInfo"}, self.root)
        s = next(s for s in sites if s.path == "CritMatic.lua")
        self.assertEqual(s.line, 1)
        self.assertIn("GetSpellInfo(123)", s.snippet)
        self.assertEqual(s.symbol, "GetSpellInfo")


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_grep -v`
Expected: FAIL with `ModuleNotFoundError`.

- [ ] **Step 3: Write minimal implementation**

Create `bot/lib/grep.py`:

```python
"""Grep CritMatic Lua sources for symbol references.

Phase 1 scans `*.lua` at the repo root and under `Modules/`. Excludes `Libs/`,
`Localization/`, and `.release/`. Word-boundary matching prevents
"UnitNameOf" from matching "UnitName".
"""
import re
from dataclasses import dataclass
from pathlib import Path

INCLUDE_GLOBS = ["*.lua", "Modules/*.lua"]
EXCLUDE_DIR_PARTS = {"Libs", "Localization", ".release"}


@dataclass(frozen=True)
class CallSite:
    symbol: str
    path: str  # repo-relative
    line: int
    snippet: str


def _iter_lua_files(root: Path):
    for pattern in INCLUDE_GLOBS:
        for p in root.glob(pattern):
            if any(part in EXCLUDE_DIR_PARTS for part in p.parts):
                continue
            yield p


def find_call_sites(symbols: set[str], root: Path) -> list[CallSite]:
    if not symbols:
        return []
    pattern = re.compile(r"\b(" + "|".join(re.escape(s) for s in symbols) + r")\b")
    sites: list[CallSite] = []
    for path in _iter_lua_files(root):
        try:
            text = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue
        for line_no, line in enumerate(text.splitlines(), start=1):
            for m in pattern.finditer(line):
                sites.append(
                    CallSite(
                        symbol=m.group(1),
                        path=str(path.relative_to(root)),
                        line=line_no,
                        snippet=line.strip(),
                    )
                )
    return sites
```

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

Run: `python3 -m unittest bot.tests.test_grep -v`
Expected: 4 tests pass.

- [ ] **Step 5: Commit**

```bash
git add bot/lib/grep.py bot/tests/test_grep.py
git commit -m "feat(bot): add Lua source greper for at-risk symbols"
```

---

## Task 8: Report renderer

**Files:**
- Create: `bot/lib/report.py`, `bot/tests/test_report.py`

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

Create `bot/tests/test_report.py`:

```python
import unittest
from bot.lib import report
from bot.lib.diff import Diff
from bot.lib.grep import CallSite


class TestReport(unittest.TestCase):
    def test_renders_at_risk_table(self):
        d = Diff(removed={"GetSpellInfo"}, added=set(), renamed=[])
        sites = [
            CallSite("GetSpellInfo", "CritMatic.lua", 412, "local n = GetSpellInfo(id)")
        ]
        md = report.render(
            branch="wow",
            new_version="11.3.0",
            new_build=110300,
            previous_version="11.2.7",
            previous_build=110207,
            detected_at="2026-05-06T13:42:11Z",
            diff=d,
            sites=sites,
        )
        self.assertIn("# CritMatic patch report", md)
        self.assertIn("11.3.0", md)
        self.assertIn("GetSpellInfo", md)
        self.assertIn("CritMatic.lua", md)
        self.assertIn("412", md)

    def test_renders_renamed_section_when_present(self):
        d = Diff(removed={"OldName"}, added={"NewName"}, renamed=[("OldName", "NewName")])
        md = report.render(
            branch="wow", new_version="x", new_build=1,
            previous_version="y", previous_build=0,
            detected_at="t", diff=d, sites=[],
        )
        self.assertIn("Possibly renamed", md)
        self.assertIn("OldName", md)
        self.assertIn("NewName", md)

    def test_renders_zero_at_risk_cleanly(self):
        d = Diff(removed=set(), added=set(), renamed=[])
        md = report.render(
            branch="wow", new_version="x", new_build=1,
            previous_version="y", previous_build=0,
            detected_at="t", diff=d, sites=[],
        )
        self.assertIn("At-risk call sites (0)", md)


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_report -v`
Expected: FAIL with `ModuleNotFoundError`.

- [ ] **Step 3: Write minimal implementation**

Create `bot/lib/report.py`:

```python
"""Render a Markdown patch report from analyzer outputs."""
from bot.lib.diff import Diff
from bot.lib.grep import CallSite


def render(
    *,
    branch: str,
    new_version: str,
    new_build: int,
    previous_version: str,
    previous_build: int,
    detected_at: str,
    diff: Diff,
    sites: list[CallSite],
) -> str:
    lines: list[str] = []
    lines.append(f"# CritMatic patch report: WoW {new_version} (build {new_build})")
    lines.append("")
    lines.append(f"**Branch:** {branch}")
    lines.append(f"**Detected at:** {detected_at}")
    lines.append(
        f"**Compared against:** previous build {previous_build} ({previous_version})"
    )
    lines.append("")
    lines.append(f"## At-risk call sites ({len(sites)})")
    lines.append("")
    if sites:
        lines.append("| Symbol | File | Line | Snippet |")
        lines.append("|--------|------|------|---------|")
        for s in sorted(sites, key=lambda x: (x.path, x.line)):
            snippet = s.snippet.replace("|", "\\|")
            lines.append(f"| `{s.symbol}` | `{s.path}` | {s.line} | `{snippet}` |")
        lines.append("")
    if diff.renamed:
        lines.append("## Possibly renamed (heuristic, verify manually)")
        lines.append("")
        lines.append("| Old | Candidate new |")
        lines.append("|-----|----------------|")
        for old, new in sorted(diff.renamed):
            lines.append(f"| `{old}` | `{new}` |")
        lines.append("")
    lines.append("## Raw diff stats")
    lines.append("")
    lines.append(f"- Removed: {len(diff.removed)}")
    lines.append(f"- Added: {len(diff.added)}")
    lines.append(f"- Renamed (heuristic): {len(diff.renamed)}")
    lines.append("")
    return "\n".join(lines)
```

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

Run: `python3 -m unittest bot.tests.test_report -v`
Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add bot/lib/report.py bot/tests/test_report.py
git commit -m "feat(bot): add Markdown report renderer"
```

---

## Task 9: Notifier

**Files:**
- Create: `bot/lib/notify.py`, `bot/tests/test_notify.py`

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

Create `bot/tests/test_notify.py`:

```python
import unittest
from unittest.mock import patch
from bot.lib import notify


class TestNotify(unittest.TestCase):
    def test_uses_terminal_notifier_if_available(self):
        with patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), \
             patch("subprocess.run") as run:
            notify.notify(title="t", subtitle="s", body="b", open_path="/tmp/x.md")
            args = run.call_args.args[0]
            self.assertEqual(args[0], "/usr/local/bin/terminal-notifier")
            self.assertIn("-open", args)

    def test_falls_back_to_osascript(self):
        with patch("shutil.which", return_value=None), \
             patch("subprocess.run") as run:
            notify.notify(title="t", subtitle="s", body="b")
            args = run.call_args.args[0]
            self.assertEqual(args[0], "osascript")
            joined = " ".join(args)
            self.assertIn("display notification", joined)

    def test_escapes_double_quotes_in_osascript_body(self):
        with patch("shutil.which", return_value=None), \
             patch("subprocess.run") as run:
            notify.notify(title='a"b', subtitle="s", body='c"d')
            args = run.call_args.args[0]
            script = next(a for a in args if "display notification" in a)
            # Ensure no unescaped double quotes inside the strings.
            self.assertNotIn('"a"b"', script)
            self.assertNotIn('"c"d"', script)


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_notify -v`
Expected: FAIL with `ModuleNotFoundError`.

- [ ] **Step 3: Write minimal implementation**

Create `bot/lib/notify.py`:

```python
"""macOS notifications. Uses terminal-notifier if installed, else osascript."""
import shutil
import subprocess


def _escape_applescript(s: str) -> str:
    return s.replace("\\", "\\\\").replace('"', '\\"')


def notify(*, title: str, subtitle: str, body: str, open_path: str | None = None) -> None:
    tn = shutil.which("terminal-notifier")
    if tn:
        cmd = [tn, "-title", title, "-subtitle", subtitle, "-message", body]
        if open_path:
            cmd += ["-open", f"file://{open_path}"]
        subprocess.run(cmd, check=False)
        return

    script = (
        f'display notification "{_escape_applescript(body)}" '
        f'with title "{_escape_applescript(title)}" '
        f'subtitle "{_escape_applescript(subtitle)}"'
    )
    subprocess.run(["osascript", "-e", script], check=False)
```

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

Run: `python3 -m unittest bot.tests.test_notify -v`
Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add bot/lib/notify.py bot/tests/test_notify.py
git commit -m "feat(bot): add macOS notifier with terminal-notifier fallback"
```

---

## Task 10: Analyzer entry point

**Files:**
- Create: `bot/analyze.py`

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

Create `bot/tests/test_analyze.py`:

```python
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot import analyze


class TestAnalyzeMain(unittest.TestCase):
    def test_writes_report_and_returns_at_risk_count(self):
        repo = Path(tempfile.mkdtemp())
        (repo / "CritMatic.lua").write_text("local x = GetSpellInfo(1)\n", encoding="utf-8")
        reports_dir = repo / "bot" / "reports"
        reports_dir.mkdir(parents=True)

        with patch("bot.analyze.fetch_global_functions") as ff:
            ff.side_effect = [
                {"GetSpellInfo", "UnitName"},  # previous
                {"UnitName"},                   # new
            ]
            count, report_path = analyze.run(
                branch="wow",
                new_version="11.3.0",
                new_build=110300,
                previous_version="11.2.7",
                previous_build=110207,
                repo_root=repo,
                reports_dir=reports_dir,
                detected_at="2026-05-06T13:42:11Z",
            )

        self.assertEqual(count, 1)
        self.assertTrue(report_path.exists())
        body = report_path.read_text(encoding="utf-8")
        self.assertIn("GetSpellInfo", body)
        self.assertIn("CritMatic.lua", body)


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_analyze -v`
Expected: FAIL with `ModuleNotFoundError`.

- [ ] **Step 3: Write minimal implementation**

Create `bot/analyze.py`:

```python
"""Analyzer entry point: given a new + previous build, produce a Markdown report.

Source of API surface: Townlong-Yak's `wow-ui-source` GitHub repo. We pull a
single representative file (`FrameXML/GlobalString.lua`) per tag in this Phase 1
implementation; expanding to more files is a Phase 1.5 task. This file alone
contains many globals and is sufficient to demonstrate the pipeline end-to-end.
"""
import argparse
from pathlib import Path

from bot.lib.diff import compute_diff
from bot.lib.grep import find_call_sites
from bot.lib.report import render
from bot.lib.townlong import extract_global_function_names, fetch_raw_lua

# Subset of files we pull per tag. Phase 1: one file. Phase 1.5: expand.
TOWNLONG_PATHS = ["Interface/FrameXML/GlobalString.lua"]


def _tag_for(branch: str, version: str) -> str:
    # Townlong-Yak/Gethe tags follow patterns like "live-11.2.7.59185" and
    # "classic_era-1.15.8.<build>". Mapping branch -> tag-prefix:
    prefix_map = {
        "wow": "live",
        "wowt": "ptr",
        "wow_classic_era": "classic_era",
        "wow_classic": "classic",
    }
    prefix = prefix_map.get(branch, branch)
    return f"{prefix}-{version}"


def fetch_global_functions(branch: str, version: str) -> set[str]:
    names: set[str] = set()
    tag = _tag_for(branch, version)
    for path in TOWNLONG_PATHS:
        try:
            text = fetch_raw_lua(tag, path)
        except Exception:
            continue
        names |= extract_global_function_names(text)
    return names


def run(
    *,
    branch: str,
    new_version: str,
    new_build: int,
    previous_version: str,
    previous_build: int,
    repo_root: Path,
    reports_dir: Path,
    detected_at: str,
) -> tuple[int, Path]:
    previous = fetch_global_functions(branch, previous_version)
    new = fetch_global_functions(branch, new_version)
    diff = compute_diff(previous, new)
    sites = find_call_sites(diff.removed, repo_root)

    md = render(
        branch=branch,
        new_version=new_version,
        new_build=new_build,
        previous_version=previous_version,
        previous_build=previous_build,
        detected_at=detected_at,
        diff=diff,
        sites=sites,
    )
    slug = new_version.replace(".", "_")
    out = reports_dir / f"{detected_at[:10]}-build-{new_build}-{slug}.md"
    out.write_text(md, encoding="utf-8")
    return len(sites), out


def _cli() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--branch", required=True)
    p.add_argument("--new-version", required=True)
    p.add_argument("--new-build", type=int, required=True)
    p.add_argument("--previous-version", required=True)
    p.add_argument("--previous-build", type=int, required=True)
    p.add_argument("--repo-root", required=True)
    p.add_argument("--reports-dir", required=True)
    p.add_argument("--detected-at", required=True)
    args = p.parse_args()

    count, path = run(
        branch=args.branch,
        new_version=args.new_version,
        new_build=args.new_build,
        previous_version=args.previous_version,
        previous_build=args.previous_build,
        repo_root=Path(args.repo_root),
        reports_dir=Path(args.reports_dir),
        detected_at=args.detected_at,
    )
    print(path)
    return 0 if count == 0 else 1


if __name__ == "__main__":
    raise SystemExit(_cli())
```

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

Run: `python3 -m unittest bot.tests.test_analyze -v`
Expected: 1 test passes.

- [ ] **Step 5: Run the full suite**

Run: `python3 -m unittest discover bot/tests -v`
Expected: all tests written so far pass.

- [ ] **Step 6: Commit**

```bash
git add bot/analyze.py bot/tests/test_analyze.py
git commit -m "feat(bot): add analyzer entry point wiring diff + grep + report"
```

---

## Task 11: Detector entry point

**Files:**
- Create: `bot/detect.py`, `bot/tests/test_detect.py`

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

Create `bot/tests/test_detect.py`:

```python
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
from bot import detect
from bot.lib.wago import Build


class TestDetect(unittest.TestCase):
    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.state_dir = self.tmp / "state"
        self.state_dir.mkdir()
        self.reports_dir = self.tmp / "reports"
        self.reports_dir.mkdir()
        self.repo_root = self.tmp / "repo"
        self.repo_root.mkdir()

    def test_kill_switch_short_circuits(self):
        (self.state_dir / "disabled").touch()
        with patch("bot.detect.fetch_builds") as fb:
            rc = detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        fb.assert_not_called()
        self.assertEqual(rc, 0)

    def test_first_run_records_state_no_analysis(self):
        builds = [
            Build(branch="wow", version="11.2.7.110207", build=110207),
            Build(branch="wow_classic_era", version="1.15.8.11508", build=11508),
        ]
        with patch("bot.detect.fetch_builds", return_value=builds), \
             patch("bot.detect.run_analyzer") as ra, \
             patch("bot.detect.notify") as nf:
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        ra.assert_not_called()
        nf.assert_not_called()
        saved = json.loads((self.state_dir / "last-seen-builds.json").read_text(encoding="utf-8"))
        self.assertEqual(saved["wow"]["build"], 110207)

    def test_new_build_triggers_analyzer_and_notification(self):
        # Stage prior state.
        (self.state_dir / "last-seen-builds.json").write_text(json.dumps({
            "wow": {"build": 110207, "version": "11.2.7", "seen_at": "2026-04-01T00:00:00Z"},
        }), encoding="utf-8")
        builds = [Build(branch="wow", version="11.3.0.110300", build=110300)]
        report_path = self.reports_dir / "fake.md"
        report_path.write_text("# fake\n", encoding="utf-8")
        with patch("bot.detect.fetch_builds", return_value=builds), \
             patch("bot.detect.run_analyzer", return_value=(2, report_path)) as ra, \
             patch("bot.detect.notify") as nf:
            rc = detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        ra.assert_called_once()
        nf.assert_called_once()
        self.assertEqual(rc, 0)
        saved = json.loads((self.state_dir / "last-seen-builds.json").read_text(encoding="utf-8"))
        self.assertEqual(saved["wow"]["build"], 110300)

    def test_zero_at_risk_does_not_notify(self):
        (self.state_dir / "last-seen-builds.json").write_text(json.dumps({
            "wow": {"build": 110207, "version": "11.2.7", "seen_at": "2026-04-01T00:00:00Z"},
        }), encoding="utf-8")
        builds = [Build(branch="wow", version="11.3.0.110300", build=110300)]
        with patch("bot.detect.fetch_builds", return_value=builds), \
             patch("bot.detect.run_analyzer", return_value=(0, self.reports_dir / "x.md")), \
             patch("bot.detect.notify") as nf:
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        nf.assert_not_called()

    def test_ptr_branch_logged_but_not_analyzed(self):
        (self.state_dir / "last-seen-builds.json").write_text(json.dumps({
            "wowt": {"build": 110200, "version": "11.2.0", "seen_at": "2026-04-01T00:00:00Z"},
        }), encoding="utf-8")
        builds = [Build(branch="wowt", version="11.3.0.110301", build=110301)]
        with patch("bot.detect.fetch_builds", return_value=builds), \
             patch("bot.detect.run_analyzer") as ra, \
             patch("bot.detect.notify") as nf:
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        ra.assert_not_called()
        nf.assert_not_called()
        saved = json.loads((self.state_dir / "last-seen-builds.json").read_text(encoding="utf-8"))
        self.assertEqual(saved["wowt"]["build"], 110301)

    def test_http_failure_increments_counter_does_not_crash(self):
        from bot.lib.http import HTTPError
        with patch("bot.detect.fetch_builds", side_effect=HTTPError("network")):
            rc = detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        self.assertEqual(rc, 0)
        self.assertEqual((self.state_dir / "failure-count.txt").read_text().strip(), "1")


if __name__ == "__main__":
    unittest.main()
```

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

Run: `python3 -m unittest bot.tests.test_detect -v`
Expected: FAIL with `ModuleNotFoundError` or attribute errors.

- [ ] **Step 3: Write minimal implementation**

Create `bot/detect.py`:

```python
"""Patch watcher detector. Entry point invoked by launchd every 4h.

Reads last-seen state, hits Wago build feed, and for each new build on a
flavor we analyze, runs the analyzer and posts a notification when there
are at-risk call sites. PTR builds (wowt) are recorded but not analyzed.
"""
import datetime as _dt
import json
import sys
from pathlib import Path

from bot.lib import state
from bot.lib.http import HTTPError
from bot.lib.notify import notify
from bot.lib.wago import Build, fetch_builds, latest_per_branch
from bot.analyze import run as run_analyzer

ANALYZE_BRANCHES = {"wow", "wow_classic_era", "wow_classic"}
TRACK_ONLY_BRANCHES = {"wowt"}
FAILURE_NOTIFICATION_THRESHOLD = 3


def _now_iso() -> str:
    return _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds").replace("+00:00", "Z")


def _version_str_from_full(full: str) -> str:
    # "11.2.7.110207" -> "11.2.7"
    parts = full.split(".")
    return ".".join(parts[:-1]) if len(parts) > 1 else full


def _state_path(state_dir: Path) -> Path:
    return state_dir / "last-seen-builds.json"


def run(*, state_dir: Path, reports_dir: Path, repo_root: Path) -> int:
    if state.is_disabled(state_dir):
        return 0

    try:
        builds: list[Build] = fetch_builds()
    except HTTPError as exc:
        n = state.bump_failure(state_dir)
        print(f"fetch_builds failed (count={n}): {exc}", file=sys.stderr)
        if n == FAILURE_NOTIFICATION_THRESHOLD:
            notify(
                title="CritMatic patch watcher",
                subtitle="offline",
                body=f"{n} consecutive failures, see launchd.log",
            )
        return 0
    state.reset_failures(state_dir)

    latest = latest_per_branch(builds)
    last_seen = state.load_last_seen(_state_path(state_dir))
    detected_at = _now_iso()

    new_state = dict(last_seen)
    for branch, b in latest.items():
        if branch not in ANALYZE_BRANCHES and branch not in TRACK_ONLY_BRANCHES:
            continue
        prev = last_seen.get(branch)
        if prev is None:
            # First sighting: record only, do not analyze.
            new_state[branch] = {"build": b.build, "version": _version_str_from_full(b.version), "seen_at": detected_at}
            continue
        if b.build <= int(prev["build"]):
            continue

        # We have a newer build for this branch.
        new_version = _version_str_from_full(b.version)
        prev_version = prev["version"]
        prev_build = int(prev["build"])

        if branch in ANALYZE_BRANCHES:
            try:
                count, report_path = run_analyzer(
                    branch=branch,
                    new_version=new_version,
                    new_build=b.build,
                    previous_version=prev_version,
                    previous_build=prev_build,
                    repo_root=repo_root,
                    reports_dir=reports_dir,
                    detected_at=detected_at,
                )
            except Exception as exc:
                print(f"analyzer failed for {branch} {new_version}: {exc}", file=sys.stderr)
                continue
            if count > 0:
                notify(
                    title="CritMatic patch watcher",
                    subtitle=f"{branch} {new_version}",
                    body=f"{count} at-risk call sites. Report: {report_path}",
                    open_path=str(report_path),
                )
        # PTR (track-only) is recorded silently below.
        new_state[branch] = {"build": b.build, "version": new_version, "seen_at": detected_at}

    state.save_last_seen(_state_path(state_dir), new_state)
    return 0


def _main() -> int:
    repo_root = Path(__file__).resolve().parent.parent
    state_dir = repo_root / "bot" / "state"
    reports_dir = repo_root / "bot" / "reports"
    state_dir.mkdir(parents=True, exist_ok=True)
    reports_dir.mkdir(parents=True, exist_ok=True)
    return run(state_dir=state_dir, reports_dir=reports_dir, repo_root=repo_root)


if __name__ == "__main__":
    raise SystemExit(_main())
```

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

Run: `python3 -m unittest bot.tests.test_detect -v`
Expected: 6 tests pass.

- [ ] **Step 5: Run the full suite**

Run: `python3 -m unittest discover bot/tests -v`
Expected: all tests pass.

- [ ] **Step 6: Commit**

```bash
git add bot/detect.py bot/tests/test_detect.py
git commit -m "feat(bot): add detector entry point with state + notify wiring"
```

---

## Task 12: launchd plist template

**Files:**
- Create: `bot/plist/com.critmatic.bot.plist`

- [ ] **Step 1: Write the plist template**

Create `bot/plist/com.critmatic.bot.plist`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.critmatic.bot</string>
    <key>ProgramArguments</key>
    <array>
        <string>__PYTHON3__</string>
        <string>__REPO_PATH__/bot/detect.py</string>
    </array>
    <key>WorkingDirectory</key>
    <string>__REPO_PATH__</string>
    <key>StartInterval</key>
    <integer>14400</integer>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>__REPO_PATH__/bot/state/launchd.log</string>
    <key>StandardErrorPath</key>
    <string>__REPO_PATH__/bot/state/launchd.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PYTHONPATH</key>
        <string>__REPO_PATH__</string>
    </dict>
</dict>
</plist>
```

- [ ] **Step 2: Verify the template is well-formed XML**

Run: `python3 -c "import plistlib; plistlib.loads(open('bot/plist/com.critmatic.bot.plist','rb').read())"`
Expected: no output, no exceptions. (The placeholders `__REPO_PATH__` and `__PYTHON3__` are valid string contents.)

- [ ] **Step 3: Commit**

```bash
git add bot/plist/com.critmatic.bot.plist
git commit -m "feat(bot): add launchd plist template"
```

---

## Task 13: README

**Files:**
- Create: `bot/README.md`

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

Create `bot/README.md`:

````markdown
# CritMatic Patch Watcher (Phase 1)

Local-only macOS bot that polls Wago.tools for new WoW builds, diffs the WoW global-function surface, greps CritMatic source for at-risk symbols, and posts a macOS notification linking to a Markdown report. **Detect + analyze + notify only.** No code changes, no git operations, no releases.

## Install

```bash
REPO="$(pwd)"
PY="$(which python3)"

sed "s|__REPO_PATH__|${REPO}|g; s|__PYTHON3__|${PY}|g" \
  bot/plist/com.critmatic.bot.plist \
  > ~/Library/LaunchAgents/com.critmatic.bot.plist

launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.critmatic.bot.plist
```

The first run fires immediately (because `RunAtLoad=true`). Subsequent runs every 4 hours.

## Pause / resume

```bash
# Pause: any future scheduled run exits immediately.
touch bot/state/disabled

# Resume:
rm bot/state/disabled
```

## Uninstall

```bash
launchctl bootout gui/$UID ~/Library/LaunchAgents/com.critmatic.bot.plist
rm ~/Library/LaunchAgents/com.critmatic.bot.plist
```

## Where things live

- Reports: `bot/reports/<date>-build-<n>-<version>.md` (gitignored).
- Last-seen state: `bot/state/last-seen-builds.json` (gitignored).
- Logs: `bot/state/launchd.log` (gitignored).
- Notifications log: `bot/state/notifications.log` (gitignored).
- Kill switch: `bot/state/disabled` (gitignored).

## Reading a report

A report is a single Markdown file with these sections:

- **At-risk call sites:** every place in CritMatic source that uses a WoW global function that disappeared between the previous build and the new one.
- **Possibly renamed:** heuristic pairs (Levenshtein <= 3) where a removed name and an added name look similar. Verify before acting.
- **Raw diff stats:** counts.

Phase 1 only inspects top-level globals defined in Townlong-Yak's `FrameXML/GlobalString.lua`. Events, CVars, and `C_*` namespaced tables are out of scope until Phase 1.5.

## Run on demand

```bash
PYTHONPATH=. python3 bot/detect.py
```

## Run the test suite

```bash
PYTHONPATH=. python3 -m unittest discover bot/tests
```
````

- [ ] **Step 2: Commit**

```bash
git add bot/README.md
git commit -m "docs(bot): add Phase 1 README"
```

---

## Task 14: End-to-end manual test

This is a manual checklist. No code changes; verifies the wiring works against real services.

- [ ] **Step 1: Confirm full unit suite is green**

Run: `PYTHONPATH=. python3 -m unittest discover bot/tests -v`
Expected: all tests pass.

- [ ] **Step 2: Stub `last-seen-builds.json` to a build one number behind real**

Run:

```bash
PYTHONPATH=. python3 - <<'PY'
import json
from pathlib import Path
from bot.lib.wago import fetch_builds, latest_per_branch
latest = latest_per_branch(fetch_builds())
out = {}
for branch in ("wow", "wow_classic_era", "wow_classic", "wowt"):
    if branch not in latest:
        continue
    b = latest[branch]
    parts = b.version.split(".")
    version = ".".join(parts[:-1])
    out[branch] = {"build": b.build - 1, "version": version, "seen_at": "2026-01-01T00:00:00Z"}
Path("bot/state/last-seen-builds.json").write_text(json.dumps(out, indent=2), encoding="utf-8")
print(json.dumps(out, indent=2))
PY
```

Expected: a JSON map printed and written. Each branch's `build` is `(real - 1)`.

- [ ] **Step 3: Run the detector**

Run: `PYTHONPATH=. python3 bot/detect.py`
Expected:
- A macOS notification fires (or, if there are zero at-risk symbols, no notification but the run still completes silently).
- A new file appears under `bot/reports/` if any analyze ran.
- `bot/state/last-seen-builds.json` is updated to the real current build numbers.

- [ ] **Step 4: Run again, expect quiet**

Run: `PYTHONPATH=. python3 bot/detect.py`
Expected: no new report, no notification, `last-seen-builds.json` unchanged.

- [ ] **Step 5: Verify kill switch**

Run:

```bash
touch bot/state/disabled
PYTHONPATH=. python3 bot/detect.py
echo "exit=$?"
rm bot/state/disabled
```

Expected: `exit=0`, no network activity, no state changes.

- [ ] **Step 6: Install launchd job and verify first scheduled run**

Run the install snippet from `bot/README.md` (Task 13). Then:

```bash
sleep 5
tail -n 50 bot/state/launchd.log
```

Expected: log shows a recent run.

- [ ] **Step 7: Uninstall (cleanup for now, reinstall whenever you want)**

```bash
launchctl bootout gui/$UID ~/Library/LaunchAgents/com.critmatic.bot.plist
rm ~/Library/LaunchAgents/com.critmatic.bot.plist
```

- [ ] **Step 8: Final commit if anything was tweaked**

If you changed anything during manual testing:

```bash
git add -A
git commit -m "fix(bot): tweaks from end-to-end manual test"
```

---

## Definition of done

- [ ] All unit tests pass: `PYTHONPATH=. python3 -m unittest discover bot/tests`.
- [ ] Manual end-to-end test (Task 14) passes.
- [ ] launchd job installs cleanly and the first run lands in `bot/state/launchd.log`.
- [ ] `bot/README.md` is sufficient to install / pause / resume / uninstall without asking Claude.
- [ ] No file under `Libs/`, `Localization/`, or `.release/` is grepped or modified by the bot.
- [ ] No GitHub tokens, CurseForge tokens, or `.env` values are read by the bot.
