# Windows 更新退回手动触发 + 系统语言精确检测 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:** 把 Windows 上复杂且不可靠的"分离进程后台自动更新"退回为"提示用户关闭程序、手动运行命令"，并顺带修复 Windows 上系统语言检测失效导致中文用户看到英文提示 / 走慢速源的问题。

**Architecture:** 分三步——(1) 抽一个共享的系统 locale 探测 helper（含 Windows UI 语言兜底），让更新提示语言和镜像源判断都基于它；(2) 把 `run_update_command` 的 Windows 分支改成"打印手动更新说明并停止启动"，把命令构造函数从 `windows_updater.py` 搬进 `update_check.py`；(3) 彻底删除 `windows_updater.py` 及其后台更新机制（detached PowerShell、result 文件握手、启动时结果展示、4 条死消息）。macOS/Linux 的进程内 `uv tool install` 路径完全不动。

**Tech Stack:** Python 3.11–3.13、`uv tool install`、`prompt_toolkit`（仅渲染标签字符串，不改布局 API）、`rich.Console`、`ctypes.windll.kernel32.GetUserDefaultUILanguage`、`locale.windows_locale`、`unittest` + `pytest`。

## Global Constraints

- 目标 Python 版本：`>=3.11,<3.14`（见 `pyproject.toml`）。
- 仅用 `logging`，禁止 `print`（`rich.Console` 输出属于用户可见 UI，允许）。
- 运行测试：`uv run pytest ...`；不新增任何依赖（`ctypes`/`locale` 是标准库）。
- git commit message 用**中文**
- **白牌铁律**：任何给用户看的更新命令文本必须由 `target` 派生——包名用 `target.package`、附加参数用 `*target.install_args`、产品名用 `target.product_label`。禁止硬编码 `comate-cli`。
- **仅改 Windows 行为**：macOS/Linux 的 `run_update_command`（进程内 `asyncio.create_subprocess_exec` 跑 `uv tool install`）逻辑一行不改。
- **不动 prompt_toolkit 布局 API**：本计划只改 `show_update_prompt` 渲染的**标签字符串**（`show_update_command_label`），不改 `Application`/`Layout`/`KeyBindings`。若执行者需要改 prompt_toolkit 代码，MUST 先用 context7 查文档（见项目 CLAUDE.md 规则 1）。
- 工作目录：所有路径相对 `packages/comate_cli/`。测试命令在该目录下执行。

## File Structure

- `comate_cli/terminal_agent/update_messages.py` — **修改**。新增共享 locale 探测（`_windows_ui_locale`、`system_locale_candidates`），重写 `detect_update_locale`；改 4 个 locale 的 `show_update_command_label` 文案；删除 `UpdateMessages` 里 4 个死字段及各 locale 对应条目。
- `comate_cli/terminal_agent/update_check.py` — **修改**。新增 `windows_update_command` / `windows_update_command_text`（从 `windows_updater.py` 搬入，`--reinstall` 修复版）；重写 `run_update_command` 的 Windows 分支（打印手动说明 → 返回 True）；`format_update_hint` 的 Windows 分支改用本模块函数；`_is_chinese_locale` 改用共享 helper。
- `comate_cli/terminal_agent/windows_updater.py` — **删除**。整个 detached updater 机制（PowerShell 脚本、result 握手、命令构造）不再需要。
- `comate_cli/terminal_agent/app.py` — **修改**。删除 `_show_startup_update_result` 函数及其在启动流程里的调用。
- `tests/test_update_check.py` — **修改**。更新 2 个 Windows 手动路径测试、删除 spawn 测试、更新标签测试、删除 `TestWindowsUpdaterMessages` 类；新增系统 locale 探测测试类。
- `tests/test_windows_updater.py` — **删除**。
- `tests/test_app_startup_order.py` — **修改**。移除对 `_show_startup_update_result` 的 patch 行。

---

## Task 1: 共享系统 locale 探测 + Windows UI 语言兜底

**Files:**
- Modify: `comate_cli/terminal_agent/update_messages.py`（顶部 imports、`detect_update_locale`）
- Modify: `comate_cli/terminal_agent/update_check.py:156-172`（`_is_chinese_locale`）
- Test: `tests/test_update_check.py`（新增 `TestSystemLocaleDetection` 类）

**Interfaces:**
- Produces:
  - `comate_cli.terminal_agent.update_messages.system_locale_candidates() -> list[str]`：按优先级返回非空 locale 字符串候选（显式 env → win32 UI 语言 → `locale.getlocale()`）。
  - `comate_cli.terminal_agent.update_messages._windows_ui_locale() -> str | None`：win32 上返回用户 UI 语言（如 `"zh_CN"`），非 win32 或失败返回 `None`。
  - `detect_update_locale() -> str`：改为基于 `system_locale_candidates()`，语义不变（返回 `zh_cn`/`zh_tw`/`ja`/`en`）。
  - `comate_cli.terminal_agent.update_check._is_chinese_locale() -> bool`：改为基于 `system_locale_candidates()`，OR 语义与前缀集合 `(zh_cn, zh_sg, zh_mo, zh_hans)` 保持不变。

**背景（先读）：** 当前 `detect_update_locale`（`update_messages.py`）和 `_is_chinese_locale`（`update_check.py`）都只读 `LC_ALL/LC_MESSAGES/LANG/LANGUAGE` 四个 POSIX 环境变量再 fallback `locale.getlocale()`。这些环境变量在 Windows 上通常不存在，`locale.getlocale()` 未 `setlocale` 时多为 `C`，导致中文 Windows 用户被判成 `en` / 非中文。修复思路：新增一个 win32 专属兜底源 `GetUserDefaultUILanguage()`，插在"显式 env 之后、`locale.getlocale()` 之前"，两个消费者共用同一候选序列、各自保留原分类逻辑（避免 `zh_mo` 等边界回归）。

**平台 API 核验：**
- Windows：`sys.platform == "win32"` 时才调用 `ctypes.windll.kernel32.GetUserDefaultUILanguage()`；该 Win32 API 返回当前用户 UI 语言的 LANGID，再用 Python 标准库 `locale.windows_locale` 映射为 `"zh_CN"` 这类 locale 字符串。任何异常都返回 `None`，继续走后续 fallback。
- macOS/Linux：`sys.platform` 分别为 `"darwin"` / `"linux"`，`_windows_ui_locale()` 直接返回 `None`，不会 import/use `ctypes.windll`；语言来源仍是显式 env 与 `locale.getlocale()`。
- 不调用 `locale.setlocale()`：避免修改进程级 locale；`locale.getlocale()` 只读取当前设置，返回 `(language, encoding)` 或空值，空值由调用方回退到 `"en"`。

- [ ] **Step 1: 写失败测试（新增测试类）**

在 `tests/test_update_check.py` 文件**末尾**追加以下测试类（`os`、`patch`、`unittest`、`SimpleNamespace`、`AsyncMock` 等已在文件顶部导入）：

```python
class TestSystemLocaleDetection(unittest.TestCase):
    def test_windows_ui_locale_returns_none_off_win32(self) -> None:
        from comate_cli.terminal_agent.update_messages import _windows_ui_locale

        for platform in ("linux", "darwin"):
            with self.subTest(platform=platform):
                with patch("comate_cli.terminal_agent.update_messages.sys.platform", platform):
                    self.assertIsNone(_windows_ui_locale())

    def test_windows_ui_locale_maps_win32_langid_with_fake_ctypes(self) -> None:
        from comate_cli.terminal_agent.update_messages import _windows_ui_locale

        fake_ctypes = SimpleNamespace(
            windll=SimpleNamespace(
                kernel32=SimpleNamespace(GetUserDefaultUILanguage=lambda: 2052)
            )
        )
        with (
            patch("comate_cli.terminal_agent.update_messages.sys.platform", "win32"),
            patch.dict("sys.modules", {"ctypes": fake_ctypes}),
        ):
            self.assertEqual(_windows_ui_locale(), "zh_CN")

    def test_windows_ui_locale_returns_none_when_win32_api_unavailable(self) -> None:
        from comate_cli.terminal_agent.update_messages import _windows_ui_locale

        with (
            patch("comate_cli.terminal_agent.update_messages.sys.platform", "win32"),
            patch.dict("sys.modules", {"ctypes": SimpleNamespace()}),
        ):
            self.assertIsNone(_windows_ui_locale())

    def test_system_locale_candidates_preserve_source_priority(self) -> None:
        from comate_cli.terminal_agent.update_messages import system_locale_candidates

        with (
            patch.dict(
                os.environ,
                {"LC_ALL": "en_US.UTF-8", "LC_MESSAGES": "", "LANG": "", "LANGUAGE": ""},
                clear=False,
            ),
            patch(
                "comate_cli.terminal_agent.update_messages._windows_ui_locale",
                return_value="zh_CN",
            ),
            patch(
                "comate_cli.terminal_agent.update_messages.locale.getlocale",
                return_value=("ja_JP", "UTF-8"),
            ),
        ):
            self.assertEqual(
                system_locale_candidates(),
                ["en_US.UTF-8", "zh_CN", "ja_JP"],
            )

    def test_detect_update_locale_prefers_explicit_env_over_windows_api(self) -> None:
        from comate_cli.terminal_agent.update_messages import detect_update_locale

        with (
            patch.dict(
                os.environ,
                {"LC_ALL": "en_US.UTF-8", "LC_MESSAGES": "", "LANG": "", "LANGUAGE": ""},
                clear=False,
            ),
            patch(
                "comate_cli.terminal_agent.update_messages._windows_ui_locale",
                return_value="zh_CN",
            ),
            patch(
                "comate_cli.terminal_agent.update_messages.locale.getlocale",
                return_value=("en_US", "UTF-8"),
            ),
        ):
            self.assertEqual(detect_update_locale(), "en")

    def test_detect_update_locale_falls_back_to_windows_ui_language(self) -> None:
        from comate_cli.terminal_agent.update_messages import detect_update_locale

        with (
            patch.dict(
                os.environ,
                {"LC_ALL": "", "LC_MESSAGES": "", "LANG": "", "LANGUAGE": ""},
                clear=False,
            ),
            patch(
                "comate_cli.terminal_agent.update_messages._windows_ui_locale",
                return_value="zh_CN",
            ),
            patch(
                "comate_cli.terminal_agent.update_messages.locale.getlocale",
                return_value=(None, None),
            ),
        ):
            self.assertEqual(detect_update_locale(), "zh_cn")

    def test_is_chinese_locale_uses_windows_ui_language_when_env_absent(self) -> None:
        from comate_cli.terminal_agent.update_check import _is_chinese_locale

        with (
            patch.dict(
                os.environ,
                {"LC_ALL": "", "LC_MESSAGES": "", "LANG": "", "LANGUAGE": ""},
                clear=False,
            ),
            patch(
                "comate_cli.terminal_agent.update_messages._windows_ui_locale",
                return_value="zh_CN",
            ),
            patch(
                "comate_cli.terminal_agent.update_messages.locale.getlocale",
                return_value=(None, None),
            ),
        ):
            self.assertTrue(_is_chinese_locale())

    def test_is_chinese_locale_true_when_windows_api_chinese_despite_english_env(self) -> None:
        from comate_cli.terminal_agent.update_check import _is_chinese_locale

        with (
            patch.dict(
                os.environ,
                {"LC_ALL": "en_US.UTF-8", "LC_MESSAGES": "", "LANG": "", "LANGUAGE": ""},
                clear=False,
            ),
            patch(
                "comate_cli.terminal_agent.update_messages._windows_ui_locale",
                return_value="zh_CN",
            ),
            patch(
                "comate_cli.terminal_agent.update_messages.locale.getlocale",
                return_value=("en_US", "UTF-8"),
            ),
        ):
            self.assertTrue(_is_chinese_locale())
```

> 测试意图说明：前三个用例验证平台边界——Linux/macOS 不触碰 Windows API；
win32 下 `GetUserDefaultUILanguage()` 返回的 LANGID 可经 `locale.windows_locale`
映射成 Python locale 字符串；Windows API 不可用或异常时安全返回 `None` 并回退。
`test_system_locale_candidates_preserve_source_priority` 直接锁住共享 helper 的候选顺序
（显式 env > Windows UI > `locale.getlocale()`）。最后一个用例故意断言 `True`——
`_is_chinese_locale` 是"任一来源为中文即用镜像"
的 OR 语义（原实现也是英文 env 不会阻止中文 `getlocale` 触发镜像），这里保留该
语义并把 Windows UI 语言纳入来源。真实 Windows 上的 `ctypes.windll.kernel32`
调用仍需手测确认，但 Linux/macOS 单测会覆盖不会误调用 Windows-only API。

- [ ] **Step 2: 运行测试确认失败**

Run: `uv run pytest tests/test_update_check.py::TestSystemLocaleDetection -v`
Expected: FAIL —— `AttributeError: <module 'comate_cli.terminal_agent.update_messages'> does not have the attribute '_windows_ui_locale'`（`sys` 也尚未在该模块导入）。

- [ ] **Step 3: 在 `update_messages.py` 顶部补 imports**

把文件头部：

```python
from __future__ import annotations

import locale
import os
from dataclasses import dataclass
```

替换为：

```python
from __future__ import annotations

import locale
import logging
import os
import sys
from dataclasses import dataclass

logger = logging.getLogger(__name__)
```

- [ ] **Step 4: 新增探测函数并重写 `detect_update_locale`**

在 `update_messages.py` 中，把当前的 `detect_update_locale`：

```python
def detect_update_locale() -> str:
    for env_var in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"):
        normalized = _normalize_locale(os.environ.get(env_var))
        if normalized:
            return normalized
    try:
        lang, _encoding = locale.getlocale()
    except Exception:
        return "en"
    return _normalize_locale(lang) or "en"
```

替换为：

```python
def _windows_ui_locale() -> str | None:
    """返回 Windows 用户 UI 语言对应的 locale 字符串（如 "zh_CN"），否则 None。

    Windows 上 POSIX locale 环境变量通常缺失，因此直接问系统用户显示语言
    （LANGID 2052 -> "zh_CN"）。非 Windows 平台与任何异常都返回 None，让调用
    方回退到其它来源。
    """
    if sys.platform != "win32":
        return None
    try:
        import ctypes

        lang_id = ctypes.windll.kernel32.GetUserDefaultUILanguage()
        return locale.windows_locale.get(lang_id)
    except Exception:
        logger.debug("windows UI locale detection failed", exc_info=True)
        return None


def system_locale_candidates() -> list[str]:
    """按优先级返回系统 locale 候选字符串（非空）。

    优先级：显式 POSIX 环境变量（用户意图）> Windows UI 语言（仅 win32 兜底）
    > locale.getlocale()。返回原始字符串，由调用方各自做归一化/分类。被
    detect_update_locale()（提示语言）与 _is_chinese_locale()（镜像源）共用，
    使 Windows 修复只落在一处。
    """
    candidates: list[str] = []
    for env_var in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"):
        value = os.environ.get(env_var)
        if value:
            candidates.append(value)
    windows_locale = _windows_ui_locale()
    if windows_locale:
        candidates.append(windows_locale)
    try:
        lang, _encoding = locale.getlocale()
    except Exception:
        lang = None
    if lang:
        candidates.append(lang)
    return candidates


def detect_update_locale() -> str:
    for candidate in system_locale_candidates():
        normalized = _normalize_locale(candidate)
        if normalized:
            return normalized
    return "en"
```

> 说明：`_normalize_locale` 对任意非空字符串都返回 `zh_cn/zh_tw/ja/en`（仅对空/None 返回 None），因此循环会命中第一个候选——即第一个非空 env 变量，语义与原实现一致；env 全空时才用 Windows API 或 `getlocale()`。

- [ ] **Step 5: 重写 `_is_chinese_locale` 并删除旧 import（`update_check.py`）**

先把 `update_check.py` 顶部的 `import locale` 删除。该模块当前只有旧
`_is_chinese_locale` 使用 `locale.getlocale()`；改为共享 helper 后继续保留会变成
未使用 import。

再把 `update_check.py` 当前的：

```python
def _is_chinese_locale() -> bool:
    """判断当前系统 locale 是否为中文环境。"""
    # zh_tw（台湾）和 zh_hk（香港）不走清华镜像源
    cn_prefixes = ("zh_cn", "zh_sg", "zh_mo", "zh_hans")
    for env_var in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"):
        val = os.environ.get(env_var, "").lower()
        if any(val.startswith(prefix) for prefix in cn_prefixes):
            return True
    try:
        lang, _ = locale.getlocale()
        if lang:
            lang_lower = lang.lower()
            if any(lang_lower.startswith(prefix) for prefix in cn_prefixes):
                return True
    except Exception:
        pass
    return False
```

替换为：

```python
def _is_chinese_locale() -> bool:
    """判断当前系统 locale 是否为中国大陆简体环境（决定是否走清华镜像源）。"""
    # zh_tw（台湾）和 zh_hk（香港）不走清华镜像源；OR 语义：任一来源为中文即用镜像。
    from comate_cli.terminal_agent.update_messages import system_locale_candidates

    cn_prefixes = ("zh_cn", "zh_sg", "zh_mo", "zh_hans")
    for candidate in system_locale_candidates():
        value = candidate.lower()
        if any(value.startswith(prefix) for prefix in cn_prefixes):
            return True
    return False
```

> `update_check.py` 已有多处函数内 `from ...update_messages import ...` 的先例（如 `get_update_messages`），`update_messages` 不 import `update_check`，无循环风险。删除 `import locale` 是本改动的一部分：locale 探测职责已收口到 `update_messages.system_locale_candidates()`。

- [ ] **Step 6: 运行新测试确认通过**

Run: `uv run pytest tests/test_update_check.py::TestSystemLocaleDetection -v`
Expected: PASS（8 个用例全绿）。

- [ ] **Step 7: 运行既有 locale 回归测试确认不破坏**

Run: `uv run pytest "tests/test_update_check.py::TestCheckUpdate::test_update_locale_detection_maps_supported_locales" -v`
Expected: PASS（显式 env 优先语义未变）。

- [ ] **Step 8: 提交**

```bash
git add comate_cli/terminal_agent/update_messages.py comate_cli/terminal_agent/update_check.py tests/test_update_check.py
git commit -m "$(cat <<'EOF'
特性(update): 精确检测 Windows 系统语言用于更新提示

抽出 system_locale_candidates() 共享 helper，新增 win32 下
GetUserDefaultUILanguage 兜底，插在显式 env 与 locale.getlocale() 之间。
detect_update_locale 与 _is_chinese_locale 改用同一候选序列、各自保留原
分类语义，修复 Windows 中文用户被误判为 en / 非中文导致看到英文提示、
走慢速 pypi 源的问题。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Windows 更新分支改为"打印手动命令并停止启动"

**Files:**
- Modify: `comate_cli/terminal_agent/update_check.py`（新增命令构造函数、`run_update_command` 的 win32 分支、`format_update_hint` 的 win32 分支）
- Modify: `comate_cli/terminal_agent/update_messages.py`（4 个 locale 的 `show_update_command_label`）
- Test: `tests/test_update_check.py`（`TestCheckUpdate` 内 3 个 Windows 测试 + 命令构造测试 + 标签测试）

**Interfaces:**
- Consumes: `UpdateTarget`（`.package`、`.install_args`、`.product_label`，已存在于 `update_check.py`）；`windows_manual_update` message（已存在，保留）。
- Produces:
  - `comate_cli.terminal_agent.update_check.windows_update_command(target: UpdateTarget) -> tuple[str, ...]`：`uv tool install {pkg}@latest --force --reinstall --refresh-package {pkg} *install_args`。
  - `comate_cli.terminal_agent.update_check.windows_update_command_text(target: UpdateTarget) -> str`：上者 `" ".join(...)`。
  - `run_update_command(...)` 的 Windows 分支：打印 `windows_manual_update` 后返回 `True`（`should_stop`），不再 spawn 任何进程、不再返回 `False`。

**背景（先读）：** 当前 win32 分支调用 `windows_updater.spawn_detached_updater`：成功返回 `True`（退出等后台更新），失败打印 `windows_manual_update` 返回 `False`（继续进 TUI）。本任务把它统一为"打印手动说明并返回 `True`"——`_handle_update_on_launch` 会把该 `True` 作为 `should_stop`，`app.run` 里 `if should_stop: return` 从而退出程序，符合"关闭当前程序、手动更新"。命令构造函数从即将删除的 `windows_updater.py` 搬进 `update_check.py`（同模块，去掉跨模块 import）。

**命令文本范围：** `windows_update_command_text()` 继续使用 `" ".join(...)`，与现有
`windows_updater.py` 行为一致，适用于当前发行配置里的简单 token（flag、URL、host、
包名）。`target.install_args` 暂不支持包含空格、引号、`&`、`|`、括号等 CMD /
PowerShell shell metachar 的值；如果未来白牌需要这类参数，必须另起设计同时覆盖
CMD 与 PowerShell 的可复制命令 quoting，并补专门测试。

- [ ] **Step 1: 更新 3 个 Windows 行为测试（先让它们表达新意图 → 失败）**

在 `tests/test_update_check.py` 的 `TestCheckUpdate` 类中：

(a) `test_windows_update_path_prints_manual_command_without_subprocess`：删除其 `with (...)` 里的这段 spawn patch——

```python
            patch(
                "comate_cli.terminal_agent.windows_updater.spawn_detached_updater",
                return_value=False,
            ),
```

替换为断言式 patch，防止红灯阶段真的写 updater 文件或启动 PowerShell：

```python
            patch(
                "comate_cli.terminal_agent.windows_updater.spawn_detached_updater",
                side_effect=AssertionError(
                    "Windows manual update path must not spawn detached updater"
                ),
            ),
```

并把该方法末尾附近的 `self.assertFalse(ok)` 改为 `self.assertTrue(ok)`。

(b) `test_windows_update_path_uses_comate_recovery_package`：同样删除其 `with (...)` 里的——

```python
            patch(
                "comate_cli.terminal_agent.windows_updater.spawn_detached_updater",
                return_value=False,
            ),
```

替换为同样的断言式 patch，并把 `self.assertFalse(ok)` 改为
`self.assertTrue(ok)`。

(c) `test_windows_update_path_spawns_detached_updater_and_stops_launch`（约 918–957 行，验证 spawn 路径）：**整段删除**——该行为已被删除，"返回 True / 停止启动"由 (a)(b) 覆盖。

- [ ] **Step 2: 运行确认失败**

Run: `uv run pytest "tests/test_update_check.py::TestCheckUpdate::test_windows_update_path_prints_manual_command_without_subprocess" "tests/test_update_check.py::TestCheckUpdate::test_windows_update_path_uses_comate_recovery_package" -v`
Expected: FAIL —— 现有实现仍会调用 `spawn_detached_updater`，断言式 patch 会抛
`AssertionError: Windows manual update path must not spawn detached updater`。这
证明旧路径仍在，同时不会在本机创建 updater 文件或启动 PowerShell。

- [ ] **Step 3: 在 `update_check.py` 新增命令构造函数**

在 `update_check.py` 中 `UpdateTarget` 数据类定义之后（`update_target_from_profile` 之前的位置即可）加入：

```python
def windows_update_command(target: UpdateTarget) -> tuple[str, ...]:
    """Windows 手动更新用的修复式 uv 命令。

    Windows 文件锁可能让 tool 环境部分更新，故手动命令用 --reinstall 彻底重建
    venv。与 UpdateTarget.update_command()（macOS/Linux，无需修复）分开。所有
    字段来自 target，兼容白牌发行版。
    """
    return (
        "uv",
        "tool",
        "install",
        f"{target.package}@latest",
        "--force",
        "--reinstall",
        "--refresh-package",
        target.package,
        *target.install_args,
    )


def windows_update_command_text(target: UpdateTarget) -> str:
    return " ".join(windows_update_command(target))
```

- [ ] **Step 4: 迁移命令构造测试到 `test_update_check.py`**

从 `tests/test_windows_updater.py` 的 `TestWindowsUpdateCommand` 迁移前两个测试到
`tests/test_update_check.py` 的 `TestCheckUpdate` 类中，import 改为
`comate_cli.terminal_agent.update_check.windows_update_command` /
`windows_update_command_text`：

```python
    def test_windows_update_command_adds_reinstall_without_mutating_update_target(self) -> None:
        from comate_cli.terminal_agent.update_check import windows_update_command

        target = UpdateTarget(
            package="acme-cli",
            product_label="Acme",
            release_notes_url="https://acme.example/releases",
            install_args=("--default-index", "https://pypi.example.com/local/simple"),
            command_name="acme",
        )

        self.assertEqual(
            windows_update_command(target),
            (
                "uv",
                "tool",
                "install",
                "acme-cli@latest",
                "--force",
                "--reinstall",
                "--refresh-package",
                "acme-cli",
                "--default-index",
                "https://pypi.example.com/local/simple",
            ),
        )
        self.assertEqual(
            target.update_command(),
            (
                "uv",
                "tool",
                "install",
                "acme-cli@latest",
                "--force",
                "--refresh-package",
                "acme-cli",
                "--default-index",
                "https://pypi.example.com/local/simple",
            ),
        )

    def test_windows_update_command_text_is_plain_copyable_text(self) -> None:
        from comate_cli.terminal_agent.update_check import windows_update_command_text

        target = UpdateTarget(
            package="acme-cli",
            product_label="Acme",
            release_notes_url="https://acme.example/releases",
            install_args=("--default-index", "https://pypi.example.com/local/simple"),
            command_name="acme",
        )

        self.assertEqual(
            windows_update_command_text(target),
            (
                "uv tool install acme-cli@latest --force --reinstall "
                "--refresh-package acme-cli --default-index "
                "https://pypi.example.com/local/simple"
            ),
        )
```

> 这两个测试验证新增接口本身：Windows 手动命令加 `--reinstall`，但不改变
`UpdateTarget.update_command()`（macOS/Linux 路径继续无 `--reinstall`）。

- [ ] **Step 5: 重写 `run_update_command` 的 win32 分支**

把 `run_update_command` 中当前的：

```python
    if sys.platform == "win32":
        from comate_cli.terminal_agent.windows_updater import (
            spawn_detached_updater,
            windows_update_command_text,
        )

        windows_command_text = windows_update_command_text(update_target)

        if spawn_detached_updater(info, target=update_target):
            console.out(
                messages.windows_update_scheduled.format(
                    product=update_target.product_label,
                    latest=info.latest_version,
                    current=info.current_version,
                ),
                end="\n",
            )
            return True
        console.out(
            messages.windows_manual_update.format(
                product=update_target.product_label,
                package=update_target.package,
                command=windows_command_text,
                current=info.current_version,
                latest=info.latest_version,
            ),
            end="\n",
        )
        return False
```

替换为：

```python
    if sys.platform == "win32":
        # Windows 无法在运行中的进程内安全重建 uv tool 环境（trampoline /
        # python.exe 可能被锁）。退回最原始的做法：打印手动更新说明并停止启动
        # （返回 True -> should_stop），由用户关闭程序后自行运行命令。
        console.out(
            messages.windows_manual_update.format(
                product=update_target.product_label,
                package=update_target.package,
                command=windows_update_command_text(update_target),
                current=info.current_version,
                latest=info.latest_version,
            ),
            end="\n",
        )
        return True
```

- [ ] **Step 6: 更新 `format_update_hint` 的 win32 分支**

把 `format_update_hint` 中当前的：

```python
    if is_windows:
        from comate_cli.terminal_agent.windows_updater import windows_update_command_text

        command = windows_update_command_text(update_target)
```

替换为：

```python
    if is_windows:
        command = windows_update_command_text(update_target)
```

- [ ] **Step 7: 改 4 个 locale 的 `show_update_command_label` 文案**

在 `update_messages.py` 的 `_MESSAGES` 中逐个替换（键的值），使全屏提示选项 1 反映"显示命令并退出"而非"退出后自动完成"：

- `en`: `show_update_command_label="Update now (completes after exit)"` → `show_update_command_label="Show update command and exit"`
- `zh_cn`: `show_update_command_label="立即更新（退出后自动完成）"` → `show_update_command_label="显示更新命令并退出"`
- `zh_tw`: `show_update_command_label="立即更新（結束後自動完成）"` → `show_update_command_label="顯示更新命令並退出"`
- `ja`: `show_update_command_label="今すぐ更新（終了後に自動実行）"` → `show_update_command_label="更新コマンドを表示して終了"`

- [ ] **Step 8: 更新标签相关测试**

在 `tests/test_update_check.py` 的 `TestCheckUpdate` 类中，先更新
`test_update_prompt_rows_use_platform_specific_primary_label` 的 Windows 文案断言：

```python
        self.assertIn("Update now (completes after exit)", windows_text)
```

替换为：

```python
        self.assertIn("Show update command and exit", windows_text)
```

保留同一测试里的 Linux 断言（`Update now (runs ...`），用它继续证明
macOS/Linux 的更新命令展示不变。

然后把当前位于 `TestWindowsUpdaterMessages` 中的独立 label 测试移到
`TestCheckUpdate` 类中（因为 `TestWindowsUpdaterMessages` 会在 Task 3 删除），并把：

```python
    def test_show_update_command_label_mentions_completion_after_exit(self) -> None:
        from comate_cli.terminal_agent.update_messages import get_update_messages

        self.assertEqual(
            get_update_messages("en").show_update_command_label,
            "Update now (completes after exit)",
        )
        self.assertEqual(
            get_update_messages("zh_cn").show_update_command_label,
            "立即更新（退出后自动完成）",
        )
```

替换为：

```python
    def test_show_update_command_label_mentions_showing_command_and_exit(self) -> None:
        from comate_cli.terminal_agent.update_messages import get_update_messages

        self.assertEqual(
            get_update_messages("en").show_update_command_label,
            "Show update command and exit",
        )
        self.assertEqual(
            get_update_messages("zh_cn").show_update_command_label,
            "显示更新命令并退出",
        )
```

- [ ] **Step 9: 运行本任务相关测试确认通过**

Run: `uv run pytest "tests/test_update_check.py::TestCheckUpdate::test_windows_update_command_adds_reinstall_without_mutating_update_target" "tests/test_update_check.py::TestCheckUpdate::test_windows_update_command_text_is_plain_copyable_text" "tests/test_update_check.py::TestCheckUpdate::test_windows_update_path_prints_manual_command_without_subprocess" "tests/test_update_check.py::TestCheckUpdate::test_windows_update_path_uses_comate_recovery_package" "tests/test_update_check.py::TestCheckUpdate::test_update_prompt_rows_use_platform_specific_primary_label" "tests/test_update_check.py::TestCheckUpdate::test_show_update_command_label_mentions_showing_command_and_exit" "tests/test_update_check.py::TestCheckUpdate::test_non_windows_update_path_still_spawns_uv_install_command" -v`
Expected: PASS（Windows 打印手动说明并返回 True；macOS/Linux 路径不变仍绿）。

- [ ] **Step 10: 提交**

```bash
git add comate_cli/terminal_agent/update_check.py comate_cli/terminal_agent/update_messages.py tests/test_update_check.py
git commit -m "$(cat <<'EOF'
特性(update): Windows 更新退回手动触发

run_update_command 的 Windows 分支不再 spawn 分离进程后台更新，改为打印
"关闭程序 + 运行 --reinstall 命令" 的手动说明并返回 True（停止启动，退出
程序）。命令构造 windows_update_command[_text] 从 windows_updater 搬入
update_check，字段全部来自 target 以兼容白牌。全屏提示选项 1 文案由
"退出后自动完成" 改为 "显示更新命令并退出"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: 删除 detached updater 机制与死代码

**Files:**
- Delete: `comate_cli/terminal_agent/windows_updater.py`
- Modify: `comate_cli/terminal_agent/app.py`（删 `_show_startup_update_result` 定义 + 调用）
- Modify: `comate_cli/terminal_agent/update_messages.py`（删 `UpdateMessages` 4 个死字段 + 各 locale 条目）
- Delete: `tests/test_windows_updater.py`
- Modify: `tests/test_app_startup_order.py`（移除 `_show_startup_update_result` patch）
- Modify: `tests/test_update_check.py`（删 Task 2 临时 `windows_updater` assertion patch、删 `TestWindowsUpdaterMessages` 类）

**Interfaces:**
- Consumes: 无新增。本任务纯删除 Task 2 之后已无人调用的代码。
- Produces: 无。删除后全仓不应再有 `windows_updater` 引用。

**背景（先读）：** Task 2 完成后，`windows_updater.py` 里只剩 `windows_update_command[_text]` 曾被外部使用，而它们已搬进 `update_check.py`；其余（`spawn_detached_updater`/`build_updater_script`/`_SCRIPT_TEMPLATE`/`consume_update_result`/`UpdateResult`/`format_update_result_message` 等）仅被 `app._show_startup_update_result` 与 `test_windows_updater.py` 使用。`app._show_startup_update_result` 负责在下次启动展示 result 文件，随后台更新一并作废。`~/.comate/updates/` 旧产物按共识**不主动清理**（无害）。

- [ ] **Step 1: 删除 `windows_updater.py`**

```bash
git rm comate_cli/terminal_agent/windows_updater.py
```

- [ ] **Step 2: 删除 `test_windows_updater.py`**

```bash
git rm tests/test_windows_updater.py
```

- [ ] **Step 3: 从 `app.py` 删除 `_show_startup_update_result` 定义**

删除 `app.py` 中整个函数（当前形如）：

```python
def _show_startup_update_result(target: UpdateTarget) -> None:
    """Display a detached updater result file if one exists."""
    try:
        from comate_cli.terminal_agent.windows_updater import (
            consume_update_result,
            format_update_result_message,
        )

        result = consume_update_result(target.package)
        if result is None:
            return
        style = "green" if result.status == "success" else "yellow"
        console.print(f"[{style}]{format_update_result_message(result, target=target)}[/]")
    except Exception:
        logger.debug("startup update result display failed", exc_info=True)
```

- [ ] **Step 4: 从 `app.py` 删除对它的调用**

把 `run(...)` 中当前的：

```python
    if interactive:
        _show_startup_update_result(update_target)

        _check_update_func, get_pending_update_prompt_info = _import_update_check_core()
```

替换为：

```python
    if interactive:
        _check_update_func, get_pending_update_prompt_info = _import_update_check_core()
```

- [ ] **Step 5: 从 `tests/test_app_startup_order.py` 移除 patch 行**

删除这一行（约 126 行）：

```python
            patch.object(app_module, "_show_startup_update_result", side_effect=lambda target: order.append("update.result")),
```

（该测试的断言未引用 `"update.result"`，无需改其它内容。）

- [ ] **Step 6: 从 `update_messages.py` 删除 4 个死字段**

在 `UpdateMessages` dataclass 定义中删除这 4 行：

```python
    windows_update_scheduled: str
    windows_update_result_success: str
    windows_update_result_failed: str
    windows_update_result_timeout: str
```

保留 `windows_manual_update`、`windows_hint`、`success`、`failure` 等其它字段。

- [ ] **Step 7: 从每个 locale 删除对应条目**

在 `_MESSAGES` 的 **4 个** locale（`en`/`zh_cn`/`zh_tw`/`ja`）中，各删除以下 4 个键值对（连同其多行字符串）：`windows_update_scheduled=(...)`、`windows_update_result_success=(...)`、`windows_update_result_failed=(...)`、`windows_update_result_timeout=(...)`。保留同一 locale 里的 `windows_manual_update=(...)`。

> 提示：可用 `uv run python -c "from comate_cli.terminal_agent.update_messages import _MESSAGES; print(len(_MESSAGES))"` 快速自检模块仍能 import（4 个字段删干净后 dataclass 构造不会报缺参/多参）。

- [ ] **Step 8: 从 `tests/test_update_check.py` 删除 Task 2 临时 assertion patch**

Task 2 为红灯阶段在两个 Windows 手动路径测试里加入了：

```python
            patch(
                "comate_cli.terminal_agent.windows_updater.spawn_detached_updater",
                side_effect=AssertionError(
                    "Windows manual update path must not spawn detached updater"
                ),
            ),
```

Task 3 会删除 `windows_updater.py`，所以这两个 patch target 必须同步删除。删除后
`test_windows_update_path_prints_manual_command_without_subprocess` 仍保留
`asyncio.create_subprocess_exec` 的 `AssertionError("Windows update must not spawn uv")`
patch，用它继续证明 Windows 手动路径不会走进程内 uv 子进程；同时 `rg` 静态核验会
兜底确认没有 `windows_updater` 残留引用。

- [ ] **Step 9: 从 `tests/test_update_check.py` 删除 `TestWindowsUpdaterMessages` 类**

删除整个类（`test_all_locales_define_windows_updater_fields` 与 `test_windows_timeout_copy_refers_to_shutdown_not_all_windows` 两个方法都引用了已删除的 4 个 message 字段）：

```python
class TestWindowsUpdaterMessages(unittest.TestCase):
    def test_all_locales_define_windows_updater_fields(self) -> None:
        ...
    def test_windows_timeout_copy_refers_to_shutdown_not_all_windows(self) -> None:
        ...
```

- [ ] **Step 10: 静态核验——全仓无 `windows_updater` 悬空引用**

Run: `rg -n "windows_updater|_show_startup_update_result|windows_update_scheduled|windows_update_result" comate_cli tests`
Expected: 无输出（exit code 1）。若只命中 `__pycache__` / `.pytest_cache` 等缓存噪声，先清缓存再复跑；源文件或测试文件中不得有任何残留引用。

- [ ] **Step 11: 跑完整测试套件**

Run: `uv run pytest tests/test_update_check.py tests/test_app_startup_order.py -v`
Expected: PASS（全绿，无 import 错误、无 `AttributeError`）。

- [ ] **Step 12: 可选静态检查（仓库根目录）**

若当前执行环境安装了根项目 dev 工具，可从仓库根目录运行：

```bash
uv run ruff check \
  packages/comate_cli/comate_cli/terminal_agent/update_check.py \
  packages/comate_cli/comate_cli/terminal_agent/update_messages.py \
  packages/comate_cli/comate_cli/terminal_agent/app.py \
  packages/comate_cli/tests/test_update_check.py \
  packages/comate_cli/tests/test_app_startup_order.py
```

Expected: PASS。若 `ruff` 在当前环境不可用，不要为此安装新依赖；在最终报告中明确说明未运行即可。

- [ ] **Step 13: 全量回归**

Run: `uv run pytest -q`
Expected: PASS（无因删文件/删字段引入的失败）。

- [ ] **Step 14: 提交**

```bash
git add -A
git commit -m "$(cat <<'EOF'
重构(update): 删除 Windows 分离进程后台更新机制

删除 windows_updater.py（detached PowerShell 脚本、result 文件握手、
--reinstall 命令构造已搬入 update_check）、app._show_startup_update_result
及其调用、UpdateMessages 里 4 条已作废的 windows_update_scheduled /
windows_update_result_* 文案与对应测试。Windows 更新自此只走手动路径。
~/.comate/updates/ 旧产物无害，不主动清理。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)"
```

---

## Self-Review

**1. Spec coverage（对照 grilling 共识 6+4 条）：**

- A1 仅 Windows / macOS·Linux 不动 → Task 2 只改 win32 分支；`test_non_windows_update_path_still_spawns_uv_install_command` 保绿（Task 2 Step 9）。✅
- A2 保留全屏预检提示、选项 1 改"显示更新命令" → Task 2 Step 7 改标签文案；prompt 逻辑（`show_update_prompt`/`decide_update_prompt`）不动。✅
- A3 选中后打印说明 + 退出程序（返回 True）→ Task 2 Step 5（`return True`）。✅
- A4 `--reinstall` 修复版、字段来自 target → Task 2 Step 3（`windows_update_command`，`*target.install_args`）。✅
- A5 彻底删 `windows_updater.py`、命令搬 `update_check`、删 `_show_startup_update_result`、删 4 条 message、删/改测试 → Task 2 Step 3 搬迁 + Task 3 全量删除。✅
- A6 残留文件不清理 → Task 3 背景说明，无清理步骤。✅
- B7 共享 helper（显式 env > win32 API > getlocale）→ Task 1 Step 4（`system_locale_candidates`）。✅
- B8 两消费者改用 helper、各自分类不变 → Task 1 Step 4/5，无 zh_mo 回归（前缀集合原样保留）。✅
- B9 需真实 Windows 实测 + Linux 回归护栏 → Task 1 测试均 patch `_windows_ui_locale`（Linux 可跑）；ctypes 真调用留待 Windows 手测（Task 1 Step 1 说明）。✅
- 文案 `show_update_command_label` 改"显示更新命令并退出"（四语言）→ Task 2 Step 7 + Step 8。✅

**2. Placeholder scan：** 无 TBD/TODO/"类似 Task N"/"添加适当错误处理"等占位符；每个改代码的 step 都给了完整前后代码。✅

**3. Type consistency：**
- `windows_update_command(target: UpdateTarget) -> tuple[str, ...]` / `windows_update_command_text(target: UpdateTarget) -> str`：Task 2 定义并在同任务的 `run_update_command`、`format_update_hint` 中调用，签名一致。✅
- `system_locale_candidates() -> list[str]`、`_windows_ui_locale() -> str | None`：Task 1 定义，`detect_update_locale`/`_is_chinese_locale` 消费，返回类型一致。✅
- 删除项（`spawn_detached_updater`/`consume_update_result`/`format_update_result_message`/`_show_startup_update_result`/4 message 字段）在 Task 3 Step 10 用 `rg` 兜底确认无悬空引用。✅

## 手动验证清单（真实 Windows，Linux 无法覆盖）

1. 中文 Windows（系统显示语言=简体中文）、未设 `LANG/LC_*`：启动应看到**中文**更新提示；更新检查应走清华镜像（可在 debug 日志确认 URL 为 `mirrors.tuna.tsinghua.edu.cn`）。
2. 全屏预检提示中选"显示更新命令并退出"：终端打印含 `uv tool install <pkg>@latest --force --reinstall --refresh-package <pkg>` 的说明，随后进程退出。
3. 在新 PowerShell 里粘贴该命令，确认能成功升级；再次启动确认版本已更新且不再提示。
4. 若机器上残留旧 `~/.comate/updates/*-result.json`：启动不再展示旧后台更新结果，也不清理该文件；它应保持无害且不阻塞启动。

---

## Execution Handoff

计划已保存到 `docs/superpowers/plans/2026-07-07-windows-update-manual-rollback.md`。
