# CritMatic Patch Watcher (Phase 2) 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:** Add an autonomous auto-fix proposer (invokes `claude -p` in an isolated git worktree) and a five-check verification gate that labels each proposed fix branch as `auto-ship-eligible`, `held-for-review`, or `manual-review-needed`, plus a small approval CLI for the user to merge or reject pending fixes locally.

**Architecture:** Two new modules under `bot/lib/` (`fixer.py` + `verify.py`), one new entry-point CLI (`bot/approve.py`), one new state file (`bot/state/pending-fixes.json`), and small extensions to `bot/lib/state.py`, `bot/analyze.py`, and `bot/detect.py`. The fixer creates worktrees at `<repo_parent>/CritMatic-bot/<branch_name>`, runs `claude -p` with a constrained tool set inside the worktree, parses Claude's last-line JSON, commits the diff, and hands off to the verifier. The verifier runs `luac -p`, the BigWigs packager dry-run (`-d -z`), a re-grep for removed symbols, a path allowlist regex, and a diff-line-count cap. The detector's existing `count > 0` block becomes a three-way fork (skip-Claude / fixer-then-verify / unchanged). Approval CLI re-runs the verify gate before any merge and refuses anything that isn't a fast-forward.

**Tech Stack:** Python 3 stdlib only (`subprocess`, `json`, `re`, `pathlib`, `argparse`, `unittest`). Calls out to `git`, `claude`, `luac`, and `bash .release/release.sh` via `subprocess`. macOS notifications via the existing `bot/lib/notify.py`.

**Spec deviations from `docs/superpowers/specs/2026-05-07-critmatic-patch-watcher-phase-2-design.md`:** None planned. The plan implements the spec verbatim. If a task surfaces a real friction with the spec during implementation, the implementer should pause and report rather than silently deviate.

---

## File Structure

**New files:**

| Path | Responsibility |
|------|----------------|
| `bot/lib/fixer.py` | `propose_fix(*, report_path, branch_name, removed_symbols, repo_root) -> FixResult`. Creates worktree, runs `claude -p`, commits, parses confidence JSON. |
| `bot/lib/verify.py` | `verify(*, worktree_path, removed_symbols, repo_root) -> VerifyResult`. Runs the five objective checks in order. |
| `bot/approve.py` | CLI: `list` (default), `show <branch>`, `merge <branch>`, `reject <branch>`. |
| `bot/tests/test_fixer.py` | Unit tests for fixer (mock `subprocess.run` for git + claude). |
| `bot/tests/test_verify.py` | Unit tests for verifier (mock `subprocess.run` for git + luac + release.sh). |
| `bot/tests/test_approve.py` | Unit tests for the CLI (mock subprocess). |

**Modified files:**

| Path | Change |
|------|--------|
| `bot/lib/state.py` | Add `PENDING_FIXES_FILENAME`, `load_pending_fixes`, `record_pending_fix`, `remove_pending_fix`. |
| `bot/tests/test_state.py` | Add roundtrip tests for the three new helpers. |
| `bot/analyze.py` | Change `run()` signature to return `tuple[int, Path, set[str]]` (also returns the diff's `removed` set). |
| `bot/tests/test_analyze.py` | Update existing test to assert the third element. |
| `bot/detect.py` | Replace the existing `if count > 0: notify(...)` block with the Phase 2 three-way fork (manual-review / fixer+verify / nothing). |
| `bot/tests/test_detect.py` | Add tests for the three new code paths; existing tests remain. |
| `bot/README.md` | Document `bot/approve.py`, the worktree layout, and the new pending-fixes state file. |

---

## Task 1: State pending-fix 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`):

```python
    def test_pending_fixes_empty_when_missing(self):
        self.assertEqual(state.load_pending_fixes(self.tmp), {})

    def test_record_pending_fix_then_load(self):
        entry = {
            "report_path": "bot/reports/x.md",
            "status": "auto-ship-eligible",
            "confidence": 88,
            "worktree_path": "/tmp/wt",
            "created_at": "2026-05-07T11:24:18Z",
            "verify_rows": [],
            "rationale": "ok",
        }
        state.record_pending_fix(self.tmp, "bot/patch-x", entry)
        loaded = state.load_pending_fixes(self.tmp)
        self.assertEqual(loaded["bot/patch-x"], entry)

    def test_remove_pending_fix(self):
        state.record_pending_fix(self.tmp, "bot/a", {"status": "held-for-review"})
        state.record_pending_fix(self.tmp, "bot/b", {"status": "auto-ship-eligible"})
        state.remove_pending_fix(self.tmp, "bot/a")
        loaded = state.load_pending_fixes(self.tmp)
        self.assertNotIn("bot/a", loaded)
        self.assertIn("bot/b", loaded)

    def test_remove_pending_fix_missing_is_noop(self):
        state.remove_pending_fix(self.tmp, "bot/never-existed")
        self.assertEqual(state.load_pending_fixes(self.tmp), {})
```

- [ ] **Step 2: Run to 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_fixes'`.

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

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

```python
PENDING_FIXES_FILENAME = "pending-fixes.json"


def _pending_fixes_path(state_dir: Path) -> Path:
    return state_dir / PENDING_FIXES_FILENAME


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


def record_pending_fix(state_dir: Path, branch: str, entry: dict) -> None:
    fixes = load_pending_fixes(state_dir)
    fixes[branch] = entry
    p = _pending_fixes_path(state_dir)
    tmp = p.with_suffix(p.suffix + ".tmp")
    tmp.write_text(json.dumps(fixes, indent=2, sort_keys=True), encoding="utf-8")
    os.replace(tmp, p)


def remove_pending_fix(state_dir: Path, branch: str) -> None:
    fixes = load_pending_fixes(state_dir)
    if branch not in fixes:
        return
    del fixes[branch]
    p = _pending_fixes_path(state_dir)
    tmp = p.with_suffix(p.suffix + ".tmp")
    tmp.write_text(json.dumps(fixes, indent=2, sort_keys=True), encoding="utf-8")
    os.replace(tmp, p)
```

- [ ] **Step 4: Run to verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_state -v`
Expected: 10 tests total pass (6 existing + 4 new).

- [ ] **Step 5: Commit**

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

---

## Task 2: Fixer module

**Files:**
- Create: `bot/lib/fixer.py`
- Create: `bot/tests/test_fixer.py`

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

Create `bot/tests/test_fixer.py`:

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


class TestProposeFix(unittest.TestCase):
    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.repo = self.tmp / "repo"
        self.repo.mkdir()
        self.report = self.repo / "report.md"
        self.report.write_text("# patch report\n\n- removed: GetOldThing\n", encoding="utf-8")

    def _fake_subprocess_factory(self, claude_stdout="", claude_returncode=0, diff_output="M\tCritMatic.lua\n"):
        """Returns a side_effect function for subprocess.run that fakes git and claude."""
        def run(cmd, *args, **kwargs):
            result = MagicMock(returncode=0, stdout="", stderr="")
            if cmd[0] == "git" and "worktree" in cmd:
                pass  # just succeed
            elif cmd[0] == "git" and cmd[1:3] == ["-C"] and "diff" in cmd and "--name-only" in cmd:
                result.stdout = diff_output
            elif cmd[0] == "git" and cmd[1:3] == ["-C"] and "add" in cmd:
                pass
            elif cmd[0] == "git" and cmd[1:3] == ["-C"] and "commit" in cmd:
                pass
            elif cmd[0] == "git" and cmd[1:3] == ["-C"] and "rev-parse" in cmd:
                result.stdout = "deadbeef\n"
            elif cmd[0] == "claude":
                result.stdout = claude_stdout
                result.returncode = claude_returncode
            return result
        return run

    def test_creates_worktree_runs_claude_and_returns_fix_result(self):
        valid_json = '{"confidence": 88, "rationale": "renamed", "files_changed": ["CritMatic.lua"]}'
        with patch("subprocess.run", side_effect=self._fake_subprocess_factory(
            claude_stdout=f"...some output...\n{valid_json}\n",
            diff_output="CritMatic.lua\n",
        )):
            result = fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-test",
                removed_symbols={"GetOldThing"},
                repo_root=self.repo,
            )
        self.assertEqual(result.branch, "bot/patch-test")
        self.assertEqual(result.confidence, 88)
        self.assertEqual(result.rationale, "renamed")
        self.assertEqual(result.files_changed, ["CritMatic.lua"])
        self.assertEqual(result.claude_exit_code, 0)
        self.assertEqual(result.commit_sha, "deadbeef")

    def test_no_diff_means_no_commit(self):
        valid_json = '{"confidence": 0, "rationale": "no edits", "files_changed": []}'
        with patch("subprocess.run", side_effect=self._fake_subprocess_factory(
            claude_stdout=valid_json + "\n",
            diff_output="",  # empty diff
        )):
            result = fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-empty",
                removed_symbols={"X"},
                repo_root=self.repo,
            )
        self.assertIsNone(result.commit_sha)

    def test_unparseable_json_yields_zero_confidence(self):
        with patch("subprocess.run", side_effect=self._fake_subprocess_factory(
            claude_stdout="garbage no json here\n",
            diff_output="CritMatic.lua\n",
        )):
            result = fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-bad",
                removed_symbols={"X"},
                repo_root=self.repo,
            )
        self.assertEqual(result.confidence, 0)
        self.assertEqual(result.rationale, "claude output unparseable")

    def test_claude_nonzero_exit_still_returns_result(self):
        with patch("subprocess.run", side_effect=self._fake_subprocess_factory(
            claude_stdout="",
            claude_returncode=2,
            diff_output="",
        )):
            result = fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-fail",
                removed_symbols={"X"},
                repo_root=self.repo,
            )
        self.assertEqual(result.claude_exit_code, 2)
        self.assertEqual(result.confidence, 0)

    def test_prompt_includes_report_text_and_removed_symbols(self):
        captured = {}
        def capture(cmd, *args, **kwargs):
            result = MagicMock(returncode=0, stdout="", stderr="")
            if cmd[0] == "claude":
                # Find the prompt argument; it is the positional arg after `-p`.
                idx = cmd.index("-p") + 1
                captured["prompt"] = cmd[idx]
                result.stdout = '{"confidence": 50, "rationale": "x", "files_changed": []}\n'
            elif cmd[0] == "git" and "diff" in cmd and "--name-only" in cmd:
                result.stdout = ""
            elif cmd[0] == "git" and "rev-parse" in cmd:
                result.stdout = "abc\n"
            return result

        with patch("subprocess.run", side_effect=capture):
            fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-prompt",
                removed_symbols={"GetOldThing", "GetOtherDeadAPI"},
                repo_root=self.repo,
            )
        self.assertIn("# patch report", captured["prompt"])
        self.assertIn("GetOldThing", captured["prompt"])
        self.assertIn("GetOtherDeadAPI", captured["prompt"])
        self.assertIn("confidence", captured["prompt"])
        # Scope rule explicitly mentioned:
        self.assertIn("Libs/", captured["prompt"])


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

- [ ] **Step 2: Run to verify failure**

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

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

Create `bot/lib/fixer.py`:

```python
"""Phase 2: auto-fix proposer. Runs `claude -p` in an isolated git worktree
to propose a fix for a detected WoW patch breakage, commits the diff, and
returns a structured FixResult for the verifier and detector to consume.

The bot never invokes claude with --dangerously-skip-permissions; the tool
allowlist is restricted to read/write/grep operations only.
"""
import json
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path

CLAUDE_MAX_TURNS = 30
CLAUDE_TIMEOUT_SECONDS = 300
CLAUDE_ALLOWED_TOOLS = "Read,Edit,Write,Glob,Grep"
WORKTREE_PARENT_NAME = "CritMatic-bot"


@dataclass(frozen=True)
class FixResult:
    branch: str
    worktree_path: Path
    commit_sha: str | None
    confidence: int
    rationale: str
    files_changed: list[str] = field(default_factory=list)
    claude_exit_code: int = 0


def _build_prompt(report_text: str, removed_symbols: set[str]) -> str:
    symbol_list = ", ".join(sorted(removed_symbols)) if removed_symbols else "(none listed)"
    return (
        "You are fixing a WoW addon (CritMatic) that broke when a new WoW patch "
        "was released. Below is the patch report. Your job: edit the addon source "
        "to handle the removed/renamed APIs, then output a self-rated confidence "
        "number.\n\n"
        "PATCH REPORT:\n"
        f"{report_text}\n\n"
        "REMOVED API SYMBOLS THE ADDON USES:\n"
        f"{symbol_list}\n\n"
        "SCOPE RULES:\n"
        "- ONLY edit files in the addon's own source. Do NOT touch:\n"
        "  Libs/, Localization/, .release/, .pkgmeta.yaml, bot/, *.toc files\n"
        "- For each removed symbol, the patch report lists possible renames in the\n"
        "  \"Possibly renamed\" table. Use those when they look right.\n"
        "- For removed symbols with no rename candidate, add a defensive fallback:\n"
        "  use the new API if available, else degrade gracefully.\n"
        "- Keep changes MINIMAL and SURGICAL. Do not refactor unrelated code.\n\n"
        "OUTPUT REQUIREMENT:\n"
        "After all edits, output exactly one JSON object as the LAST LINE of your\n"
        "response, with no surrounding text. Schema:\n"
        '{"confidence": <integer 0-100>, "rationale": "<one to two sentences>", "files_changed": ["<repo-relative path>", ...]}\n'
    )


_JSON_LINE_RE = re.compile(r"\{.*\}")


def _parse_claude_output(stdout: str) -> tuple[int, str, list[str]]:
    for line in reversed(stdout.strip().splitlines()):
        line = line.strip()
        m = _JSON_LINE_RE.search(line)
        if not m:
            continue
        try:
            data = json.loads(m.group(0))
        except json.JSONDecodeError:
            continue
        try:
            confidence = int(data.get("confidence", 0))
        except (TypeError, ValueError):
            confidence = 0
        rationale = str(data.get("rationale", ""))
        files = list(data.get("files_changed") or [])
        return confidence, rationale, files
    return 0, "claude output unparseable", []


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


def propose_fix(
    *,
    report_path: Path,
    branch_name: str,
    removed_symbols: set[str],
    repo_root: Path,
) -> FixResult:
    worktree_path = repo_root.parent / WORKTREE_PARENT_NAME / branch_name.replace("/", "_")

    # Create the worktree off master.
    subprocess.run(
        ["git", "-C", str(repo_root), "worktree", "add", "-b", branch_name, str(worktree_path), "master"],
        capture_output=True, text=True, check=False,
    )

    report_text = report_path.read_text(encoding="utf-8")
    prompt = _build_prompt(report_text, removed_symbols)

    claude_proc = subprocess.run(
        [
            "claude", "-p", prompt,
            "--allowedTools", CLAUDE_ALLOWED_TOOLS,
            "--max-turns", str(CLAUDE_MAX_TURNS),
        ],
        cwd=str(worktree_path),
        capture_output=True, text=True,
        timeout=CLAUDE_TIMEOUT_SECONDS,
        check=False,
    )

    confidence, rationale, files_changed = _parse_claude_output(claude_proc.stdout)

    # The worktree is on the new branch with no commits yet. Detect Claude's
    # uncommitted changes via `git status --porcelain` instead of a diff range.
    status_proc = _git(worktree_path, "status", "--porcelain")
    has_changes = bool(status_proc.stdout.strip())

    commit_sha: str | None = None
    if has_changes:
        _git(worktree_path, "add", "-A")
        _git(worktree_path, "commit", "-m", f"bot: proposed fix for {branch_name}")
        rev = _git(worktree_path, "rev-parse", "HEAD")
        commit_sha = rev.stdout.strip() or None

    return FixResult(
        branch=branch_name,
        worktree_path=worktree_path,
        commit_sha=commit_sha,
        confidence=confidence,
        rationale=rationale,
        files_changed=files_changed,
        claude_exit_code=claude_proc.returncode,
    )
```

- [ ] **Step 4: Run to verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_fixer -v`
Expected: 5 tests pass.

- [ ] **Step 5: Commit**

```bash
git add bot/lib/fixer.py bot/tests/test_fixer.py
git commit -m "feat(bot): add fixer module that runs claude -p in a worktree"
```

---

## Task 3: Verifier module

**Files:**
- Create: `bot/lib/verify.py`
- Create: `bot/tests/test_verify.py`

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

Create `bot/tests/test_verify.py`:

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


class TestVerify(unittest.TestCase):
    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.repo = self.tmp / "repo"
        self.repo.mkdir()
        self.worktree = self.tmp / "wt"
        self.worktree.mkdir()
        # Provide a CritMatic.lua in the worktree so grep check has something to scan.
        (self.worktree / "CritMatic.lua").write_text("local x = 1\n", encoding="utf-8")

    def _make_fakes(self, *, changed_files="CritMatic.lua\n", luac_ok=True, packager_ok=True,
                    packager_exists=True, shortstat="1 file changed, 5 insertions(+), 2 deletions(-)\n"):
        def run(cmd, *args, **kwargs):
            result = MagicMock(returncode=0, stdout="", stderr="")
            if cmd[0] == "git" and "diff" in cmd and "--name-only" in cmd:
                result.stdout = changed_files
            elif cmd[0] == "git" and "diff" in cmd and "--shortstat" in cmd:
                result.stdout = shortstat
            elif cmd[0] == "luac":
                result.returncode = 0 if luac_ok else 1
                result.stderr = "" if luac_ok else "lua: syntax error"
            elif cmd[0] == "bash" and "release.sh" in cmd[1]:
                result.returncode = 0 if packager_ok else 1
            return result
        return run

    def test_all_checks_pass(self):
        # Make release.sh exist so packager check actually runs.
        (self.repo / ".release").mkdir()
        (self.repo / ".release" / "release.sh").write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
        with patch("subprocess.run", side_effect=self._make_fakes()):
            r = verify.verify(
                worktree_path=self.worktree,
                removed_symbols={"GetDeadAPI"},
                repo_root=self.repo,
            )
        self.assertTrue(r.passed_all, [(row.name, row.detail) for row in r.rows])
        self.assertEqual(len(r.rows), 5)

    def test_luac_failure_fails_overall(self):
        (self.repo / ".release").mkdir()
        (self.repo / ".release" / "release.sh").write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
        with patch("subprocess.run", side_effect=self._make_fakes(luac_ok=False)):
            r = verify.verify(
                worktree_path=self.worktree,
                removed_symbols=set(),
                repo_root=self.repo,
            )
        self.assertFalse(r.passed_all)
        luac_row = next(row for row in r.rows if row.name == "luac -p")
        self.assertFalse(luac_row.passed)

    def test_packager_missing_is_pass_with_note(self):
        # No .release/release.sh exists.
        with patch("subprocess.run", side_effect=self._make_fakes(packager_exists=False)):
            r = verify.verify(
                worktree_path=self.worktree,
                removed_symbols=set(),
                repo_root=self.repo,
            )
        pkg = next(row for row in r.rows if row.name == "packager dry-run")
        self.assertTrue(pkg.passed)
        self.assertIn("not present", pkg.detail.lower())

    def test_disallowed_path_fails(self):
        with patch("subprocess.run", side_effect=self._make_fakes(
            changed_files="Libs/Ace3.lua\n",
        )):
            r = verify.verify(
                worktree_path=self.worktree,
                removed_symbols=set(),
                repo_root=self.repo,
            )
        self.assertFalse(r.passed_all)
        path_row = next(row for row in r.rows if row.name == "path allowlist")
        self.assertFalse(path_row.passed)
        self.assertIn("Libs/", path_row.detail)

    def test_diff_size_cap_fails(self):
        with patch("subprocess.run", side_effect=self._make_fakes(
            shortstat="3 files changed, 250 insertions(+), 0 deletions(-)\n",
        )):
            r = verify.verify(
                worktree_path=self.worktree,
                removed_symbols=set(),
                repo_root=self.repo,
            )
        size_row = next(row for row in r.rows if row.name == "diff size")
        self.assertFalse(size_row.passed)

    def test_removed_symbol_still_present_fails(self):
        # Put a removed symbol back into the worktree's source.
        (self.worktree / "CritMatic.lua").write_text("local x = GetDeadAPI()\n", encoding="utf-8")
        with patch("subprocess.run", side_effect=self._make_fakes()):
            r = verify.verify(
                worktree_path=self.worktree,
                removed_symbols={"GetDeadAPI"},
                repo_root=self.repo,
            )
        grep_row = next(row for row in r.rows if row.name == "removed-symbol grep")
        self.assertFalse(grep_row.passed)


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

- [ ] **Step 2: Run to verify failure**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_verify -v`
Expected: ModuleNotFoundError.

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

Create `bot/lib/verify.py`:

```python
"""Phase 2: five-check verification gate over a fixer-produced worktree.

Order: luac -p, packager dry-run (skipped if absent), removed-symbol grep,
path allowlist, diff size cap. Each check produces one VerifyRow.
"""
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path

from bot.lib.grep import find_call_sites

MAX_DIFF_LINES = 200
DISALLOWED_PATH_RE = re.compile(r"^(Libs/|Localization/|\.release/|\.pkgmeta\.yaml$|bot/)")
PACKAGER_TIMEOUT_SECONDS = 120


@dataclass(frozen=True)
class VerifyRow:
    name: str
    passed: bool
    detail: str


@dataclass
class VerifyResult:
    passed_all: bool
    rows: list[VerifyRow] = field(default_factory=list)


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


def _changed_files(worktree: Path) -> list[str]:
    proc = _git(worktree, "diff", "--name-only", "master..HEAD")
    return [line.strip() for line in proc.stdout.splitlines() if line.strip()]


def _luac_check(worktree: Path, files: list[str]) -> VerifyRow:
    if shutil.which("luac") is None:
        return VerifyRow("luac -p", False, "luac not installed")
    failures: list[str] = []
    for f in files:
        if not f.endswith(".lua"):
            continue
        full = worktree / f
        if not full.exists():
            continue
        proc = subprocess.run(
            ["luac", "-p", str(full)],
            capture_output=True, text=True, check=False,
        )
        if proc.returncode != 0:
            failures.append(f"{f}: {proc.stderr.strip()}")
    if failures:
        return VerifyRow("luac -p", False, "; ".join(failures))
    return VerifyRow("luac -p", True, f"{sum(1 for f in files if f.endswith('.lua'))} files clean")


def _packager_check(worktree: Path, repo_root: Path) -> VerifyRow:
    script = repo_root / ".release" / "release.sh"
    if not script.exists():
        return VerifyRow("packager dry-run", True, ".release/release.sh not present, skipped")
    proc = subprocess.run(
        ["bash", str(script), "-d", "-z", "-t", str(worktree)],
        capture_output=True, text=True,
        timeout=PACKAGER_TIMEOUT_SECONDS, check=False,
    )
    if proc.returncode != 0:
        tail = (proc.stderr or proc.stdout).splitlines()[-3:]
        return VerifyRow("packager dry-run", False, " | ".join(tail))
    return VerifyRow("packager dry-run", True, "exit 0")


def _removed_symbol_check(worktree: Path, removed_symbols: set[str]) -> VerifyRow:
    if not removed_symbols:
        return VerifyRow("removed-symbol grep", True, "no symbols to check")
    sites = find_call_sites(removed_symbols, worktree)
    if sites:
        sample = ", ".join(f"{s.symbol}@{s.path}:{s.line}" for s in sites[:3])
        return VerifyRow("removed-symbol grep", False, f"{len(sites)} hits remaining ({sample})")
    return VerifyRow("removed-symbol grep", True, "0 remaining hits")


def _path_allowlist_check(files: list[str]) -> VerifyRow:
    bad = [f for f in files if DISALLOWED_PATH_RE.match(f)]
    if bad:
        return VerifyRow("path allowlist", False, f"disallowed: {', '.join(bad)}")
    return VerifyRow("path allowlist", True, f"{len(files)} files in scope")


_SHORTSTAT_RE = re.compile(r"(\d+) insertions?\(\+\).*?(\d+) deletions?\(-\)")
_INSERTIONS_ONLY_RE = re.compile(r"(\d+) insertions?\(\+\)")
_DELETIONS_ONLY_RE = re.compile(r"(\d+) deletions?\(-\)")


def _diff_size_check(worktree: Path) -> VerifyRow:
    proc = _git(worktree, "diff", "--shortstat", "master..HEAD")
    text = proc.stdout
    ins = del_ = 0
    m = _SHORTSTAT_RE.search(text)
    if m:
        ins = int(m.group(1))
        del_ = int(m.group(2))
    else:
        m_i = _INSERTIONS_ONLY_RE.search(text)
        m_d = _DELETIONS_ONLY_RE.search(text)
        if m_i:
            ins = int(m_i.group(1))
        if m_d:
            del_ = int(m_d.group(1))
    total = ins + del_
    if total > MAX_DIFF_LINES:
        return VerifyRow("diff size", False, f"{total} lines (cap {MAX_DIFF_LINES})")
    return VerifyRow("diff size", True, f"{total} lines")


def verify(
    *,
    worktree_path: Path,
    removed_symbols: set[str],
    repo_root: Path,
) -> VerifyResult:
    files = _changed_files(worktree_path)
    rows: list[VerifyRow] = [
        _luac_check(worktree_path, files),
        _packager_check(worktree_path, repo_root),
        _removed_symbol_check(worktree_path, removed_symbols),
        _path_allowlist_check(files),
        _diff_size_check(worktree_path),
    ]
    return VerifyResult(passed_all=all(r.passed for r in rows), rows=rows)
```

- [ ] **Step 4: Run to verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_verify -v`
Expected: 6 tests pass.

- [ ] **Step 5: Commit**

```bash
git add bot/lib/verify.py bot/tests/test_verify.py
git commit -m "feat(bot): add 5-check verify gate (luac, packager, grep, paths, size)"
```

---

## Task 4: Analyzer signature change

**Files:**
- Modify: `bot/analyze.py` (change `run` return type to `tuple[int, Path, set[str]]`)
- Modify: `bot/tests/test_analyze.py` (update test assertion)

- [ ] **Step 1: Update the failing test first**

Replace the body of `test_writes_report_and_returns_at_risk_count` in `bot/tests/test_analyze.py` so it expects three return values:

```python
        with patch(
            "bot.analyze.diff_api_surface",
            return_value=({"GetSpellInfo"}, set()),
        ):
            count, report_path, removed = 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())
        self.assertEqual(removed, {"GetSpellInfo"})
        body = report_path.read_text(encoding="utf-8")
        self.assertIn("GetSpellInfo", body)
        self.assertIn("CritMatic.lua", body)
```

- [ ] **Step 2: Run to verify failure**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_analyze -v`
Expected: failure (e.g., `ValueError: not enough values to unpack`).

- [ ] **Step 3: Update analyzer return**

Edit `bot/analyze.py`. Change the `run` function's return statement and signature:

Before (last line of `run`):
```python
    return len(sites), out
```

After:
```python
    return len(sites), out, set(diff.removed)
```

Also update the `run` function's return type annotation:

Before:
```python
) -> tuple[int, Path]:
```

After:
```python
) -> tuple[int, Path, set[str]]:
```

And update `_cli` to unpack three values:

Before:
```python
    count, path = run(
```

After:
```python
    count, path, _removed = run(
```

- [ ] **Step 4: Run to verify pass**

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

Then run the full suite to confirm nothing else broke:

Run: `PYTHONPATH=. python3 -m unittest discover bot/tests`
Expected: all tests pass except `bot.tests.test_detect` may now fail because detect.py still expects 2 return values from analyze.run. That is fixed in Task 5.

If only test_detect fails, proceed. If anything else fails, stop and report.

- [ ] **Step 5: Commit**

```bash
git add bot/analyze.py bot/tests/test_analyze.py
git commit -m "refactor(bot): analyze.run returns removed_symbols set (Phase 2 prep)"
```

---

## Task 5: Detector wiring (three-way fork)

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

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

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

```python
    def test_more_than_8_at_risk_skips_fixer(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)]
        report_path = self.reports_dir / "fake.md"
        report_path.write_text("# fake\n", encoding="utf-8")
        # 9 at-risk -> over the cap
        with patch("bot.detect.fetch_builds", return_value=builds), \
             patch("bot.detect.run_analyzer", return_value=(9, report_path, {"X"})), \
             patch("bot.detect.propose_fix") as pf, \
             patch("bot.detect.notify"):
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        pf.assert_not_called()

    def test_within_cap_runs_fixer_and_records_pending_fix(self):
        from bot.lib.fixer import FixResult
        from bot.lib.verify import VerifyResult, VerifyRow
        (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"
        worktree.mkdir()

        fix = FixResult(
            branch="bot/patch-wow-11_3_0",
            worktree_path=worktree,
            commit_sha="deadbeef",
            confidence=88,
            rationale="renamed",
            files_changed=["CritMatic.lua"],
            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.notify"):
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        from bot.lib import state as state_mod
        fixes = state_mod.load_pending_fixes(self.state_dir)
        self.assertIn(fix.branch, fixes)
        self.assertEqual(fixes[fix.branch]["status"], "auto-ship-eligible")

    def test_low_confidence_or_failed_verify_yields_held(self):
        from bot.lib.fixer import FixResult
        from bot.lib.verify import VerifyResult, VerifyRow
        (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 / "wt2"
        worktree.mkdir()

        fix = FixResult(
            branch="bot/patch-wow-11_3_0",
            worktree_path=worktree,
            commit_sha="abc",
            confidence=60,  # below threshold
            rationale="unsure",
            files_changed=["CritMatic.lua"],
            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.notify"):
            detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        from bot.lib import state as state_mod
        fixes = state_mod.load_pending_fixes(self.state_dir)
        self.assertEqual(fixes[fix.branch]["status"], "held-for-review")

    def test_fixer_exception_does_not_crash_detector(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)]
        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, {"X"})), \
             patch("bot.detect.propose_fix", side_effect=RuntimeError("worktree failed")), \
             patch("bot.detect.notify"):
            rc = detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
        self.assertEqual(rc, 0)
```

Also, three existing tests in this file already pass `(2, report_path)` as the run_analyzer return value. Update them to `(2, report_path, set())` (and similarly for the count=0 test, `(0, ..., set())`):

Find these existing patterns in `bot/tests/test_detect.py` and update each tuple to add a third element `set()`:
- `patch("bot.detect.run_analyzer", return_value=(2, report_path))` → `patch("bot.detect.run_analyzer", return_value=(2, report_path, set()))`
- `patch("bot.detect.run_analyzer", return_value=(0, self.reports_dir / "x.md"))` → `patch("bot.detect.run_analyzer", return_value=(0, self.reports_dir / "x.md", set()))`

This applies to the following tests:
- `test_new_build_triggers_analyzer_and_notification`
- `test_zero_at_risk_does_not_notify`
- `test_notification_appends_log_line`
- `test_zero_at_risk_does_not_log`

(Note: the existing `test_new_build_triggers_analyzer_and_notification` asserts `nf.assert_called_once()` which still applies since we will still notify on the auto-ship-eligible / held path. But the test does not stub propose_fix/run_verify, so we must add `patch("bot.detect.propose_fix")` and `patch("bot.detect.run_verify")` to that test's `with` block. Same for `test_notification_appends_log_line`. Update those two tests to add the patches without changing their assertions:

In `test_new_build_triggers_analyzer_and_notification`, change the `with` block to:

```python
        from bot.lib.fixer import FixResult
        from bot.lib.verify import VerifyResult
        worktree = self.tmp / "wt_a"
        worktree.mkdir()
        fix = FixResult(branch="bot/x", worktree_path=worktree, commit_sha="a",
                        confidence=90, rationale="x", files_changed=[], claude_exit_code=0)
        vr = VerifyResult(passed_all=True, rows=[])
        with patch("bot.detect.fetch_builds", return_value=builds), \
             patch("bot.detect.run_analyzer", return_value=(2, report_path, set())) as ra, \
             patch("bot.detect.propose_fix", return_value=fix), \
             patch("bot.detect.run_verify", return_value=vr), \
             patch("bot.detect.notify") as nf:
            rc = detect.run(state_dir=self.state_dir, reports_dir=self.reports_dir, repo_root=self.repo_root)
```

In `test_notification_appends_log_line`, do the same: import FixResult/VerifyResult, build a fix + vr, add `propose_fix` + `run_verify` patches.

For `test_zero_at_risk_does_not_notify` and `test_zero_at_risk_does_not_log`, only the third element of the run_analyzer return needs to change to `set()`. No fixer patches needed because count == 0 short-circuits before fixer.

- [ ] **Step 2: Run to verify failures**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_detect -v`
Expected: all four new tests fail with `AttributeError` on `bot.detect.propose_fix` (or similar). The four updated existing tests should still pass.

- [ ] **Step 3: Update detector**

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

```python
from bot.lib.fixer import propose_fix
from bot.lib.verify import verify as run_verify
```

Add a new constant near the other module constants:

```python
AUTOFIX_MAX_SITES = 8
CONFIDENCE_THRESHOLD = 75
```

Replace the existing `if count > 0: notify(...)` block (lines roughly 91-101 in the current file) with:

```python
            if count > AUTOFIX_MAX_SITES:
                notify(
                    title="CritMatic patch watcher",
                    subtitle=f"{branch} {new_version}",
                    body=f"manual-review-needed: {count} at-risk sites. Report: {report_path}",
                    open_path=str(report_path),
                )
                _append_notification_log(
                    state_dir,
                    f"{detected_at}  {branch} {new_version}  status=manual-review-needed  sites={count}  report={report_path}",
                )
            elif count >= 1:
                fix_branch_name = f"bot/patch-{branch}-{new_version}".replace(".", "_")
                try:
                    fix = propose_fix(
                        report_path=report_path,
                        branch_name=fix_branch_name,
                        removed_symbols=removed_symbols,
                        repo_root=repo_root,
                    )
                    vr = run_verify(
                        worktree_path=fix.worktree_path,
                        removed_symbols=removed_symbols,
                        repo_root=repo_root,
                    )
                    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, {
                        "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),
                    })
                    failed_names = [r.name for r in vr.rows if not r.passed]
                    body = (
                        f"{status}: {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={status}"
                        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)
                except Exception as exc:
                    print(f"fixer failed for {branch} {new_version}: {exc}", file=sys.stderr)
                    notify(
                        title="CritMatic patch watcher",
                        subtitle=f"{branch} {new_version}",
                        body=f"fixer failed; manual review needed for {report_path}",
                        open_path=str(report_path),
                    )
                    _append_notification_log(
                        state_dir,
                        f"{detected_at}  {branch} {new_version}  status=fixer-failed  report={report_path}  error={exc}",
                    )
```

Also update the `run_analyzer` call site to unpack three values. Find:

```python
                count, report_path = run_analyzer(
```

Change to:

```python
                count, report_path, removed_symbols = run_analyzer(
```

- [ ] **Step 4: Run to verify pass**

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

Then full suite:

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

- [ ] **Step 5: Commit**

```bash
git add bot/detect.py bot/tests/test_detect.py
git commit -m "feat(bot): wire fixer + verify into detector with 3-way fork"
```

---

## Task 6: Approval CLI

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

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

Create `bot/tests/test_approve.py`:

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


class TestApproveList(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()

    def test_list_empty(self):
        out = approve.cmd_list(self.state_dir)
        self.assertIn("no pending fixes", out.lower())

    def test_list_renders_entries(self):
        state.record_pending_fix(self.state_dir, "bot/a", {
            "status": "auto-ship-eligible", "confidence": 88,
            "report_path": "bot/reports/a.md", "worktree_path": "/tmp/a",
            "created_at": "t", "verify_rows": [], "rationale": "ok",
        })
        state.record_pending_fix(self.state_dir, "bot/b", {
            "status": "held-for-review", "confidence": 50,
            "report_path": "bot/reports/b.md", "worktree_path": "/tmp/b",
            "created_at": "t", "verify_rows": [], "rationale": "no",
        })
        out = approve.cmd_list(self.state_dir)
        self.assertIn("bot/a", out)
        self.assertIn("bot/b", out)
        self.assertIn("auto-ship-eligible", out)
        self.assertIn("held-for-review", out)


class TestApproveMerge(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()
        self.worktree = self.tmp / "wt"
        self.worktree.mkdir()
        state.record_pending_fix(self.state_dir, "bot/x", {
            "status": "auto-ship-eligible", "confidence": 90,
            "report_path": "bot/reports/x.md",
            "worktree_path": str(self.worktree),
            "created_at": "t", "verify_rows": [], "rationale": "ok",
        })

    def test_merge_unknown_branch_errors(self):
        rc, msg = approve.cmd_merge(self.state_dir, self.repo_root, "bot/nonexistent")
        self.assertNotEqual(rc, 0)
        self.assertIn("not found", msg.lower())

    def test_merge_runs_verify_then_ff_merge(self):
        from bot.lib.verify import VerifyResult, VerifyRow
        passing = VerifyResult(passed_all=True, rows=[VerifyRow("luac -p", True, "ok")])
        with patch("bot.approve.run_verify", return_value=passing), \
             patch("subprocess.run") as sp:
            sp.return_value = MagicMock(returncode=0, stdout="", stderr="")
            rc, msg = approve.cmd_merge(self.state_dir, self.repo_root, "bot/x")
        self.assertEqual(rc, 0)
        merge_call = next(c for c in sp.call_args_list if "merge" in c.args[0])
        self.assertIn("--ff-only", merge_call.args[0])
        # pending entry removed
        self.assertNotIn("bot/x", state.load_pending_fixes(self.state_dir))

    def test_merge_aborts_when_verify_fails(self):
        from bot.lib.verify import VerifyResult, VerifyRow
        failing = VerifyResult(passed_all=False, rows=[VerifyRow("luac -p", False, "syntax err")])
        with patch("bot.approve.run_verify", return_value=failing), \
             patch("subprocess.run") as sp:
            rc, msg = approve.cmd_merge(self.state_dir, self.repo_root, "bot/x")
        self.assertNotEqual(rc, 0)
        self.assertIn("verify", msg.lower())
        merge_calls = [c for c in sp.call_args_list if "merge" in c.args[0]]
        self.assertEqual(merge_calls, [])
        # entry still present (not consumed)
        self.assertIn("bot/x", state.load_pending_fixes(self.state_dir))


class TestApproveReject(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()
        self.worktree = self.tmp / "wt"
        self.worktree.mkdir()
        state.record_pending_fix(self.state_dir, "bot/y", {
            "status": "held-for-review", "confidence": 30,
            "report_path": "bot/reports/y.md",
            "worktree_path": str(self.worktree),
            "created_at": "t", "verify_rows": [], "rationale": "no",
        })

    def test_reject_removes_worktree_and_entry(self):
        with patch("subprocess.run") as sp:
            sp.return_value = MagicMock(returncode=0, stdout="", stderr="")
            rc, msg = approve.cmd_reject(self.state_dir, self.repo_root, "bot/y")
        self.assertEqual(rc, 0)
        worktree_calls = [c for c in sp.call_args_list if "worktree" in c.args[0]]
        self.assertTrue(any("remove" in c.args[0] for c in worktree_calls))
        self.assertNotIn("bot/y", state.load_pending_fixes(self.state_dir))


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

- [ ] **Step 2: Run to verify failure**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_approve -v`
Expected: ModuleNotFoundError on `bot.approve`.

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

Create `bot/approve.py`:

```python
"""Phase 2: approval CLI for pending auto-fix branches.

Subcommands:
  bot/approve.py                # list pending fixes (table)
  bot/approve.py show <branch>  # full record + diff
  bot/approve.py merge <branch> # re-run verify, ff-merge into local master, cleanup
  bot/approve.py reject <branch># cleanup worktree + pending-fix entry, do not touch master

Never pushes to remote, never tags, never invokes release.sh. Phase 3 wires
those.
"""
import argparse
import subprocess
import sys
from pathlib import Path

from bot.lib import state
from bot.lib.verify import verify as run_verify


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


def cmd_list(state_dir: Path) -> str:
    fixes = state.load_pending_fixes(state_dir)
    if not fixes:
        return "no pending fixes"
    lines = [f"{'BRANCH':40}  {'STATUS':22}  {'CONF':>4}  REPORT"]
    for branch, entry in sorted(fixes.items()):
        lines.append(
            f"{branch:40}  {entry.get('status',''):22}  "
            f"{entry.get('confidence',0):>4}  {entry.get('report_path','')}"
        )
    return "\n".join(lines)


def cmd_show(state_dir: Path, repo_root: Path, branch: str) -> tuple[int, str]:
    fixes = state.load_pending_fixes(state_dir)
    entry = fixes.get(branch)
    if entry is None:
        return 1, f"{branch}: not found"
    out = [f"branch: {branch}"]
    for k in ("status", "confidence", "rationale", "report_path", "worktree_path", "created_at"):
        out.append(f"{k}: {entry.get(k, '')}")
    out.append("verify_rows:")
    for row in entry.get("verify_rows", []):
        mark = "PASS" if row.get("passed") else "FAIL"
        out.append(f"  [{mark}] {row.get('name','?')}: {row.get('detail','')}")
    diff = _git(repo_root, "diff", "master..." + branch)
    out.append("---- diff ----")
    out.append(diff.stdout)
    return 0, "\n".join(out)


def cmd_merge(state_dir: Path, repo_root: Path, branch: str) -> tuple[int, str]:
    fixes = state.load_pending_fixes(state_dir)
    entry = fixes.get(branch)
    if entry is None:
        return 1, f"{branch}: not found"

    worktree_path = Path(entry["worktree_path"])
    removed_symbols: set[str] = set(entry.get("removed_symbols") or [])
    vr = run_verify(
        worktree_path=worktree_path,
        removed_symbols=removed_symbols,
        repo_root=repo_root,
    )
    if not vr.passed_all:
        failed = [f"{r.name}: {r.detail}" for r in vr.rows if not r.passed]
        return 1, "verify failed at merge time:\n  " + "\n  ".join(failed)

    merge = _git(repo_root, "merge", "--ff-only", branch)
    if merge.returncode != 0:
        return 1, (
            "ff-merge failed; master may have moved. Rebase manually:\n"
            f"  git -C {repo_root} fetch && git -C {worktree_path} rebase master\n"
            f"stderr: {merge.stderr.strip()}"
        )

    _git(repo_root, "worktree", "remove", str(worktree_path))
    _git(repo_root, "branch", "-d", branch)
    state.remove_pending_fix(state_dir, branch)
    return 0, f"merged {branch} into master (no push). Cleanup complete."


def cmd_reject(state_dir: Path, repo_root: Path, branch: str) -> tuple[int, str]:
    fixes = state.load_pending_fixes(state_dir)
    entry = fixes.get(branch)
    if entry is None:
        return 1, f"{branch}: not found"
    worktree_path = Path(entry["worktree_path"])
    _git(repo_root, "worktree", "remove", "--force", str(worktree_path))
    _git(repo_root, "branch", "-D", branch)
    state.remove_pending_fix(state_dir, branch)
    return 0, f"rejected {branch} and removed worktree."


def _main(argv: list[str] | None = None) -> int:
    repo_root = Path(__file__).resolve().parent.parent
    state_dir = repo_root / "bot" / "state"

    parser = argparse.ArgumentParser(description="CritMatic patch-watcher approval CLI")
    sub = parser.add_subparsers(dest="cmd")
    sub.add_parser("list")
    p_show = sub.add_parser("show"); p_show.add_argument("branch")
    p_merge = sub.add_parser("merge"); p_merge.add_argument("branch")
    p_reject = sub.add_parser("reject"); p_reject.add_argument("branch")
    args = parser.parse_args(argv)

    if args.cmd in (None, "list"):
        print(cmd_list(state_dir))
        return 0
    if args.cmd == "show":
        rc, msg = cmd_show(state_dir, repo_root, args.branch)
        print(msg); return rc
    if args.cmd == "merge":
        rc, msg = cmd_merge(state_dir, repo_root, args.branch)
        print(msg); return rc
    if args.cmd == "reject":
        rc, msg = cmd_reject(state_dir, repo_root, args.branch)
        print(msg); return rc
    parser.print_help()
    return 2


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

- [ ] **Step 4: Run to verify pass**

Run: `PYTHONPATH=. python3 -m unittest bot.tests.test_approve -v`
Expected: 6 tests pass.

Run full suite:

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

- [ ] **Step 5: Commit**

```bash
git add bot/approve.py bot/tests/test_approve.py
git commit -m "feat(bot): add approve.py CLI for listing, merging, and rejecting fixes"
```

---

## Task 7: README

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

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

Add the following section to `bot/README.md` immediately after the existing `## Run on demand` section:

````markdown
## Auto-fix proposals (Phase 2)

When the analyzer finds **1-8 at-risk call sites** in CritMatic, the bot:
1. Creates a worktree at `<repo_parent>/CritMatic-bot/<branch_name>`.
2. Runs `claude -p` headlessly in that worktree to propose a fix (tools restricted to read/write/grep, no Bash, no MCP, 5-minute timeout, max 30 turns).
3. Commits whatever Claude edited on the new branch.
4. Runs five objective verify checks: `luac -p`, packager dry-run (`-d -z`), zero-removed-symbol grep, path allowlist, and a 200-line diff cap.
5. Labels the fix `auto-ship-eligible` (verify all-pass + Claude self-rated >= 75) or `held-for-review` (anything else).
6. Notifies via macOS Notification Center; appends a line to `bot/state/notifications.log`; records the fix in `bot/state/pending-fixes.json`.

If 9+ sites are at risk, the bot skips Claude entirely and notifies `manual-review-needed`.

Phase 2 NEVER auto-merges, NEVER pushes, NEVER releases. Use the approval CLI:

```bash
# List pending fixes:
PYTHONPATH=. python3 bot/approve.py

# Inspect one (shows verify rows + diff):
PYTHONPATH=. python3 bot/approve.py show bot/patch-wow-12_0_5

# Merge into local master (re-runs verify, ff-only, no push):
PYTHONPATH=. python3 bot/approve.py merge bot/patch-wow-12_0_5

# Discard:
PYTHONPATH=. python3 bot/approve.py reject bot/patch-wow-12_0_5
```

`merge` re-runs the verify gate before touching master; if checks now fail, the merge aborts and the entry stays pending. The merge is fast-forward only; if master moved, rebase the worktree manually.

### Where Phase 2 things live

- Pending fixes: `bot/state/pending-fixes.json` (gitignored).
- Auto-fix worktrees: `<repo_parent>/CritMatic-bot/<branch>/` (sibling directory; not inside the repo).
- Inspect manually with `git worktree list`.
````

- [ ] **Step 2: Commit**

```bash
git add bot/README.md
git commit -m "docs(bot): document Phase 2 auto-fix and approval CLI"
```

---

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

This is a manual checklist. No code changes. Verifies the wiring works against fakes.

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

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

- [ ] **Step 2: Save a fake `claude` script**

Create `/tmp/fake-claude` (this is a shim used only for the manual test; the real bot uses the real `claude` CLI):

```bash
cat > /tmp/fake-claude <<'SH'
#!/bin/bash
# Echo the prompt arg index for debugging, then "edit" CritMatic.lua
# inside the worktree (cwd) and emit valid JSON on the last line.
# Args: claude -p <prompt> --allowedTools ... --max-turns ...
echo "fake claude invoked in $(pwd)"
# Append a trivial change to a real source file so git status is non-empty.
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
```

- [ ] **Step 3: Stub state to one build behind retail**

Run:

```bash
PYTHONPATH=. python3 - <<'PY'
import json
from pathlib import Path
out = {"wow": {"build": 60000, "version": "11.2.7", "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("state stubbed to 11.2.7")
PY
rm -f bot/reports/*.md
rm -f bot/state/pending-fixes.json
# Clean any prior bot-fix worktrees from earlier runs:
git worktree list | awk '/CritMatic-bot/ {print $1}' | xargs -I{} git worktree remove --force {} 2>/dev/null || true
```

- [ ] **Step 4: Run the detector with the fake claude on PATH first**

Run:

```bash
PATH="/tmp:/usr/local/bin:/usr/bin:/bin" \
  ln -sf /tmp/fake-claude /tmp/claude
PATH="/tmp:$PATH" PYTHONPATH=. python3 bot/detect.py
echo "exit=$?"
```

NOTE: This temporarily shadows `claude` with the fake script. If the detector's analyzer reports 0 at-risk (likely, since CritMatic source uses few currently-removed APIs), the fixer won't fire and you'll need to either pick a different version pair OR temporarily lower `AUTOFIX_MAX_SITES` and induce a fake at-risk grep hit. Easier path: skip Step 4 and use Step 5's direct fixer test.

- [ ] **Step 5: Direct fixer + verify dry-run**

Skip past the analyzer and exercise fixer + verify directly:

```bash
PATH="/tmp:$PATH" PYTHONPATH=. python3 - <<'PY'
from pathlib import Path
from bot.lib.fixer import propose_fix
from bot.lib.verify import verify
from bot.lib import state

repo = Path(".").resolve()
report = repo / "bot" / "reports" / "manual-test-report.md"
report.write_text("# Test\nremoved: GetDeadAPI\n", encoding="utf-8")

fix = propose_fix(
    report_path=report,
    branch_name="bot/patch-manual-test",
    removed_symbols={"GetDeadAPI"},
    repo_root=repo,
)
print("fix:", fix)
vr = verify(
    worktree_path=fix.worktree_path,
    removed_symbols={"GetDeadAPI"},
    repo_root=repo,
)
print("verify:")
for row in vr.rows:
    print(f"  [{('PASS' if row.passed else 'FAIL')}] {row.name}: {row.detail}")
print("passed_all:", vr.passed_all)

state.record_pending_fix(repo / "bot" / "state", fix.branch, {
    "report_path": str(report),
    "status": "auto-ship-eligible" if (vr.passed_all and fix.confidence >= 75) else "held-for-review",
    "confidence": fix.confidence,
    "worktree_path": str(fix.worktree_path),
    "created_at": "manual-test",
    "verify_rows": [{"name": r.name, "passed": r.passed, "detail": r.detail} for r in vr.rows],
    "rationale": fix.rationale,
})
PY
```

Expected: prints fix details, prints 5 verify rows, prints `passed_all: True` (assuming `luac` is on PATH and `release.sh` exists). The pending-fixes file gets an entry.

- [ ] **Step 6: Run the CLI**

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

Expected: a one-row table listing `bot/patch-manual-test` as `auto-ship-eligible`.

```bash
PYTHONPATH=. python3 bot/approve.py show bot/patch-manual-test
```

Expected: full record with verify rows + diff.

```bash
PYTHONPATH=. python3 bot/approve.py reject bot/patch-manual-test
```

Expected: worktree removed, branch deleted, pending-fix entry gone. Re-running `bot/approve.py` shows `no pending fixes`.

- [ ] **Step 7: Cleanup**

```bash
rm -f /tmp/fake-claude /tmp/claude
rm -f bot/reports/manual-test-report.md
git worktree list | awk '/CritMatic-bot/ {print $1}' | xargs -I{} git worktree remove --force {} 2>/dev/null || true
git branch | grep '^  bot/patch-' | xargs -r git branch -D 2>/dev/null || true
```

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

```bash
git add -A && git commit -m "fix(bot): tweaks from Phase 2 manual test" || true
```

---

## Definition of done

- All unit tests pass: `PYTHONPATH=. python3 -m unittest discover bot/tests`.
- Manual end-to-end test (Task 8) passes; pending-fixes.json roundtrips through `bot/approve.py`.
- A simulated 1-8 at-risk patch produces a labelled branch + notification + pending-fix entry.
- A simulated 9+ at-risk patch produces `manual-review-needed` with no Claude invocation.
- `bot/approve.py merge <branch>` ff-merges into local master without pushing.
- `bot/approve.py reject <branch>` removes the worktree and entry.
- The launchd job continues to run unchanged; Phase 2 changes are picked up on next scheduled fire.
