# CritMatic Patch Watcher (Phase 3) 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:** Auto-merge `auto-ship-eligible` Phase 2 fixes, version-bump (heuristic minor for big diffs, else patch), prepend CHANGE_LOG.md, annotated tag, and schedule release at a randomized millisecond-jittered timestamp inside the next Tue 9-11am US Pacific or Wed 9-11am CET window. On every poll, drain due scheduled releases by `git push origin master <tag>` + `bash .release/release.sh` (with CF_API_KEY sourced from `.env`). Tighten launchd cadence to 15 min. Add `bot/state/auto-release-disabled` kill switch and `bot/approve.py cancel <ver>` subcommand.

**Architecture:** Three new lib modules (`version_bump.py`, `scheduler.py`, `release.py`), state extension for `pending-releases.json`, two new responsibilities in `bot/detect.py` (drain step at poll start, auto-release pipeline replacing the Phase 2 "record pending fix" branch when status is `auto-ship-eligible`), one new subcommand in `bot/approve.py`, and a tightened launchd plist. Stdlib only; calls out to `git`, `bash .release/release.sh`, and reads `.env` directly. The auto-release path reuses `approve.cmd_merge` for the ff-only merge (defence-in-depth via the existing verify re-run).

**Tech Stack:** Python 3 stdlib only (`subprocess`, `json`, `re`, `random`, `datetime`, `zoneinfo`, `pathlib`, `argparse`, `unittest`). Shells out to `git`, `bash`, and `.release/release.sh`. macOS `launchd` for scheduling.

**Spec deviations from `docs/superpowers/specs/2026-05-07-critmatic-patch-watcher-phase-3-design.md`:** None planned. The plan implements the spec verbatim.

---

## File Structure

**New files:**

| Path | Responsibility |
|------|----------------|
| `bot/lib/version_bump.py` | Decide patch vs minor; rewrite TOC + versions.json + CHANGE_LOG.md; commit + tag (transactional). |
| `bot/lib/scheduler.py` | `next_peak_timestamp(now, rng)` returning a tz-aware ms-jittered datetime in the next Tue-Pacific or Wed-CET window. |
| `bot/lib/release.py` | `parse_dotenv(path) -> dict[str, str]` and `run_release(repo_root) -> ReleaseResult`. |
| `bot/tests/test_version_bump.py` | Unit tests for bump kind heuristic, version arithmetic, transactional apply. |
| `bot/tests/test_scheduler.py` | Unit tests with deterministic `random.Random(seed)`. |
| `bot/tests/test_release.py` | Unit tests for dotenv parser + mocked subprocess release run. |

**Modified files:**

| Path | Change |
|------|--------|
| `bot/lib/state.py` | Add `PENDING_RELEASES_FILENAME` + `load_pending_releases` + `record_pending_release` + `remove_pending_release`. |
| `bot/tests/test_state.py` | Add roundtrip tests for the three new helpers. |
| `bot/detect.py` | Add `_drain_due_releases` called at start of `run`; replace `auto-ship-eligible` branch with `_auto_release_pipeline` (with kill-switch fallback to Phase 2 behaviour). |
| `bot/tests/test_detect.py` | Add tests for drain step and auto-release pipeline. |
| `bot/approve.py` | Add `cancel <version>` subcommand; list scheduled releases in default `list` output. |
| `bot/tests/test_approve.py` | Add tests for `cancel`. |
| `bot/plist/com.critmatic.bot.plist` | Change `<integer>14400</integer>` to `<integer>900</integer>`. |
| `bot/README.md` | Document Phase 3 behaviour, kill switch, approve cancel, plist reinstall step. |

---

## Task 1: State pending-release helpers

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

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

Append to `bot/tests/test_state.py` (inside `class TestState`, after the existing tests):

```python
    def test_pending_releases_empty_when_missing(self):
        self.assertEqual(state.load_pending_releases(self.tmp), {})

    def test_record_pending_release_then_load(self):
        entry = {
            "branch": "wow",
            "tag": "v5.5.8",
            "scheduled_at": "2026-05-12T09:14:23.456-07:00",
            "status": "scheduled",
            "fix_branch": "bot/patch-wow-12_0_5",
            "report_path": "bot/reports/x.md",
            "created_at": "2026-05-07T11:24:18Z",
            "retries": 0,
        }
        state.record_pending_release(self.tmp, "v5.5.8", entry)
        loaded = state.load_pending_releases(self.tmp)
        self.assertEqual(loaded["v5.5.8"], entry)

    def test_remove_pending_release(self):
        state.record_pending_release(self.tmp, "v5.5.8", {"status": "scheduled"})
        state.record_pending_release(self.tmp, "v5.5.9", {"status": "scheduled"})
        state.remove_pending_release(self.tmp, "v5.5.8")
        loaded = state.load_pending_releases(self.tmp)
        self.assertNotIn("v5.5.8", loaded)
        self.assertIn("v5.5.9", loaded)

    def test_remove_pending_release_missing_is_noop(self):
        state.remove_pending_release(self.tmp, "v9.9.9")
        self.assertEqual(state.load_pending_releases(self.tmp), {})
```

- [ ] **Step 2: Verify failure**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_state -v`
Expected: 4 new tests fail with `AttributeError: module 'bot.lib.state' has no attribute 'load_pending_releases'`. Existing 10 tests still pass.

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

Append to `bot/lib/state.py` (after the existing `remove_pending_fix` function):

```python
PENDING_RELEASES_FILENAME = "pending-releases.json"


def _pending_releases_path(state_dir: Path) -> Path:
    return state_dir / PENDING_RELEASES_FILENAME


def load_pending_releases(state_dir: Path) -> dict:
    p = _pending_releases_path(state_dir)
    if not p.exists():
        return {}
    return json.loads(p.read_text(encoding="utf-8"))


def record_pending_release(state_dir: Path, version: str, entry: dict) -> None:
    releases = load_pending_releases(state_dir)
    releases[version] = entry
    p = _pending_releases_path(state_dir)
    tmp = p.with_suffix(p.suffix + ".tmp")
    tmp.write_text(json.dumps(releases, indent=2, sort_keys=True), encoding="utf-8")
    os.replace(tmp, p)


def remove_pending_release(state_dir: Path, version: str) -> None:
    releases = load_pending_releases(state_dir)
    if version not in releases:
        return
    del releases[version]
    p = _pending_releases_path(state_dir)
    tmp = p.with_suffix(p.suffix + ".tmp")
    tmp.write_text(json.dumps(releases, indent=2, sort_keys=True), encoding="utf-8")
    os.replace(tmp, p)
```

- [ ] **Step 4: Verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_state -v`
Expected: 14 state tests pass (10 existing + 4 new). Then full discover: `PYTHONPATH=. python3 -m unittest discover bot/tests` → 77 pass (73 prior + 4 new).

- [ ] **Step 5: Commit**

```bash
git add bot/lib/state.py bot/tests/test_state.py
git commit -m "feat(bot): add pending-release state helpers (Phase 3)"
```

---

## Task 2: Version-bump module

**Files:**
- Create: `bot/lib/version_bump.py`
- Create: `bot/tests/test_version_bump.py`

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

Create `bot/tests/test_version_bump.py`:

```python
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
from bot.lib import version_bump


class TestDecideBumpKind(unittest.TestCase):
    def test_few_files_few_lines_is_patch(self):
        self.assertEqual(version_bump.decide_bump_kind(1, 5), "patch")
        self.assertEqual(version_bump.decide_bump_kind(3, 50), "patch")

    def test_many_files_is_minor(self):
        self.assertEqual(version_bump.decide_bump_kind(4, 5), "minor")

    def test_many_lines_is_minor(self):
        self.assertEqual(version_bump.decide_bump_kind(1, 51), "minor")

    def test_both_over_threshold_is_minor(self):
        self.assertEqual(version_bump.decide_bump_kind(10, 200), "minor")


class TestVersionArithmetic(unittest.TestCase):
    def test_parse_version(self):
        self.assertEqual(version_bump.parse_version("5.5.7"), (5, 5, 7))

    def test_parse_version_invalid(self):
        with self.assertRaises(ValueError):
            version_bump.parse_version("garbage")

    def test_next_version_patch(self):
        self.assertEqual(version_bump.next_version("5.5.7", "patch"), "5.5.8")

    def test_next_version_minor_resets_patch(self):
        self.assertEqual(version_bump.next_version("5.5.7", "minor"), "5.6.0")

    def test_next_version_unknown_kind(self):
        with self.assertRaises(ValueError):
            version_bump.next_version("5.5.7", "major")


class TestReadCurrentVersion(unittest.TestCase):
    def test_reads_toc_version_line(self):
        repo = Path(tempfile.mkdtemp())
        (repo / "CritMatic.toc").write_text(
            "## Interface: 110207\n## Title: CritMatic\n## Version: 5.5.7\n## Author: x\n",
            encoding="utf-8",
        )
        self.assertEqual(version_bump.read_current_version(repo), "5.5.7")

    def test_raises_when_toc_missing(self):
        repo = Path(tempfile.mkdtemp())
        with self.assertRaises(FileNotFoundError):
            version_bump.read_current_version(repo)


class TestApplyBump(unittest.TestCase):
    def setUp(self):
        self.repo = Path(tempfile.mkdtemp())
        (self.repo / "CritMatic.toc").write_text(
            "## Interface: 110207\n## Version: 5.5.7\n## Author: x\n",
            encoding="utf-8",
        )
        (self.repo / "versions.json").write_text(
            json.dumps({"critmatic_version": "5.5.7"}, indent=2),
            encoding="utf-8",
        )
        (self.repo / "CHANGE_LOG.md").write_text(
            "# Changelog\n\n## [v5.5.7-release] - 05/06/2026\n- prior entry\n",
            encoding="utf-8",
        )

    def _fake_git_factory(self, tag_exists_initially=False):
        """Returns a side_effect for subprocess.run mocking git add/commit/tag/rev-parse calls."""
        state = {"tag_exists": set()}
        if tag_exists_initially:
            state["tag_exists"].add("v5.5.8")

        def run(cmd, *args, **kwargs):
            result = MagicMock(returncode=0, stdout="", stderr="")
            if cmd[0] == "git":
                if "rev-parse" in cmd and "--verify" in cmd:
                    # Used to check tag existence: git rev-parse --verify <tag>
                    tag = cmd[-1]
                    result.returncode = 0 if tag in state["tag_exists"] else 1
                elif "tag" in cmd and "-a" in cmd:
                    # Tag creation: git tag -a <tag> -m <msg>
                    tag_idx = cmd.index("-a") + 1
                    state["tag_exists"].add(cmd[tag_idx])
                elif "tag" in cmd and "-d" in cmd:
                    # Tag deletion (for cleanup on failure)
                    tag_idx = cmd.index("-d") + 1
                    state["tag_exists"].discard(cmd[tag_idx])
            return result
        return run

    def test_rewrites_toc_versions_changelog(self):
        with patch("subprocess.run", side_effect=self._fake_git_factory()):
            tag = version_bump.apply_bump(
                repo_root=self.repo,
                new_version="5.5.8",
                changelog_body="Compatibility fix for WoW 12.0.5.",
                today_mmddyyyy="05/07/2026",
            )
        self.assertEqual(tag, "v5.5.8")
        toc = (self.repo / "CritMatic.toc").read_text(encoding="utf-8")
        self.assertIn("## Version: 5.5.8", toc)
        self.assertNotIn("## Version: 5.5.7", toc)
        versions = json.loads((self.repo / "versions.json").read_text(encoding="utf-8"))
        self.assertEqual(versions["critmatic_version"], "5.5.8")
        changelog = (self.repo / "CHANGE_LOG.md").read_text(encoding="utf-8")
        self.assertIn("## [v5.5.8-release] - 05/07/2026", changelog)
        self.assertIn("Compatibility fix for WoW 12.0.5.", changelog)
        # Prior entry preserved.
        self.assertIn("## [v5.5.7-release] - 05/06/2026", changelog)

    def test_tag_collision_appends_suffix(self):
        with patch("subprocess.run", side_effect=self._fake_git_factory(tag_exists_initially=True)):
            tag = version_bump.apply_bump(
                repo_root=self.repo,
                new_version="5.5.8",
                changelog_body="x",
                today_mmddyyyy="05/07/2026",
            )
        self.assertEqual(tag, "v5.5.8-1")

    def test_apply_bump_transactional_on_changelog_failure(self):
        # Simulate a failure mid-way: make CHANGE_LOG.md a directory so write fails.
        (self.repo / "CHANGE_LOG.md").unlink()
        (self.repo / "CHANGE_LOG.md").mkdir()
        before_toc = (self.repo / "CritMatic.toc").read_text(encoding="utf-8")
        before_versions = (self.repo / "versions.json").read_text(encoding="utf-8")
        with patch("subprocess.run", side_effect=self._fake_git_factory()):
            with self.assertRaises(Exception):
                version_bump.apply_bump(
                    repo_root=self.repo,
                    new_version="5.5.8",
                    changelog_body="x",
                    today_mmddyyyy="05/07/2026",
                )
        # Rolled back.
        self.assertEqual((self.repo / "CritMatic.toc").read_text(encoding="utf-8"), before_toc)
        self.assertEqual((self.repo / "versions.json").read_text(encoding="utf-8"), before_versions)


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

- [ ] **Step 2: Verify failure**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_version_bump -v`
Expected: ModuleNotFoundError on `bot.lib.version_bump`.

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

Create `bot/lib/version_bump.py`:

```python
"""Phase 3 version bumper: rewrite TOC + versions.json + CHANGE_LOG.md, commit, tag.

Decides patch vs minor based on a small heuristic over the size of the proposed
fix. apply_bump is transactional: on any failure mid-flight, it restores the
pre-call state of the working tree (and removes any commit/tag it created)
before re-raising.
"""
import json
import re
import subprocess
from pathlib import Path

PATCH_BUMP_FILES_THRESHOLD = 3
PATCH_BUMP_LINES_THRESHOLD = 50

_TOC_VERSION_RE = re.compile(r"^(## Version:\s*)([0-9]+\.[0-9]+\.[0-9]+)\s*$", re.MULTILINE)


def decide_bump_kind(files_changed: int, diff_lines: int) -> str:
    if files_changed > PATCH_BUMP_FILES_THRESHOLD or diff_lines > PATCH_BUMP_LINES_THRESHOLD:
        return "minor"
    return "patch"


def parse_version(s: str) -> tuple[int, int, int]:
    parts = s.strip().split(".")
    if len(parts) != 3:
        raise ValueError(f"not semver: {s!r}")
    try:
        return (int(parts[0]), int(parts[1]), int(parts[2]))
    except ValueError as exc:
        raise ValueError(f"not semver: {s!r}") from exc


def next_version(current: str, kind: str) -> str:
    major, minor, patch = parse_version(current)
    if kind == "patch":
        return f"{major}.{minor}.{patch + 1}"
    if kind == "minor":
        return f"{major}.{minor + 1}.0"
    raise ValueError(f"unknown bump kind: {kind!r}")


def read_current_version(repo_root: Path) -> str:
    toc = repo_root / "CritMatic.toc"
    if not toc.exists():
        raise FileNotFoundError(f"{toc} does not exist")
    text = toc.read_text(encoding="utf-8")
    m = _TOC_VERSION_RE.search(text)
    if not m:
        raise ValueError("no `## Version:` line found in CritMatic.toc")
    return m.group(2)


def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
    return subprocess.run(
        ["git", "-C", str(repo), *args],
        capture_output=True, text=True, check=False,
    )


def _tag_exists(repo: Path, tag: str) -> bool:
    return _git(repo, "rev-parse", "--verify", tag).returncode == 0


def _resolve_unique_tag(repo: Path, base: str) -> str:
    if not _tag_exists(repo, base):
        return base
    suffix = 1
    while _tag_exists(repo, f"{base}-{suffix}"):
        suffix += 1
    return f"{base}-{suffix}"


def _rewrite_toc(repo: Path, new_version: str) -> None:
    toc = repo / "CritMatic.toc"
    text = toc.read_text(encoding="utf-8")
    new_text, n = _TOC_VERSION_RE.subn(rf"\g<1>{new_version}", text, count=1)
    if n != 1:
        raise ValueError("could not substitute Version line")
    toc.write_text(new_text, encoding="utf-8")


def _rewrite_versions_json(repo: Path, new_version: str) -> None:
    p = repo / "versions.json"
    data = json.loads(p.read_text(encoding="utf-8"))
    data["critmatic_version"] = new_version
    p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")


def _prepend_changelog(repo: Path, new_version: str, body: str, today_mmddyyyy: str) -> None:
    p = repo / "CHANGE_LOG.md"
    if not p.is_file():
        raise FileNotFoundError(f"{p} is not a file")
    existing = p.read_text(encoding="utf-8")
    block = f"## [v{new_version}-release] - {today_mmddyyyy}\n- Auto-fix: {body}\n\n"
    # Insert after the top-level "# Changelog" heading if present, else prepend.
    lines = existing.splitlines(keepends=True)
    insert_at = 0
    for i, line in enumerate(lines):
        if line.startswith("# "):
            insert_at = i + 1
            # skip a single blank after the H1 if present
            if insert_at < len(lines) and lines[insert_at].strip() == "":
                insert_at += 1
            break
    new_text = "".join(lines[:insert_at]) + block + "".join(lines[insert_at:])
    p.write_text(new_text, encoding="utf-8")


def apply_bump(
    *,
    repo_root: Path,
    new_version: str,
    changelog_body: str,
    today_mmddyyyy: str,
) -> str:
    # Snapshot pre-call file contents for rollback.
    toc = repo_root / "CritMatic.toc"
    versions = repo_root / "versions.json"
    changelog = repo_root / "CHANGE_LOG.md"
    snapshots = {p: p.read_text(encoding="utf-8") for p in (toc, versions, changelog) if p.is_file()}

    committed = False
    tag: str | None = None

    try:
        _rewrite_toc(repo_root, new_version)
        _rewrite_versions_json(repo_root, new_version)
        _prepend_changelog(repo_root, new_version, changelog_body, today_mmddyyyy)

        _git(repo_root, "add", "CritMatic.toc", "versions.json", "CHANGE_LOG.md")
        commit = _git(repo_root, "commit", "-m", f"chore(release): v{new_version}")
        if commit.returncode != 0:
            raise RuntimeError(f"git commit failed: {commit.stderr.strip()}")
        committed = True

        tag = _resolve_unique_tag(repo_root, f"v{new_version}")
        tag_proc = _git(repo_root, "tag", "-a", tag, "-m", f"Release {tag}")
        if tag_proc.returncode != 0:
            raise RuntimeError(f"git tag failed: {tag_proc.stderr.strip()}")

        return tag
    except Exception:
        # Rollback: undo tag, undo commit, restore files.
        if tag is not None and _tag_exists(repo_root, tag):
            _git(repo_root, "tag", "-d", tag)
        if committed:
            _git(repo_root, "reset", "--hard", "HEAD~1")
        # Always restore on-disk file content too (covers "not committed yet" failures).
        for p, content in snapshots.items():
            p.write_text(content, encoding="utf-8")
        raise
```

- [ ] **Step 4: Verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_version_bump -v`
Expected: 11 tests pass.

Then full discover:

Run: `PYTHONPATH=. python3 -m unittest discover bot/tests`
Expected: 88 pass (77 prior + 11 new).

- [ ] **Step 5: Commit**

```bash
git add bot/lib/version_bump.py bot/tests/test_version_bump.py
git commit -m "feat(bot): add transactional version bump (toc + json + changelog + tag)"
```

---

## Task 3: Scheduler module

**Files:**
- Create: `bot/lib/scheduler.py`
- Create: `bot/tests/test_scheduler.py`

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

Create `bot/tests/test_scheduler.py`:

```python
import random
import unittest
import unittest.mock
import zoneinfo
from datetime import datetime, time, timedelta
from bot.lib import scheduler


class TestNextPeakTimestamp(unittest.TestCase):
    def test_returns_tz_aware_datetime(self):
        now = datetime(2026, 5, 7, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC"))
        rng = random.Random(0)
        result = scheduler.next_peak_timestamp(now, rng)
        self.assertIsNotNone(result.tzinfo)

    def test_pacific_window_is_tuesday_9_to_11_local(self):
        # Force the rng to pick the Pacific window.
        now = datetime(2026, 5, 7, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC"))
        rng = random.Random()
        with unittest.mock.patch.object(rng, "choice", side_effect=lambda seq: seq[0]):
            result = scheduler.next_peak_timestamp(now, rng)
        result_local = result.astimezone(zoneinfo.ZoneInfo("America/Los_Angeles"))
        self.assertEqual(result_local.weekday(), 1)  # Tuesday
        self.assertGreaterEqual(result_local.hour, 9)
        self.assertLess(result_local.hour, 11)

    def test_cet_window_is_wednesday_9_to_11_local(self):
        now = datetime(2026, 5, 7, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC"))
        rng = random.Random()
        with unittest.mock.patch.object(rng, "choice", side_effect=lambda seq: seq[1]):
            result = scheduler.next_peak_timestamp(now, rng)
        result_local = result.astimezone(zoneinfo.ZoneInfo("Europe/Berlin"))
        self.assertEqual(result_local.weekday(), 2)  # Wednesday
        self.assertGreaterEqual(result_local.hour, 9)
        self.assertLess(result_local.hour, 11)

    def test_jitter_is_millisecond_precise(self):
        now = datetime(2026, 5, 7, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC"))
        seen = set()
        for seed in range(20):
            rng = random.Random(seed)
            result = scheduler.next_peak_timestamp(now, rng)
            seen.add(result.microsecond)
        # 20 different seeds should yield more than 1 distinct microsecond.
        self.assertGreater(len(seen), 5)


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

- [ ] **Step 2: Verify failure**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_scheduler -v`
Expected: ModuleNotFoundError on `bot.lib.scheduler`.

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

Create `bot/lib/scheduler.py`:

```python
"""Phase 3 release scheduler: pick a millisecond-jittered timestamp inside the
next Tue 9-11am US Pacific OR Wed 9-11am CET window.

Returns tz-aware datetimes. The choice between Tue-Pacific and Wed-CET is
random (seedable via the injected `rng` for tests).
"""
import random
import zoneinfo
from datetime import datetime, time, timedelta

PACIFIC = zoneinfo.ZoneInfo("America/Los_Angeles")
CET = zoneinfo.ZoneInfo("Europe/Berlin")
WINDOW_DURATION_MS = 2 * 60 * 60 * 1000  # 2 hours
PACIFIC_WEEKDAY = 1  # Tuesday (Monday=0)
CET_WEEKDAY = 2      # Wednesday
WINDOW_START_HOUR = 9


def _next_window_start(now: datetime, tz: zoneinfo.ZoneInfo, weekday: int) -> datetime:
    """Return the next datetime at 9:00 local in `tz` on the given weekday strictly after `now`."""
    now_local = now.astimezone(tz)
    days_ahead = (weekday - now_local.weekday()) % 7
    candidate = now_local.replace(hour=WINDOW_START_HOUR, minute=0, second=0, microsecond=0) + timedelta(days=days_ahead)
    if candidate <= now_local:
        candidate += timedelta(days=7)
    return candidate


def next_peak_timestamp(now: datetime, rng: random.Random) -> datetime:
    pacific_start = _next_window_start(now, PACIFIC, PACIFIC_WEEKDAY)
    cet_start = _next_window_start(now, CET, CET_WEEKDAY)
    chosen_start = rng.choice([pacific_start, cet_start])
    offset_ms = rng.uniform(0, WINDOW_DURATION_MS)
    return chosen_start + timedelta(milliseconds=offset_ms)
```

- [ ] **Step 4: Verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_scheduler -v`
Expected: 4 tests pass.

Then full discover:

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

- [ ] **Step 5: Commit**

```bash
git add bot/lib/scheduler.py bot/tests/test_scheduler.py
git commit -m "feat(bot): add ms-jittered peak-window scheduler"
```

---

## Task 4: Release runner module

**Files:**
- Create: `bot/lib/release.py`
- Create: `bot/tests/test_release.py`

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

Create `bot/tests/test_release.py`:

```python
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
from bot.lib import release


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

    def test_simple_kv(self):
        p = self.tmp / ".env"
        p.write_text("FOO=bar\nBAZ=qux\n", encoding="utf-8")
        self.assertEqual(release.parse_dotenv(p), {"FOO": "bar", "BAZ": "qux"})

    def test_skips_comments_and_blanks(self):
        p = self.tmp / ".env"
        p.write_text("# a comment\n\nFOO=bar\n   \n# another\nBAZ=qux\n", encoding="utf-8")
        self.assertEqual(release.parse_dotenv(p), {"FOO": "bar", "BAZ": "qux"})

    def test_strips_quotes(self):
        p = self.tmp / ".env"
        p.write_text('FOO="bar"\nBAZ=\'qux\'\n', encoding="utf-8")
        self.assertEqual(release.parse_dotenv(p), {"FOO": "bar", "BAZ": "qux"})

    def test_strips_export_prefix(self):
        p = self.tmp / ".env"
        p.write_text("export FOO=bar\n", encoding="utf-8")
        self.assertEqual(release.parse_dotenv(p), {"FOO": "bar"})


class TestRunRelease(unittest.TestCase):
    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.repo = self.tmp / "repo"
        self.repo.mkdir()
        (self.repo / ".release").mkdir()
        (self.repo / ".release" / "release.sh").write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
        (self.repo / ".env").write_text("CF_API_KEY=secret-key-123\n", encoding="utf-8")

    def test_runs_release_sh_with_cf_api_key_in_env(self):
        captured = {}
        def run(cmd, *args, **kwargs):
            captured["cmd"] = cmd
            captured["env"] = kwargs.get("env", {})
            captured["cwd"] = kwargs.get("cwd")
            return MagicMock(returncode=0, stdout="ok", stderr="")
        with patch("subprocess.run", side_effect=run):
            r = release.run_release(self.repo)
        self.assertTrue(r.success)
        self.assertEqual(r.returncode, 0)
        self.assertEqual(captured["cmd"][0], "bash")
        self.assertIn("release.sh", captured["cmd"][1])
        self.assertEqual(captured["env"].get("CF_API_KEY"), "secret-key-123")
        self.assertEqual(str(captured["cwd"]), str(self.repo))

    def test_returns_failure_on_nonzero(self):
        with patch("subprocess.run", return_value=MagicMock(returncode=2, stdout="", stderr="boom")):
            r = release.run_release(self.repo)
        self.assertFalse(r.success)
        self.assertEqual(r.returncode, 2)
        self.assertEqual(r.stderr, "boom")

    def test_missing_release_sh_raises_filenotfound(self):
        (self.repo / ".release" / "release.sh").unlink()
        with self.assertRaises(FileNotFoundError):
            release.run_release(self.repo)

    def test_missing_dotenv_raises_filenotfound(self):
        (self.repo / ".env").unlink()
        with self.assertRaises(FileNotFoundError):
            release.run_release(self.repo)

    def test_missing_cf_api_key_raises_keyerror(self):
        (self.repo / ".env").write_text("OTHER=value\n", encoding="utf-8")
        with self.assertRaises(KeyError):
            release.run_release(self.repo)


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

- [ ] **Step 2: Verify failure**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_release -v`
Expected: ModuleNotFoundError on `bot.lib.release`.

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

Create `bot/lib/release.py`:

```python
"""Phase 3 release runner: parse .env, invoke `bash .release/release.sh`
with CF_API_KEY injected. Captures stdout/stderr; returns a structured
ReleaseResult. Does not push to git (the detector handles that separately).
"""
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path

RELEASE_TIMEOUT_SECONDS = 600  # 10 minutes


@dataclass(frozen=True)
class ReleaseResult:
    success: bool
    stdout: str
    stderr: str
    returncode: int


def parse_dotenv(path: Path) -> dict[str, str]:
    out: dict[str, str] = {}
    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line[len("export "):].lstrip()
        if "=" not in line:
            continue
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip()
        if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
            value = value[1:-1]
        out[key] = value
    return out


def run_release(repo_root: Path) -> ReleaseResult:
    script = repo_root / ".release" / "release.sh"
    if not script.exists():
        raise FileNotFoundError(f"{script} does not exist")
    dotenv = repo_root / ".env"
    if not dotenv.exists():
        raise FileNotFoundError(f"{dotenv} does not exist")
    env_vars = parse_dotenv(dotenv)
    if "CF_API_KEY" not in env_vars:
        raise KeyError("CF_API_KEY")
    env = {**os.environ, "CF_API_KEY": env_vars["CF_API_KEY"]}
    proc = subprocess.run(
        ["bash", str(script)],
        cwd=str(repo_root),
        env=env,
        capture_output=True, text=True,
        timeout=RELEASE_TIMEOUT_SECONDS, check=False,
    )
    return ReleaseResult(
        success=(proc.returncode == 0),
        stdout=proc.stdout,
        stderr=proc.stderr,
        returncode=proc.returncode,
    )
```

- [ ] **Step 4: Verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_release -v`
Expected: 9 tests pass.

Then full discover:

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

- [ ] **Step 5: Commit**

```bash
git add bot/lib/release.py bot/tests/test_release.py
git commit -m "feat(bot): add release runner with .env parser"
```

---

## Task 5: Approval CLI cancel subcommand + scheduled-releases listing

**Files:**
- Modify: `bot/approve.py`
- Modify: `bot/tests/test_approve.py`

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

Append to `bot/tests/test_approve.py` (after the existing test classes):

```python
class TestApproveCancel(unittest.TestCase):
    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.state_dir = self.tmp / "state"
        self.state_dir.mkdir()
        self.repo_root = self.tmp / "repo"
        self.repo_root.mkdir()
        state.record_pending_release(self.state_dir, "v5.5.8", {
            "branch": "wow", "tag": "v5.5.8",
            "scheduled_at": "2026-05-12T09:14:23.456-07:00",
            "status": "scheduled",
            "fix_branch": "bot/patch-wow-12_0_5",
            "report_path": "bot/reports/x.md",
            "created_at": "2026-05-07T11:24:18Z",
            "retries": 0,
        })

    def test_cancel_removes_entry(self):
        rc, msg = approve.cmd_cancel(self.state_dir, "v5.5.8")
        self.assertEqual(rc, 0)
        self.assertNotIn("v5.5.8", state.load_pending_releases(self.state_dir))

    def test_cancel_unknown_version_errors(self):
        rc, msg = approve.cmd_cancel(self.state_dir, "v9.9.9")
        self.assertNotEqual(rc, 0)
        self.assertIn("not found", msg.lower())


class TestApproveListIncludesReleases(unittest.TestCase):
    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.state_dir = self.tmp / "state"
        self.state_dir.mkdir()

    def test_list_shows_scheduled_releases(self):
        state.record_pending_release(self.state_dir, "v5.5.8", {
            "branch": "wow", "tag": "v5.5.8",
            "scheduled_at": "2026-05-12T09:14:23.456-07:00",
            "status": "scheduled", "retries": 0,
        })
        out = approve.cmd_list(self.state_dir)
        self.assertIn("v5.5.8", out)
        self.assertIn("scheduled", out)
```

- [ ] **Step 2: Verify failure**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_approve -v`
Expected: 3 new tests fail (AttributeError on `cmd_cancel`; assertion failure on listing).

- [ ] **Step 3: Update approve.py**

In `bot/approve.py`, replace the existing `cmd_list` with one that also enumerates pending releases:

```python
def cmd_list(state_dir: Path) -> str:
    fixes = state.load_pending_fixes(state_dir)
    releases = state.load_pending_releases(state_dir)
    if not fixes and not releases:
        return "no pending fixes or scheduled releases"
    parts: list[str] = []
    if fixes:
        parts.append("== PENDING FIXES ==")
        parts.append(f"{'BRANCH':40}  {'STATUS':22}  {'CONF':>4}  REPORT")
        for branch, entry in sorted(fixes.items()):
            parts.append(
                f"{branch:40}  {entry.get('status',''):22}  "
                f"{entry.get('confidence',0):>4}  {entry.get('report_path','')}"
            )
    if releases:
        if parts:
            parts.append("")
        parts.append("== SCHEDULED RELEASES ==")
        parts.append(f"{'VERSION':12}  {'STATUS':12}  {'SCHEDULED_AT':32}  RETRIES")
        for version, entry in sorted(releases.items()):
            parts.append(
                f"{version:12}  {entry.get('status',''):12}  "
                f"{entry.get('scheduled_at',''):32}  {entry.get('retries', 0)}"
            )
    return "\n".join(parts)
```

Add a new function next to `cmd_reject`:

```python
def cmd_cancel(state_dir: Path, version: str) -> tuple[int, str]:
    releases = state.load_pending_releases(state_dir)
    if version not in releases:
        return 1, f"{version}: not found"
    state.remove_pending_release(state_dir, version)
    return 0, f"cancelled scheduled release {version}"
```

In `_main`, register the subcommand and dispatch:

```python
    p_cancel = sub.add_parser("cancel"); p_cancel.add_argument("version")
```

Add the dispatch branch in `_main`:

```python
    if args.cmd == "cancel":
        rc, msg = cmd_cancel(state_dir, args.version)
        print(msg); return rc
```

- [ ] **Step 4: Verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_approve -v`
Expected: 9 tests pass (6 existing + 3 new).

Then full discover:

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

- [ ] **Step 5: Commit**

```bash
git add bot/approve.py bot/tests/test_approve.py
git commit -m "feat(bot): approve.py cancel + list scheduled releases"
```

---

## Task 6: Detector drain + auto-release pipeline

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

This is the largest and most consequential Phase 3 task. The detector gains a "drain due releases" step at the start of every poll, and replaces the Phase 2 `auto-ship-eligible` branch with an auto-release pipeline (with kill-switch fallback to Phase 2 behaviour).

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

Append to `bot/tests/test_detect.py` (after the existing tests):

```python
    def test_drain_fires_release_when_due(self):
        from bot.lib.release import ReleaseResult
        # Stage a pending release scheduled in the past.
        from bot.lib import state as state_mod
        state_mod.record_pending_release(self.state_dir, "v5.5.8", {
            "branch": "wow", "tag": "v5.5.8",
            "scheduled_at": "2020-01-01T00:00:00+00:00",  # in the past
            "status": "scheduled",
            "fix_branch": "bot/x",
            "report_path": "bot/reports/x.md",
            "created_at": "t",
            "retries": 0,
        })
        with patch("bot.detect.fetch_builds", return_value=[]), \
             patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")) as sp, \
             patch("bot.detect.run_release", return_value=ReleaseResult(success=True, stdout="ok", stderr="", returncode=0)) as rr, \
             patch("bot.detect.notify"):
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        rr.assert_called_once()
        push_calls = [c for c in sp.call_args_list if "push" in (c.args[0] if c.args else [])]
        self.assertTrue(len(push_calls) >= 1)
        # entry removed on success
        self.assertNotIn("v5.5.8", state_mod.load_pending_releases(self.state_dir))

    def test_drain_does_not_fire_when_not_due(self):
        from bot.lib import state as state_mod
        state_mod.record_pending_release(self.state_dir, "v5.5.9", {
            "branch": "wow", "tag": "v5.5.9",
            "scheduled_at": "2099-01-01T00:00:00+00:00",  # far future
            "status": "scheduled", "fix_branch": "bot/x",
            "report_path": "bot/reports/x.md", "created_at": "t", "retries": 0,
        })
        with patch("bot.detect.fetch_builds", return_value=[]), \
             patch("bot.detect.run_release") as rr, \
             patch("bot.detect.notify"):
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        rr.assert_not_called()
        # entry preserved
        self.assertIn("v5.5.9", state_mod.load_pending_releases(self.state_dir))

    def test_drain_skipped_when_auto_release_disabled(self):
        from bot.lib import state as state_mod
        (self.state_dir / "auto-release-disabled").touch()
        state_mod.record_pending_release(self.state_dir, "v5.5.9", {
            "branch": "wow", "tag": "v5.5.9",
            "scheduled_at": "2020-01-01T00:00:00+00:00",
            "status": "scheduled", "fix_branch": "bot/x",
            "report_path": "bot/reports/x.md", "created_at": "t", "retries": 0,
        })
        with patch("bot.detect.fetch_builds", return_value=[]), \
             patch("bot.detect.run_release") as rr, \
             patch("bot.detect.notify"):
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        rr.assert_not_called()
        # entry preserved (paused, not cancelled)
        self.assertIn("v5.5.9", state_mod.load_pending_releases(self.state_dir))

    def test_drain_increments_retries_on_release_failure(self):
        from bot.lib.release import ReleaseResult
        from bot.lib import state as state_mod
        state_mod.record_pending_release(self.state_dir, "v5.5.9", {
            "branch": "wow", "tag": "v5.5.9",
            "scheduled_at": "2020-01-01T00:00:00+00:00",
            "status": "scheduled", "fix_branch": "bot/x",
            "report_path": "bot/reports/x.md", "created_at": "t", "retries": 0,
        })
        with patch("bot.detect.fetch_builds", return_value=[]), \
             patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")), \
             patch("bot.detect.run_release", return_value=ReleaseResult(success=False, stdout="", stderr="boom", returncode=1)), \
             patch("bot.detect.notify"):
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        entry = state_mod.load_pending_releases(self.state_dir)["v5.5.9"]
        self.assertEqual(entry["retries"], 1)

    def test_auto_release_pipeline_records_pending_release(self):
        from bot.lib.fixer import FixResult
        from bot.lib.verify import VerifyResult, VerifyRow
        from bot.lib import state as state_mod
        # Provide a real CritMatic.toc + versions.json + CHANGE_LOG.md so version_bump can run.
        (self.repo_root / "CritMatic.toc").write_text("## Version: 5.5.7\n", encoding="utf-8")
        (self.repo_root / "versions.json").write_text('{"critmatic_version": "5.5.7"}\n', encoding="utf-8")
        (self.repo_root / "CHANGE_LOG.md").write_text("# Changelog\n\n", encoding="utf-8")
        (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")
        worktree = self.tmp / "wt_pipeline"
        worktree.mkdir()
        fix = FixResult(branch="bot/patch-wow-11_3_0", worktree_path=worktree, commit_sha="abc",
                        confidence=92, rationale="renamed", files_changed=["CritMatic.lua"], claude_exit_code=0)
        vr = VerifyResult(passed_all=True, rows=[VerifyRow("luac -p", True, "ok")])
        # cmd_merge mocked to succeed (avoids needing real git state in test repo).
        with patch("bot.detect.fetch_builds", return_value=builds), \
             patch("bot.detect.run_analyzer", return_value=(2, report_path, {"X"})), \
             patch("bot.detect.propose_fix", return_value=fix), \
             patch("bot.detect.run_verify", return_value=vr), \
             patch("bot.detect.cmd_merge", return_value=(0, "merged")), \
             patch("bot.detect.apply_bump", return_value="v5.5.8"), \
             patch("bot.detect._count_diff_lines", return_value=10), \
             patch("bot.detect.notify"):
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        releases = state_mod.load_pending_releases(self.state_dir)
        self.assertEqual(len(releases), 1)
        rel = next(iter(releases.values()))
        self.assertEqual(rel["tag"], "v5.5.8")
        self.assertEqual(rel["status"], "scheduled")
        self.assertEqual(rel["retries"], 0)

    def test_auto_release_pipeline_falls_back_to_phase2_when_killswitched(self):
        from bot.lib.fixer import FixResult
        from bot.lib.verify import VerifyResult, VerifyRow
        from bot.lib import state as state_mod
        (self.state_dir / "auto-release-disabled").touch()
        (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")
        worktree = self.tmp / "wt_kill"
        worktree.mkdir()
        fix = FixResult(branch="bot/patch-wow-11_3_0", worktree_path=worktree, commit_sha="abc",
                        confidence=92, rationale="x", files_changed=[], claude_exit_code=0)
        vr = VerifyResult(passed_all=True, rows=[VerifyRow("luac -p", True, "ok")])
        with patch("bot.detect.fetch_builds", return_value=builds), \
             patch("bot.detect.run_analyzer", return_value=(2, report_path, {"X"})), \
             patch("bot.detect.propose_fix", return_value=fix), \
             patch("bot.detect.run_verify", return_value=vr), \
             patch("bot.detect.cmd_merge") as cm, \
             patch("bot.detect.apply_bump") as ab, \
             patch("bot.detect.notify"):
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        cm.assert_not_called()
        ab.assert_not_called()
        # Falls back to Phase 2 pending-fix recording.
        fixes = state_mod.load_pending_fixes(self.state_dir)
        self.assertIn(fix.branch, fixes)
        self.assertEqual(fixes[fix.branch]["status"], "auto-ship-eligible")
        # No release scheduled.
        self.assertEqual(state_mod.load_pending_releases(self.state_dir), {})
```

- [ ] **Step 2: Verify failure**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_detect -v`
Expected: 6 new tests fail (AttributeError on `bot.detect.run_release` / `bot.detect.cmd_merge` / `bot.detect.apply_bump` / `bot.detect._count_diff_lines`). Existing 12 still pass.

- [ ] **Step 3: Update detect.py imports**

At the top of `bot/detect.py`, add new imports:

```python
import random
from bot.approve import cmd_merge
from bot.lib.release import ReleaseResult, run_release
from bot.lib.scheduler import next_peak_timestamp
from bot.lib.version_bump import apply_bump, decide_bump_kind, next_version, read_current_version
```

- [ ] **Step 4: Add helper functions**

Add these helpers near the existing `_append_notification_log`:

```python
RELEASE_RETRY_CAP = 3


def _count_diff_lines(repo_root: Path) -> int:
    """Lines added + deleted between the last commit and its parent."""
    proc = subprocess.run(
        ["git", "-C", str(repo_root), "diff", "--shortstat", "HEAD~1..HEAD"],
        capture_output=True, text=True, check=False,
    )
    text = proc.stdout
    ins = del_ = 0
    import re as _re
    m_i = _re.search(r"(\d+) insertions?\(\+\)", text)
    m_d = _re.search(r"(\d+) deletions?\(-\)", text)
    if m_i:
        ins = int(m_i.group(1))
    if m_d:
        del_ = int(m_d.group(1))
    return ins + del_


def _drain_due_releases(state_dir: Path, repo_root: Path) -> None:
    if (state_dir / "auto-release-disabled").exists():
        return
    pending = state.load_pending_releases(state_dir)
    now = _dt.datetime.now(_dt.timezone.utc)
    for version, entry in list(pending.items()):
        try:
            scheduled = _dt.datetime.fromisoformat(entry["scheduled_at"])
        except (KeyError, ValueError):
            continue
        if scheduled.tzinfo is None:
            scheduled = scheduled.replace(tzinfo=_dt.timezone.utc)
        if scheduled > now:
            continue
        push = subprocess.run(
            ["git", "-C", str(repo_root), "push", "origin", "master", entry["tag"]],
            capture_output=True, text=True, check=False,
        )
        if push.returncode != 0:
            _bump_release_retry(state_dir, version, entry, push.stderr.strip() or push.stdout.strip())
            continue
        try:
            rr = run_release(repo_root)
        except FileNotFoundError as exc:
            notify(
                title="CritMatic patch watcher",
                subtitle=f"release {version} blocked",
                body=f"missing file: {exc}",
            )
            _append_notification_log(
                state_dir,
                f"{_now_iso()}  status=release-blocked  version={version}  error={exc}",
            )
            continue
        except KeyError as exc:
            notify(
                title="CritMatic patch watcher",
                subtitle=f"release {version} blocked",
                body=f"missing env var: {exc}",
            )
            _append_notification_log(
                state_dir,
                f"{_now_iso()}  status=release-blocked  version={version}  error=missing_env:{exc}",
            )
            continue
        if rr.success:
            state.remove_pending_release(state_dir, version)
            notify(
                title="CritMatic patch watcher",
                subtitle=f"released {version}",
                body=f"{entry.get('tag', version)} pushed and uploaded",
            )
            _append_notification_log(
                state_dir,
                f"{_now_iso()}  status=released  version={version}  tag={entry.get('tag', version)}",
            )
        else:
            _bump_release_retry(state_dir, version, entry, rr.stderr.strip() or rr.stdout.strip())


def _bump_release_retry(state_dir: Path, version: str, entry: dict, error: str) -> None:
    entry = {**entry, "retries": int(entry.get("retries", 0)) + 1, "last_error": error}
    state.record_pending_release(state_dir, version, entry)
    if entry["retries"] >= RELEASE_RETRY_CAP:
        notify(
            title="CritMatic patch watcher",
            subtitle=f"release {version} blocked",
            body=f"{entry['retries']} attempts failed; manual push needed",
        )
    _append_notification_log(
        state_dir,
        f"{_now_iso()}  status=release-failed  version={version}  attempt={entry['retries']}  error={error}",
    )


def _make_pending_fix_entry(report_path, status, fix, vr, detected_at, removed_symbols):
    return {
        "report_path": str(report_path),
        "status": status,
        "confidence": fix.confidence,
        "worktree_path": str(fix.worktree_path),
        "created_at": detected_at,
        "verify_rows": [
            {"name": r.name, "passed": r.passed, "detail": r.detail}
            for r in vr.rows
        ],
        "rationale": fix.rationale,
        "removed_symbols": sorted(removed_symbols),
    }


def _auto_release_pipeline(
    *, state_dir, repo_root, fix, vr, branch, new_version, removed_symbols,
    detected_at, report_path,
):
    # 1. Record the pending fix so cmd_merge has it.
    state.record_pending_fix(state_dir, fix.branch,
                             _make_pending_fix_entry(report_path, "auto-ship-eligible",
                                                     fix, vr, detected_at, removed_symbols))
    # 2. Auto-merge (re-runs verify last-mile, ff-only).
    rc, msg = cmd_merge(state_dir, repo_root, fix.branch)
    if rc != 0:
        notify(
            title="CritMatic patch watcher",
            subtitle=f"{branch} {new_version}",
            body=f"auto-merge blocked: {msg}",
            open_path=str(report_path),
        )
        _append_notification_log(
            state_dir,
            f"{detected_at}  {branch} {new_version}  status=auto-merge-blocked  fix-branch={fix.branch}  report={report_path}",
        )
        return
    # 3. Version bump.
    files_changed = len(fix.files_changed)
    diff_lines = _count_diff_lines(repo_root)
    kind = decide_bump_kind(files_changed, diff_lines)
    current = read_current_version(repo_root)
    new_v = next_version(current, kind)
    body = f"Compatibility fix for WoW {new_version}. {fix.rationale}. Files: {', '.join(fix.files_changed) or '(none)'}."
    today = _dt.datetime.now(_dt.timezone.utc).strftime("%m/%d/%Y")
    try:
        tag = apply_bump(
            repo_root=repo_root, new_version=new_v,
            changelog_body=body, today_mmddyyyy=today,
        )
    except Exception as exc:
        subprocess.run(["git", "-C", str(repo_root), "reset", "--hard", "HEAD~1"],
                       capture_output=True, text=True, check=False)
        notify(
            title="CritMatic patch watcher",
            subtitle=f"{branch} {new_version}",
            body=f"version bump failed: {exc}; auto-merge rolled back",
            open_path=str(report_path),
        )
        _append_notification_log(
            state_dir,
            f"{detected_at}  {branch} {new_version}  status=version-bump-failed  error={exc}",
        )
        return
    # 4. Schedule.
    rng = random.Random()
    scheduled_at = next_peak_timestamp(_dt.datetime.now(_dt.timezone.utc), rng)
    state.record_pending_release(state_dir, new_v, {
        "branch": branch, "tag": tag,
        "scheduled_at": scheduled_at.isoformat(),
        "status": "scheduled",
        "fix_branch": fix.branch,
        "report_path": str(report_path),
        "created_at": detected_at,
        "retries": 0,
    })
    notify(
        title="CritMatic patch watcher",
        subtitle=f"{branch} {new_version}",
        body=f"scheduled {tag} for {scheduled_at.isoformat()}",
        open_path=str(report_path),
    )
    _append_notification_log(
        state_dir,
        f"{detected_at}  {branch} {new_version}  status=scheduled  tag={tag}  at={scheduled_at.isoformat()}",
    )
```

- [ ] **Step 5: Wire the drain step + the pipeline branch into `run`**

In `bot/detect.py`'s `run` function, immediately after the `is_disabled` check (and before the `try: builds = fetch_builds(...)`), add:

```python
    _drain_due_releases(state_dir, repo_root)
```

Then in the same `run` function, locate the existing block where we determine `status`:

```python
                    if vr.passed_all and fix.confidence >= CONFIDENCE_THRESHOLD:
                        status = "auto-ship-eligible"
                    else:
                        status = "held-for-review"
                    state.record_pending_fix(state_dir, fix.branch, {
                        ...
                    })
                    failed_names = [r.name for r in vr.rows if not r.passed]
                    body = (
                        f"{status}: {fix.branch}  confidence={fix.confidence}"
                        ...
                    )
                    notify(...)
                    log_line = (...)
                    _append_notification_log(state_dir, log_line)
```

REPLACE that entire block (everything inside the `try` after `vr = run_verify(...)` and before the `except Exception as exc:`) with:

```python
                    if vr.passed_all and fix.confidence >= CONFIDENCE_THRESHOLD:
                        if (state_dir / "auto-release-disabled").exists():
                            # Phase 2 fallback: record pending-fix, manual approval.
                            state.record_pending_fix(state_dir, fix.branch,
                                _make_pending_fix_entry(report_path, "auto-ship-eligible",
                                                        fix, vr, detected_at, removed_symbols))
                            notify(
                                title="CritMatic patch watcher",
                                subtitle=f"{branch} {new_version}",
                                body=f"auto-ship-eligible (auto-release paused): {fix.branch}",
                                open_path=str(report_path),
                            )
                            _append_notification_log(
                                state_dir,
                                f"{detected_at}  {branch} {new_version}  status=auto-ship-eligible-paused  fix-branch={fix.branch}",
                            )
                        else:
                            _auto_release_pipeline(
                                state_dir=state_dir, repo_root=repo_root,
                                fix=fix, vr=vr, branch=branch, new_version=new_version,
                                removed_symbols=removed_symbols, detected_at=detected_at,
                                report_path=report_path,
                            )
                    else:
                        # held-for-review: record pending-fix, notify with failed checks.
                        state.record_pending_fix(state_dir, fix.branch,
                            _make_pending_fix_entry(report_path, "held-for-review",
                                                    fix, vr, detected_at, removed_symbols))
                        failed_names = [r.name for r in vr.rows if not r.passed]
                        body = (
                            f"held-for-review: {fix.branch}  confidence={fix.confidence}"
                            + (f"  failed=[{'; '.join(failed_names)}]" if failed_names else "")
                        )
                        notify(
                            title="CritMatic patch watcher",
                            subtitle=f"{branch} {new_version}",
                            body=body,
                            open_path=str(report_path),
                        )
                        log_line = (
                            f"{detected_at}  {branch} {new_version}  status=held-for-review"
                            f"  fix-branch={fix.branch}  confidence={fix.confidence}"
                            f"  report={report_path}"
                        )
                        if failed_names:
                            log_line += f"  failed={'; '.join(failed_names)}"
                        _append_notification_log(state_dir, log_line)
```

- [ ] **Step 6: Verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_detect -v`
Expected: 18 tests pass (12 existing + 6 new).

Then full discover:

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

- [ ] **Step 7: Commit**

```bash
git add bot/detect.py bot/tests/test_detect.py
git commit -m "feat(bot): drain step + auto-release pipeline (Phase 3)"
```

---

## Task 7: launchd plist cadence + README

**Files:**
- Modify: `bot/plist/com.critmatic.bot.plist`
- Modify: `bot/README.md`

- [ ] **Step 1: Update plist**

In `bot/plist/com.critmatic.bot.plist`, change `<integer>14400</integer>` (immediately under `<key>StartInterval</key>`) to `<integer>900</integer>`.

Verify well-formed:
```bash
python3 -c "import plistlib; plistlib.loads(open('bot/plist/com.critmatic.bot.plist','rb').read())"
```
Expected: no error.

- [ ] **Step 2: Update README**

In `bot/README.md`, immediately after the existing `## Auto-fix proposals (Phase 2)` section but before `## Run the test suite`, insert:

```markdown
## Auto-release (Phase 3)

When the analyzer produces an `auto-ship-eligible` fix, the bot:

1. Auto-merges the fix branch into local `master` (re-runs the 5-check verify gate; ff-only).
2. Bumps the version in `CritMatic.toc`, `versions.json`, and `CHANGE_LOG.md` (minor if the fix touched >3 files OR >50 diff lines, else patch).
3. Creates an annotated tag `vX.Y.Z` (with `-1`, `-2`, ... suffix on collision).
4. Schedules the release for a millisecond-jittered timestamp inside the next Tue 9-11am US Pacific OR Wed 9-11am CET window.

On every poll (every 15 min), the bot checks `bot/state/pending-releases.json` for any release whose `scheduled_at <= now`. For each due release it `git push origin master <tag>` and runs `bash .release/release.sh` with `CF_API_KEY` sourced from `.env`. On success the entry is removed; on failure the `retries` counter increments (cap 3) and the next poll retries.

### Auto-release kill switch

```bash
# Pause auto-releases (eligible fixes go to manual approval queue instead;
# already-scheduled releases are held but NOT cancelled until you cancel them):
touch bot/state/auto-release-disabled

# Resume:
rm bot/state/auto-release-disabled
```

### Cancel a scheduled release before it fires

```bash
PYTHONPATH=. python3 bot/approve.py cancel v5.5.8
```

### Tighter polling cadence

Phase 3 changes the launchd `StartInterval` from 4h to 15 min so scheduled releases land within ±15 min of their target. After updating from a Phase 1/2 install, re-bootstrap the agent:

```bash
launchctl bootout gui/$UID ~/Library/LaunchAgents/com.critmatic.bot.plist
sed "s|__REPO_PATH__|$(pwd)|g; s|__PYTHON3__|$(which python3)|g" \
  bot/plist/com.critmatic.bot.plist \
  > ~/Library/LaunchAgents/com.critmatic.bot.plist
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.critmatic.bot.plist
```
```

- [ ] **Step 3: Commit**

```bash
git add bot/plist/com.critmatic.bot.plist bot/README.md
git commit -m "feat(bot): tighten launchd to 15min + document Phase 3"
```

---

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

Manual checklist. No code changes; uses fakes for the irreversible bits.

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

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

- [ ] **Step 2: Save fake claude shim**

```bash
cat > /tmp/fake-claude <<'SH'
#!/bin/bash
echo "fake claude invoked in $(pwd)"
if [ -f CritMatic.lua ]; then
  echo "-- bot fake fix" >> CritMatic.lua
fi
echo
echo '{"confidence": 95, "rationale": "fake substitution", "files_changed": ["CritMatic.lua"]}'
SH
chmod +x /tmp/fake-claude
ln -sf /tmp/fake-claude /tmp/claude
```

- [ ] **Step 3: Save fake .release/release.sh**

```bash
# Backup the real release.sh if present
[ -f .release/release.sh.real ] || cp .release/release.sh .release/release.sh.real 2>/dev/null
cat > .release/release.sh <<'SH'
#!/bin/bash
echo "fake release.sh invoked"
echo "CF_API_KEY length: ${#CF_API_KEY}"
exit 0
SH
chmod +x .release/release.sh
```

- [ ] **Step 4: Stage a pending release in the past, verify drain fires**

```bash
# Cleanup any prior bot state from earlier runs
rm -f bot/state/pending-fixes.json bot/state/pending-releases.json
git worktree list | awk '/CritMatic-bot/ {print $1}' | xargs -I{} git worktree remove --force {} 2>/dev/null
git branch | grep '^  bot/patch-' | xargs -r git branch -D 2>/dev/null
git tag -l 'v5.5.999*' | xargs -r git tag -d

PYTHONPATH=. python3 - <<'PY'
import json
from pathlib import Path
from bot.lib import state
state.record_pending_release(Path("bot/state"), "v5.5.999", {
    "branch": "wow", "tag": "v5.5.999",
    "scheduled_at": "2020-01-01T00:00:00+00:00",  # in the past
    "status": "scheduled", "fix_branch": "manual-test",
    "report_path": "bot/reports/manual.md",
    "created_at": "manual-test", "retries": 0,
})
print("staged v5.5.999")
PY
```

Then create a v5.5.999 tag locally so the push step has something to push:

```bash
git tag -a v5.5.999 -m "Manual test tag"
```

Now run the detector and observe the drain attempt:

```bash
PATH="/tmp:$PATH" PYTHONPATH=. python3 bot/detect.py 2>&1 | tail -20
```

Expected:
- `git push origin master v5.5.999` runs (will likely fail because origin doesn't have a v5.5.999 history match; that's expected)
- If the push fails, the entry's `retries` increments
- `cat bot/state/pending-releases.json` shows `retries: 1`

If you don't want to actually attempt the push, pre-empt by also touching the kill switch first:

```bash
touch bot/state/auto-release-disabled
PATH="/tmp:$PATH" PYTHONPATH=. python3 bot/detect.py 2>&1 | tail -5
rm bot/state/auto-release-disabled
```

Expected: drain skipped, no push attempt, entry preserved.

- [ ] **Step 5: Test the cancel CLI**

```bash
PYTHONPATH=. python3 bot/approve.py
PYTHONPATH=. python3 bot/approve.py cancel v5.5.999
PYTHONPATH=. python3 bot/approve.py
```

Expected: list shows the scheduled release; cancel removes it; second list shows none.

- [ ] **Step 6: Final cleanup**

```bash
rm -f /tmp/fake-claude /tmp/claude
git tag -d v5.5.999 2>/dev/null
[ -f .release/release.sh.real ] && mv .release/release.sh.real .release/release.sh
rm -f bot/state/pending-fixes.json bot/state/pending-releases.json bot/state/auto-release-disabled
git worktree list | awk '/CritMatic-bot/ {print $1}' | xargs -I{} git worktree remove --force {} 2>/dev/null
git branch | grep '^  bot/patch-' | xargs -r git branch -D 2>/dev/null
```

- [ ] **Step 7: Re-bootstrap launchd to pick up the 15-min cadence**

```bash
launchctl bootout gui/$UID ~/Library/LaunchAgents/com.critmatic.bot.plist 2>/dev/null
sed "s|__REPO_PATH__|$(pwd)|g; s|__PYTHON3__|$(which python3)|g" \
  bot/plist/com.critmatic.bot.plist \
  > ~/Library/LaunchAgents/com.critmatic.bot.plist
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.critmatic.bot.plist
launchctl print gui/$UID/com.critmatic.bot 2>&1 | grep -E "(state|StartInterval|interval)"
```

Expected: `state = running`, interval shows 900.

---

## Definition of done

- All unit tests pass: ~110 total.
- Manual end-to-end (Task 8) passes; drain + cancel CLI both work as expected.
- launchd plist updated to 15-min cadence and re-bootstrapped.
- README documents Phase 3 behaviour, kill switch, cancel CLI, and re-bootstrap step.
- The bot continues running via launchd and picks up Phase 3 on its next scheduled poll.
