# aider-watch Implementation Plan（v1 — ⛔ DEPRECATED 2026-04-20）

> ⛔ **已被 v2 取代** — 統帥於 2026-04-20 改走「完全整合進 awoooi」路線。
> 新版 plan：[2026-04-20-aider-watch-v2.md](2026-04-20-aider-watch-v2.md)
> v1 Task 0-5 已完成（~/aider-watch 8 commits），events.py + redactor.py 會搬進 awoooi。

> **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:** 建立 aider CLI 的全程監控系統：Python wrapper 攔截 aider stdout + chat history → Telegram DM 即時推播 + PG 188 儲存 + 每日/週報 launchd 排程。

**Architecture:** Hybrid Python wrapper (stdout 即時訊號 + chat_history.md canonical events) → dispatcher → Telegram/PG/JSONL 三分流。PG 不可達時 fallback JSONL buffer，launchd 每 5min flush 補寫。launchd 另有 daily/weekly reporter job。

**Tech Stack:** Python 3.11.7 (pyenv)、pipx、pydantic v2、psycopg2-binary、requests、pexpect、pytest、PostgreSQL 192.168.0.188:5432、launchd

**Spec:** [docs/superpowers/specs/2026-04-19-aider-watch-design.md](../specs/2026-04-19-aider-watch-design.md)

---

## File Structure

**New git repo at `~/aider-watch/`**（獨立於 awoooi）：

| 檔案 | 職責 |
|------|------|
| `pyproject.toml` | 套件定義、依賴、CLI entrypoints |
| `bin/aiderw` | Python shim，轉呼 `aider_watch.wrapper:main` |
| `aider_watch/__init__.py` | 套件 metadata |
| `aider_watch/events.py` | Pydantic v2 event dataclasses + JSON serde |
| `aider_watch/redactor.py` | Secret pattern 遮罩（OpenRouter/GitHub/Google/OpenAI/Anthropic/AWS） |
| `aider_watch/telegram.py` | Bot API client（format + rate limit + 429 backoff） |
| `aider_watch/storage.py` | PG writer + JSONL fallback + flush |
| `aider_watch/parsers.py` | aider stdout 解析 + chat_history.md 重建 |
| `aider_watch/wrapper.py` | 主入口：subprocess + dispatcher + silent timeout + atexit |
| `aider_watch/reporter.py` | 日/週報聚合查詢 + TG 格式 |
| `aider_watch/cli.py` | `aider-watch {doctor,flush,replay,report}` 派發 |
| `aider_watch/config.py` | 讀 `~/.aider-watch.env`、路徑常數 |
| `schema.sql` | PG DDL（sessions + events + file_touches） |
| `scripts/install_pg.sh` | PG 188 上建 DB/user + 套 schema（one-off） |
| `scripts/install_launchd.sh` | 安裝 3 支 plist + 啟用 |
| `scripts/install.sh` | 總編：pipx install + .env 產生 + symlink + DB + launchd |
| `launchd/com.awoooi.aider-watch.flush.plist` | 每 5min |
| `launchd/com.awoooi.aider-watch.daily.plist` | 每日 23:50 |
| `launchd/com.awoooi.aider-watch.weekly.plist` | 週日 22:00 |
| `tests/test_events.py` | Unit: event schema round-trip |
| `tests/test_redactor.py` | Unit: secret 遮罩 |
| `tests/test_telegram_format.py` | Unit: event → markdown |
| `tests/test_parsers.py` | Unit: aider stdout + chat history 解析 |
| `tests/test_storage_pg.py` | Integration: PG round-trip（用 `aider_watch_test` DB） |
| `tests/test_storage_buffer.py` | Integration: JSONL fallback + flush |
| `tests/test_telegram_live.py` | Integration: 真發 `[TEST]` 訊息 |
| `tests/test_e2e_happy.py` | E2E: `aiderw --message ... --exit` |
| `tests/test_e2e_degradation.py` | E2E: 斷網 → buffer → flush |
| `tests/test_e2e_report.py` | E2E: daily/weekly report |

**不在 awoooi repo 內**：aider-watch 是統帥的個人工具，獨立 git repo 易 clone 到新機器。

---

## Task 0: 建立專案骨架

**Files:**
- Create: `~/aider-watch/pyproject.toml`
- Create: `~/aider-watch/aider_watch/__init__.py`
- Create: `~/aider-watch/.gitignore`
- Create: `~/aider-watch/README.md`

- [ ] **Step 1: 建目錄、git init**

```bash
mkdir -p ~/aider-watch/{aider_watch,tests,scripts,launchd,bin}
cd ~/aider-watch
git init -b main
```

- [ ] **Step 2: 寫 pyproject.toml**

```toml
[project]
name = "aider-watch"
version = "0.1.0"
description = "aider CLI 監控：即時 Telegram 推播 + PG 儲存 + 日週報"
requires-python = ">=3.11"
dependencies = [
    "pydantic>=2.6,<3",
    "psycopg2-binary>=2.9",
    "requests>=2.31",
    "pexpect>=4.9",
    "python-ulid>=3.0",
    "python-dateutil>=2.9",
]

[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-cov>=5.0"]

[project.scripts]
aiderw = "aider_watch.wrapper:main"
aider-watch = "aider_watch.cli:main"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
include = ["aider_watch*"]
```

- [ ] **Step 3: 寫 .gitignore**

```
__pycache__/
*.pyc
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
.env
.aider-watch.env
.DS_Store
```

- [ ] **Step 4: 寫 aider_watch/__init__.py**

```python
# aider-watch — 統帥本機 aider CLI 監控 | 2026-04-19 建立 @ Asia/Taipei
__version__ = "0.1.0"
```

- [ ] **Step 5: 寫 README.md 簡要**

```markdown
# aider-watch

統帥本機 aider CLI 全程監控（Telegram + PG + 日週報）。

參考設計稿：awoooi/docs/superpowers/specs/2026-04-19-aider-watch-design.md
```

- [ ] **Step 6: Commit**

```bash
cd ~/aider-watch
git add -A
git commit -m "chore: scaffold aider-watch project"
```

---

## Task 1: Event Schema（pydantic v2 dataclasses）

**Files:**
- Create: `~/aider-watch/aider_watch/events.py`
- Create: `~/aider-watch/tests/test_events.py`
- Create: `~/aider-watch/tests/__init__.py`

- [ ] **Step 1: 寫測試 tests/test_events.py**

```python
# 2026-04-19 @ Asia/Taipei
from datetime import datetime, timezone, timedelta
from aider_watch.events import (
    Event, SessionStart, FileEdit, ErrorEvent, Commit, SilentTimeout, SessionEnd
)
import json

TAIPEI = timezone(timedelta(hours=8))

def test_session_start_roundtrip():
    ev = SessionStart(
        ts=datetime(2026, 4, 19, 22, 0, tzinfo=TAIPEI),
        session_id="01J7XZABC",
        payload={"cwd": "/tmp/x", "model": "elephant-alpha",
                 "aider_args": ["-m", "hi"], "aider_pid": 123, "cli_version": "0.86.2"},
    )
    j = ev.model_dump_json()
    ev2 = Event.from_json(j)
    assert ev2.type == "session_start"
    assert ev2.payload["model"] == "elephant-alpha"
    assert ev2.ts.tzinfo is not None  # timezone-aware

def test_file_edit_roundtrip():
    ev = FileEdit(
        ts=datetime.now(TAIPEI),
        session_id="01J7XZABC",
        payload={"path": "apps/api/x.py", "lines_added": 12,
                 "lines_deleted": 3, "diff_head": ["@@ def f():", "+    pass", "-    old"],
                 "is_new_file": False},
    )
    assert ev.type == "file_edit"
    data = json.loads(ev.model_dump_json())
    assert data["payload"]["lines_added"] == 12

def test_session_end_fields():
    ev = SessionEnd(
        ts=datetime.now(TAIPEI),
        session_id="s1",
        payload={"duration_sec": 332, "tokens_sent": 12400, "tokens_received": 3800,
                 "cost_usd": 0, "files_changed": 3, "error_count": 0, "exit_code": 0},
    )
    assert ev.payload["duration_sec"] == 332

def test_event_type_immutable():
    ev = SessionStart(ts=datetime.now(TAIPEI), session_id="s",
                      payload={"cwd": "/t", "model": "m", "aider_args": [],
                               "aider_pid": 0, "cli_version": "x"})
    assert ev.type == "session_start"
```

- [ ] **Step 2: 跑測試驗失敗**

```bash
cd ~/aider-watch && pip install -e '.[dev]' --quiet
pytest tests/test_events.py -v
```

Expected: `ModuleNotFoundError: No module named 'aider_watch.events'`

- [ ] **Step 3: 寫實作 aider_watch/events.py**

```python
# aider-watch event schema | 2026-04-19 @ Asia/Taipei
"""
Event 資料契約：7 種 event type，統一外殼 + type-specific payload。
所有 timestamp 必須 timezone-aware（台北 +08:00）。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, Union
from pydantic import BaseModel, Field, ConfigDict
import json


class _EventBase(BaseModel):
    model_config = ConfigDict(frozen=False)
    ts: datetime
    session_id: str
    payload: dict[str, Any]

    def model_dump_json(self, **kw) -> str:  # type: ignore[override]
        return json.dumps(
            {"ts": self.ts.isoformat(), "session_id": self.session_id,
             "type": self.type, "payload": self.payload},
            ensure_ascii=False, default=str,
        )


class SessionStart(_EventBase):
    type: Literal["session_start"] = "session_start"


class FileEdit(_EventBase):
    type: Literal["file_edit"] = "file_edit"


class ErrorEvent(_EventBase):
    type: Literal["error"] = "error"


class Commit(_EventBase):
    type: Literal["commit"] = "commit"


class SilentTimeout(_EventBase):
    type: Literal["silent_timeout"] = "silent_timeout"


class SessionEnd(_EventBase):
    type: Literal["session_end"] = "session_end"


class Raw(_EventBase):
    type: Literal["raw"] = "raw"


Event = Union[SessionStart, FileEdit, ErrorEvent, Commit, SilentTimeout, SessionEnd, Raw]

_TYPE_MAP = {
    "session_start": SessionStart, "file_edit": FileEdit, "error": ErrorEvent,
    "commit": Commit, "silent_timeout": SilentTimeout,
    "session_end": SessionEnd, "raw": Raw,
}


def from_json(s: str) -> _EventBase:
    d = json.loads(s)
    cls = _TYPE_MAP[d["type"]]
    return cls(ts=datetime.fromisoformat(d["ts"]),
               session_id=d["session_id"], payload=d["payload"])


# attach classmethod for convenience
_EventBase.from_json = staticmethod(from_json)  # type: ignore[attr-defined]
```

- [ ] **Step 4: 跑測試驗通過**

```bash
pytest tests/test_events.py -v
```

Expected: 4 passed

- [ ] **Step 5: Commit**

```bash
git add aider_watch/events.py tests/test_events.py tests/__init__.py
git commit -m "feat(events): pydantic v2 event schema with 7 types"
```

---

## Task 2: Secret Redactor

**Files:**
- Create: `~/aider-watch/aider_watch/redactor.py`
- Create: `~/aider-watch/tests/test_redactor.py`

- [ ] **Step 1: 寫測試 tests/test_redactor.py**

```python
# 2026-04-19 @ Asia/Taipei
from aider_watch.redactor import redact

def test_openrouter_key_redacted():
    s = "failed with sk-or-example-redaction-test-token"
    assert "sk-or-example" not in redact(s)
    assert "<redacted:openrouter>" in redact(s)

def test_github_token_redacted():
    assert "<redacted:github>" in redact("ghp_example_redaction_test_token")

def test_google_api_key_redacted():
    assert "<redacted:google>" in redact("AIzaSy-example-redaction-test-key")

def test_openai_key_redacted():
    assert "<redacted:openai>" in redact("sk-example-redaction-test-token")

def test_anthropic_key_redacted():
    assert "<redacted:anthropic>" in redact("sk-ant-api03-abcDEF_123-xyz")

def test_telegram_bot_token_redacted():
    assert "<redacted:telegram>" in redact("<TELEGRAM_BOT_TOKEN_SAMPLE>")

def test_clean_text_passthrough():
    s = "hello world, nothing secret here"
    assert redact(s) == s

def test_dict_recursive():
    d = {"msg": "token=ghp_example_redaction_test_token",
         "nested": {"tg": "<TELEGRAM_BOT_TOKEN_SAMPLE>"}}
    out = redact(d)
    assert "ghp_example" not in str(out)
    assert "<redacted:github>" in out["msg"]
    assert "<redacted:telegram>" in out["nested"]["tg"]
```

- [ ] **Step 2: 跑測試驗失敗**

```bash
pytest tests/test_redactor.py -v
```

Expected: `ModuleNotFoundError: No module named 'aider_watch.redactor'`

- [ ] **Step 3: 寫實作 aider_watch/redactor.py**

```python
# aider-watch secret redactor | 2026-04-19 @ Asia/Taipei
"""把文字/dict 中的 secret 遮罩為 <redacted:kind>。符合 feedback_secrets_leak_incidents_2026-04-18.md。"""
from __future__ import annotations
import re
from typing import Any

_PATTERNS = [
    (re.compile(r"sk-or-v1-[A-Za-z0-9]{40,}"),        "openrouter"),
    (re.compile(r"sk-ant-api\d{2}-[A-Za-z0-9_\-]{20,}"), "anthropic"),
    (re.compile(r"sk-[A-Za-z0-9]{40,}"),              "openai"),       # 注意順序：排在 openrouter/anthropic 之後
    (re.compile(r"ghp_[A-Za-z0-9]{36}"),              "github"),
    (re.compile(r"AIza[0-9A-Za-z_\-]{35}"),           "google"),
    (re.compile(r"\b\d{8,10}:[A-Za-z0-9_\-]{35}\b"),  "telegram"),
    (re.compile(r"AKIA[0-9A-Z]{16}"),                 "aws"),
]


def redact(obj: Any) -> Any:
    """遮罩字串/dict/list 內 secret，不影響其他型別。"""
    if isinstance(obj, str):
        s = obj
        for pat, kind in _PATTERNS:
            s = pat.sub(f"<redacted:{kind}>", s)
        return s
    if isinstance(obj, dict):
        return {k: redact(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [redact(x) for x in obj]
    return obj
```

- [ ] **Step 4: 跑測試驗通過**

```bash
pytest tests/test_redactor.py -v
```

Expected: 8 passed

- [ ] **Step 5: Commit**

```bash
git add aider_watch/redactor.py tests/test_redactor.py
git commit -m "feat(redactor): 7 secret patterns (openrouter/anthropic/openai/gh/google/tg/aws)"
```

---

## Task 3: Config Loader

**Files:**
- Create: `~/aider-watch/aider_watch/config.py`

- [ ] **Step 1: 寫 aider_watch/config.py**

```python
# aider-watch config | 2026-04-19 @ Asia/Taipei
"""讀 ~/.aider-watch.env 為環境變數 + 集中暴露設定常數。"""
from __future__ import annotations
import os
from pathlib import Path
from datetime import timezone, timedelta

HOME = Path.home()
WATCH_ROOT = HOME / "aider-watch"
SESSIONS_DIR = WATCH_ROOT / "sessions"
BUFFER_DIR = WATCH_ROOT / "buffer"
LIVE_LOG = WATCH_ROOT / "live.log"
STATE_FILE = WATCH_ROOT / "state.json"
LOGS_DIR = WATCH_ROOT / "logs"
ENV_FILE = HOME / ".aider-watch.env"

TAIPEI = timezone(timedelta(hours=8))


def load_env() -> None:
    """把 ~/.aider-watch.env 的 KEY=VALUE 行寫入 os.environ（不覆寫已存在的）。"""
    if not ENV_FILE.exists():
        return
    for line in ENV_FILE.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, _, v = line.partition("=")
        k, v = k.strip(), v.strip()
        os.environ.setdefault(k, v)


def get(name: str, default: str | None = None, required: bool = False) -> str:
    load_env()
    v = os.environ.get(name, default)
    if required and not v:
        raise RuntimeError(f"{name} 未設定（check {ENV_FILE}）")
    return v or ""


def ensure_dirs() -> None:
    for d in (WATCH_ROOT, SESSIONS_DIR, BUFFER_DIR, LOGS_DIR):
        d.mkdir(parents=True, exist_ok=True)
```

- [ ] **Step 2: Commit（無測試因無邏輯；Task 4+ 間接測到）**

```bash
git add aider_watch/config.py
git commit -m "feat(config): env loader + path constants"
```

---

## Task 4: Telegram Formatter（純函式，先測 format 再測 send）

**Files:**
- Create: `~/aider-watch/aider_watch/telegram.py`（format 部分）
- Create: `~/aider-watch/tests/test_telegram_format.py`

- [ ] **Step 1: 寫測試 tests/test_telegram_format.py**

```python
# 2026-04-19 @ Asia/Taipei
from datetime import datetime
from aider_watch.events import SessionStart, FileEdit, ErrorEvent, Commit, SilentTimeout, SessionEnd
from aider_watch.telegram import format_event
from aider_watch.config import TAIPEI

def _ts(): return datetime(2026, 4, 19, 22, 0, tzinfo=TAIPEI)

def test_format_session_start():
    ev = SessionStart(ts=_ts(), session_id="s1",
                      payload={"cwd": "/Users/ogt/awoooi", "model": "elephant-alpha",
                               "aider_args": [], "aider_pid": 12345, "cli_version": "0.86.2"})
    m = format_event(ev)
    assert "🚀" in m
    assert "/Users/ogt/awoooi" in m
    assert "elephant-alpha" in m

def test_format_file_edit_with_diff():
    ev = FileEdit(ts=_ts(), session_id="s1",
                  payload={"path": "apps/api/foo.py", "lines_added": 12,
                           "lines_deleted": 3,
                           "diff_head": ["@@ def f():", "+    log()", "-    pass"],
                           "is_new_file": False})
    m = format_event(ev)
    assert "✏️" in m and "foo.py" in m and "+12" in m and "-3" in m
    assert "log()" in m

def test_format_session_end():
    ev = SessionEnd(ts=_ts(), session_id="s1",
                    payload={"duration_sec": 332, "tokens_sent": 12400,
                             "tokens_received": 3800, "cost_usd": 0,
                             "files_changed": 3, "error_count": 0, "exit_code": 0})
    m = format_event(ev)
    assert "🏁" in m and "5m32s" in m and "12.4k" in m and "exit=0" in m

def test_format_redacts_secrets():
    ev = ErrorEvent(ts=_ts(), session_id="s1",
                    payload={"kind": "api_auth", "message": "bad key sk-or-example-redaction-test-token",
                             "context_50chars": ""})
    m = format_event(ev)
    assert "sk-or-example" not in m
    assert "<redacted:" in m
```

- [ ] **Step 2: 跑測試驗失敗**

```bash
pytest tests/test_telegram_format.py -v
```

Expected: `ModuleNotFoundError: No module named 'aider_watch.telegram'`

- [ ] **Step 3: 寫 aider_watch/telegram.py（format 部分）**

```python
# aider-watch telegram | 2026-04-19 @ Asia/Taipei
"""Telegram Bot API client：format + send with backoff + fail-open（不阻塞 aider）。"""
from __future__ import annotations
import time
from typing import Any
import requests
from aider_watch.events import (
    _EventBase, SessionStart, FileEdit, ErrorEvent, Commit, SilentTimeout, SessionEnd
)
from aider_watch.redactor import redact
from aider_watch.config import get


def _fmt_dur(sec: int) -> str:
    m, s = divmod(max(sec, 0), 60)
    if m >= 60:
        h, m = divmod(m, 60)
        return f"{h}h{m}m{s}s"
    return f"{m}m{s}s"


def _fmt_tokens(n: int) -> str:
    if n >= 1000:
        return f"{n/1000:.1f}k"
    return str(n)


def format_event(ev: _EventBase) -> str | None:
    """Event → Telegram markdown；不該推到 TG 的（raw）回 None。"""
    p = redact(ev.payload)
    t = ev.type
    if t == "session_start":
        return (f"🚀 <b>aider 啟動</b>\n"
                f"repo: <code>{p['cwd']}</code>\n"
                f"model: {p['model']}\n"
                f"pid: {p['aider_pid']}")
    if t == "file_edit":
        head = "\n".join(f"    {l}" for l in p.get("diff_head", [])[:3])
        tag = "🆕" if p.get("is_new_file") else "✏️"
        return (f"{tag} <b>edit</b> <code>{p['path']}</code> "
                f"(+{p['lines_added']} -{p['lines_deleted']})\n{head}")
    if t == "error":
        return f"❌ <b>error</b>: {p['kind']} — {p['message'][:200]}"
    if t == "commit":
        files = ", ".join(p.get("files", [])[:5])
        return f"📌 <b>commit</b> <code>{p['sha'][:7]}</code>: {p['message']}\n→ {files}"
    if t == "silent_timeout":
        return f"⏸️ <b>aider 靜默</b> {p['idle_sec']//60} 分鐘 (last: <i>{p.get('last_output_tail','')[:40]}</i>)"
    if t == "session_end":
        dur = _fmt_dur(p["duration_sec"])
        return (f"🏁 <b>session 結束</b>\n"
                f"⏱️ {dur} | 🎯 exit={p['exit_code']}\n"
                f"📊 {_fmt_tokens(p['tokens_sent'])}↑ / {_fmt_tokens(p['tokens_received'])}↓ tokens | ${p['cost_usd']}\n"
                f"📝 改 {p['files_changed']} 檔 | ❌ {p['error_count']} error")
    return None  # raw 不推
```

- [ ] **Step 4: 跑測試驗通過**

```bash
pytest tests/test_telegram_format.py -v
```

Expected: 4 passed

- [ ] **Step 5: Commit**

```bash
git add aider_watch/telegram.py tests/test_telegram_format.py
git commit -m "feat(telegram): format_event for 6 event types + redact"
```

---

## Task 5: Telegram Send（backoff + fail-open）

**Files:**
- Modify: `~/aider-watch/aider_watch/telegram.py`（補 `send`）
- Create: `~/aider-watch/tests/test_telegram_live.py`

- [ ] **Step 1: 補實作 send() 到 aider_watch/telegram.py 尾端**

```python
# === 續 telegram.py ===

_API = "https://api.telegram.org/bot{token}/sendMessage"


def send(text: str, *, chat_id: str | None = None, token: str | None = None) -> bool:
    """失敗絕不 raise；return True/False 表成功與否。"""
    token = token or get("AIDER_WATCH_TELEGRAM_TOKEN", required=True)
    chat_id = chat_id or get("AIDER_WATCH_TELEGRAM_CHAT_ID", required=True)
    url = _API.format(token=token)
    backoff = 1
    for attempt in range(3):
        try:
            r = requests.post(url, data={"chat_id": chat_id, "text": text,
                                          "parse_mode": "HTML",
                                          "disable_web_page_preview": "true"},
                              timeout=5)
            if r.status_code == 200:
                return True
            if r.status_code == 429:
                retry = r.json().get("parameters", {}).get("retry_after", backoff)
                time.sleep(retry)
                continue
        except Exception:
            pass
        time.sleep(backoff)
        backoff *= 4
    return False


def send_event(ev: _EventBase) -> bool:
    msg = format_event(ev)
    if msg is None:
        return True  # 不該推的當成功
    return send(msg)
```

- [ ] **Step 2: 寫 integration 測試 tests/test_telegram_live.py**

```python
# 2026-04-19 @ Asia/Taipei
"""真發訊息到 DM chat_id 5619078117，訊息前綴 [TEST]。需 ~/.aider-watch.env 已設。"""
import pytest
from aider_watch.telegram import send
from aider_watch.config import get

@pytest.mark.integration
def test_real_send():
    if not get("AIDER_WATCH_TELEGRAM_TOKEN"):
        pytest.skip("TG token not configured")
    ok = send("[TEST] aider-watch integration test "
              f"(from pytest, safe to ignore)")
    assert ok is True
```

- [ ] **Step 3: 跑 integration 測試（要求 env 已設）**

```bash
pytest tests/test_telegram_live.py -v -m integration
```

Expected: 1 passed（Telegram 收到 `[TEST] aider-watch ...`）

- [ ] **Step 4: Commit**

```bash
git add aider_watch/telegram.py tests/test_telegram_live.py
git commit -m "feat(telegram): send() with 429 backoff + fail-open"
```

---

## Task 6: PG Schema DDL + 建 DB 腳本

**Files:**
- Create: `~/aider-watch/schema.sql`
- Create: `~/aider-watch/scripts/install_pg.sh`

- [ ] **Step 1: 寫 schema.sql**

```sql
-- aider-watch PG schema | 2026-04-19 @ Asia/Taipei
-- 用在 database "aider_watch"（獨立於 awoooi 其他 DB）

CREATE TABLE IF NOT EXISTS sessions (
  id            TEXT PRIMARY KEY,
  started_at    TIMESTAMPTZ NOT NULL,
  ended_at      TIMESTAMPTZ,
  cwd           TEXT NOT NULL,
  model         TEXT NOT NULL,
  aider_args    TEXT[],
  aider_pid     INTEGER,
  duration_sec  INTEGER,
  tokens_sent   INTEGER DEFAULT 0,
  tokens_recv   INTEGER DEFAULT 0,
  cost_usd      NUMERIC(10,6) DEFAULT 0,
  files_changed INTEGER DEFAULT 0,
  error_count   INTEGER DEFAULT 0,
  exit_code     INTEGER,
  host          TEXT DEFAULT 'ogt-mac'
);
CREATE INDEX IF NOT EXISTS sessions_started_idx ON sessions(started_at DESC);
CREATE INDEX IF NOT EXISTS sessions_cwd_idx ON sessions(cwd);

CREATE TABLE IF NOT EXISTS events (
  id         BIGSERIAL PRIMARY KEY,
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  ts         TIMESTAMPTZ NOT NULL,
  type       TEXT NOT NULL,
  payload    JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS events_session_ts_idx ON events(session_id, ts);
CREATE INDEX IF NOT EXISTS events_type_idx ON events(type);
CREATE INDEX IF NOT EXISTS events_ts_idx ON events(ts DESC);

CREATE TABLE IF NOT EXISTS file_touches (
  id            BIGSERIAL PRIMARY KEY,
  session_id    TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  path          TEXT NOT NULL,
  touched_at    TIMESTAMPTZ NOT NULL,
  lines_added   INTEGER DEFAULT 0,
  lines_deleted INTEGER DEFAULT 0,
  is_new_file   BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS ft_path_idx ON file_touches(path);
CREATE INDEX IF NOT EXISTS ft_session_idx ON file_touches(session_id);
CREATE INDEX IF NOT EXISTS ft_date_idx ON file_touches((touched_at::date));
```

- [ ] **Step 2: 寫 scripts/install_pg.sh**

```bash
#!/usr/bin/env bash
# aider-watch PG 初始化 | 2026-04-19 @ Asia/Taipei
# 在 192.168.0.188 建 DB + user + schema。需 peer auth（ssh 進 188 以 ogt 身份跑）。

set -euo pipefail
DB=aider_watch
USER=aider_watch
HERE="$(cd "$(dirname "$0")" && pwd)"

# 1. 生隨機密碼
PW="$(openssl rand -hex 24)"

# 2. 建 DB + user + grant（superuser 身份）
sudo -u postgres psql <<SQL
CREATE USER ${USER} WITH PASSWORD '${PW}';
CREATE DATABASE ${DB} OWNER ${USER};
GRANT ALL PRIVILEGES ON DATABASE ${DB} TO ${USER};
SQL

# 3. 套 schema
PGPASSWORD="${PW}" psql -h localhost -U "${USER}" -d "${DB}" -f "${HERE}/../schema.sql"

# 4. 輸出 DSN 給使用者（貼進 ~/.aider-watch.env）
echo "--- 貼進 Mac 的 ~/.aider-watch.env ---"
echo "AIDER_WATCH_DATABASE_URL=postgresql://${USER}:${PW}@192.168.0.188:5432/${DB}"
```

- [ ] **Step 3: 在 188 執行（手動步驟，由統帥驗收）**

```bash
scp ~/aider-watch/schema.sql ~/aider-watch/scripts/install_pg.sh ogt@192.168.0.188:/tmp/
ssh ogt@192.168.0.188 'bash /tmp/install_pg.sh'
# → 複製 AIDER_WATCH_DATABASE_URL=...
```

- [ ] **Step 4: 驗證表存在**

```bash
PGPASSWORD="..." psql -h 192.168.0.188 -U aider_watch -d aider_watch -c '\dt'
```

Expected: 3 tables `sessions / events / file_touches`

- [ ] **Step 5: Commit schema & script**

```bash
cd ~/aider-watch
git add schema.sql scripts/install_pg.sh
chmod +x scripts/install_pg.sh
git commit -m "feat(pg): DDL + install_pg.sh for 192.168.0.188"
```

---

## Task 7: Storage — PG writer + JSONL buffer fallback

**Files:**
- Create: `~/aider-watch/aider_watch/storage.py`
- Create: `~/aider-watch/tests/test_storage_pg.py`
- Create: `~/aider-watch/tests/test_storage_buffer.py`

- [ ] **Step 1: 寫測試 test_storage_pg.py（用獨立 aider_watch_test DB）**

```python
# 2026-04-19 @ Asia/Taipei
import os, pytest, uuid
from datetime import datetime, timedelta
from aider_watch.events import SessionStart, FileEdit, SessionEnd
from aider_watch.config import TAIPEI
from aider_watch.storage import Storage

@pytest.fixture
def test_dsn():
    dsn = os.environ.get("AIDER_WATCH_TEST_DATABASE_URL")
    if not dsn:
        pytest.skip("AIDER_WATCH_TEST_DATABASE_URL not set")
    return dsn

@pytest.fixture
def store(test_dsn):
    s = Storage(dsn=test_dsn)
    s.ensure_schema()
    yield s
    s.truncate_all()

@pytest.mark.integration
def test_roundtrip_session_and_event(store):
    sid = str(uuid.uuid4())
    store.begin_session(
        session_id=sid, started_at=datetime.now(TAIPEI),
        cwd="/tmp/x", model="elephant-alpha", aider_args=["-m", "hi"],
        aider_pid=1234,
    )
    store.write_event(FileEdit(ts=datetime.now(TAIPEI), session_id=sid,
                               payload={"path": "a.py", "lines_added": 5,
                                        "lines_deleted": 1, "diff_head": [],
                                        "is_new_file": False}))
    store.end_session(session_id=sid, ended_at=datetime.now(TAIPEI),
                      duration_sec=10, tokens_sent=100, tokens_received=50,
                      cost_usd=0, files_changed=1, error_count=0, exit_code=0)

    rows = store.query("SELECT * FROM sessions WHERE id = %s", (sid,))
    assert len(rows) == 1
    assert rows[0]["model"] == "elephant-alpha"
    assert rows[0]["files_changed"] == 1

    evs = store.query("SELECT * FROM events WHERE session_id = %s", (sid,))
    assert len(evs) == 1
    assert evs[0]["type"] == "file_edit"

    ft = store.query("SELECT * FROM file_touches WHERE session_id = %s", (sid,))
    assert len(ft) == 1
    assert ft[0]["path"] == "a.py"
```

- [ ] **Step 2: 寫測試 test_storage_buffer.py**

```python
# 2026-04-19 @ Asia/Taipei
import json
from pathlib import Path
from datetime import datetime
from aider_watch.events import FileEdit
from aider_watch.config import TAIPEI
from aider_watch.storage import Storage

def test_buffer_when_pg_offline(tmp_path, monkeypatch):
    monkeypatch.setattr("aider_watch.config.BUFFER_DIR", tmp_path)
    s = Storage(dsn="postgresql://invalid:invalid@127.0.0.1:1/nope")
    ev = FileEdit(ts=datetime.now(TAIPEI), session_id="s1",
                  payload={"path":"a","lines_added":1,"lines_deleted":0,
                           "diff_head":[],"is_new_file":False})
    s.write_event(ev)  # 不該 raise
    files = list(tmp_path.glob("pending_*.jsonl"))
    assert len(files) == 1
    line = json.loads(files[0].read_text().strip())
    assert line["type"] == "file_edit"
```

- [ ] **Step 3: 跑驗失敗**

```bash
pytest tests/test_storage_buffer.py -v
```

Expected: `ModuleNotFoundError: No module named 'aider_watch.storage'`

- [ ] **Step 4: 寫 aider_watch/storage.py**

```python
# aider-watch storage | 2026-04-19 @ Asia/Taipei
"""PG writer with JSONL fallback when PG unreachable."""
from __future__ import annotations
import json, time
from pathlib import Path
from typing import Any
import psycopg2
import psycopg2.extras
from aider_watch import config
from aider_watch.events import _EventBase
from aider_watch.redactor import redact


class Storage:
    def __init__(self, dsn: str | None = None):
        self.dsn = dsn or config.get("AIDER_WATCH_DATABASE_URL")
        self._conn = None

    def _connect(self):
        if self._conn and not self._conn.closed:
            return self._conn
        self._conn = psycopg2.connect(self.dsn, connect_timeout=3)
        return self._conn

    def _disconnect(self):
        try:
            if self._conn:
                self._conn.close()
        except Exception:
            pass
        self._conn = None

    # ---- buffer ----
    def _buffer_write(self, rec: dict) -> None:
        config.ensure_dirs()
        fp = config.BUFFER_DIR / f"pending_{rec.get('session_id','unknown')}.jsonl"
        with fp.open("a") as f:
            f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")

    # ---- public API ----
    def ensure_schema(self):
        ddl = (Path(__file__).parent.parent / "schema.sql").read_text()
        with self._connect() as c, c.cursor() as cur:
            cur.execute(ddl)
            c.commit()

    def truncate_all(self):
        with self._connect() as c, c.cursor() as cur:
            cur.execute("TRUNCATE events, file_touches, sessions RESTART IDENTITY CASCADE")
            c.commit()

    def query(self, sql: str, params=()):
        with self._connect() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
            cur.execute(sql, params)
            return cur.fetchall() if cur.description else []

    def begin_session(self, *, session_id, started_at, cwd, model,
                       aider_args, aider_pid):
        rec = {"op":"begin_session","session_id":session_id,
               "started_at":started_at.isoformat(),"cwd":cwd,"model":model,
               "aider_args":aider_args,"aider_pid":aider_pid}
        try:
            with self._connect() as c, c.cursor() as cur:
                cur.execute(
                    "INSERT INTO sessions (id,started_at,cwd,model,aider_args,aider_pid) "
                    "VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT (id) DO NOTHING",
                    (session_id, started_at, cwd, model, aider_args, aider_pid))
                c.commit()
        except Exception:
            self._disconnect()
            self._buffer_write(rec)

    def end_session(self, *, session_id, ended_at, duration_sec, tokens_sent,
                     tokens_received, cost_usd, files_changed, error_count, exit_code):
        rec = {"op":"end_session","session_id":session_id,
               "ended_at":ended_at.isoformat(),"duration_sec":duration_sec,
               "tokens_sent":tokens_sent,"tokens_received":tokens_received,
               "cost_usd":float(cost_usd),"files_changed":files_changed,
               "error_count":error_count,"exit_code":exit_code}
        try:
            with self._connect() as c, c.cursor() as cur:
                cur.execute(
                    "UPDATE sessions SET ended_at=%s, duration_sec=%s, tokens_sent=%s, "
                    "tokens_recv=%s, cost_usd=%s, files_changed=%s, error_count=%s, "
                    "exit_code=%s WHERE id=%s",
                    (ended_at, duration_sec, tokens_sent, tokens_received,
                     cost_usd, files_changed, error_count, exit_code, session_id))
                c.commit()
        except Exception:
            self._disconnect()
            self._buffer_write(rec)

    def write_event(self, ev: _EventBase) -> None:
        payload = redact(ev.payload)
        rec = {"op":"event","session_id":ev.session_id,"type":ev.type,
               "ts":ev.ts.isoformat(),"payload":payload}
        try:
            with self._connect() as c, c.cursor() as cur:
                cur.execute(
                    "INSERT INTO events (session_id,ts,type,payload) VALUES (%s,%s,%s,%s)",
                    (ev.session_id, ev.ts, ev.type, json.dumps(payload, ensure_ascii=False)))
                if ev.type == "file_edit":
                    cur.execute(
                        "INSERT INTO file_touches (session_id,path,touched_at,lines_added,lines_deleted,is_new_file) "
                        "VALUES (%s,%s,%s,%s,%s,%s)",
                        (ev.session_id, payload["path"], ev.ts,
                         payload.get("lines_added",0), payload.get("lines_deleted",0),
                         payload.get("is_new_file", False)))
                c.commit()
        except Exception:
            self._disconnect()
            self._buffer_write(rec)

    def flush_buffer(self) -> int:
        """把 buffer 下所有 pending_*.jsonl 重新套用；成功一行刪一行（原檔重寫）。回傳成功筆數。"""
        config.ensure_dirs()
        applied = 0
        for fp in list(config.BUFFER_DIR.glob("pending_*.jsonl")):
            lines = fp.read_text().splitlines()
            remaining = []
            for line in lines:
                if not line.strip():
                    continue
                rec = json.loads(line)
                try:
                    self._apply(rec)
                    applied += 1
                except Exception:
                    remaining.append(line)  # 保留失敗的
            if remaining:
                fp.write_text("\n".join(remaining) + "\n")
            else:
                fp.unlink()
        return applied

    def _apply(self, rec: dict):
        op = rec.get("op")
        if op == "event":
            with self._connect() as c, c.cursor() as cur:
                cur.execute(
                    "INSERT INTO events (session_id,ts,type,payload) VALUES (%s,%s,%s,%s)",
                    (rec["session_id"], rec["ts"], rec["type"],
                     json.dumps(rec["payload"], ensure_ascii=False)))
                if rec["type"] == "file_edit":
                    p = rec["payload"]
                    cur.execute(
                        "INSERT INTO file_touches (session_id,path,touched_at,lines_added,lines_deleted,is_new_file) "
                        "VALUES (%s,%s,%s,%s,%s,%s)",
                        (rec["session_id"], p["path"], rec["ts"],
                         p.get("lines_added",0), p.get("lines_deleted",0),
                         p.get("is_new_file", False)))
                c.commit()
        elif op == "begin_session":
            with self._connect() as c, c.cursor() as cur:
                cur.execute(
                    "INSERT INTO sessions (id,started_at,cwd,model,aider_args,aider_pid) "
                    "VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT (id) DO NOTHING",
                    (rec["session_id"], rec["started_at"], rec["cwd"],
                     rec["model"], rec["aider_args"], rec["aider_pid"]))
                c.commit()
        elif op == "end_session":
            with self._connect() as c, c.cursor() as cur:
                cur.execute(
                    "UPDATE sessions SET ended_at=%s, duration_sec=%s, tokens_sent=%s, "
                    "tokens_recv=%s, cost_usd=%s, files_changed=%s, error_count=%s, "
                    "exit_code=%s WHERE id=%s",
                    (rec["ended_at"], rec["duration_sec"], rec["tokens_sent"],
                     rec["tokens_received"], rec["cost_usd"], rec["files_changed"],
                     rec["error_count"], rec["exit_code"], rec["session_id"]))
                c.commit()
```

- [ ] **Step 5: 跑測試驗通過（需先設 AIDER_WATCH_TEST_DATABASE_URL 指向 aider_watch_test）**

```bash
# 在 188 預先建 aider_watch_test DB：
ssh ogt@192.168.0.188 "sudo -u postgres createdb -O aider_watch aider_watch_test"
export AIDER_WATCH_TEST_DATABASE_URL="postgresql://aider_watch:<pw>@192.168.0.188:5432/aider_watch_test"
pytest tests/test_storage_pg.py tests/test_storage_buffer.py -v -m integration
```

Expected: 2 passed

- [ ] **Step 6: Commit**

```bash
git add aider_watch/storage.py tests/test_storage_pg.py tests/test_storage_buffer.py
git commit -m "feat(storage): PG writer + JSONL buffer fallback + flush"
```

---

## Task 8: aider stdout / chat_history / git diff 解析

**Files:**
- Create: `~/aider-watch/aider_watch/parsers.py`
- Create: `~/aider-watch/tests/test_parsers.py`
- Create: `~/aider-watch/tests/fixtures/aider_stdout_sample.txt`
- Create: `~/aider-watch/tests/fixtures/aider_chat_history_sample.md`

- [ ] **Step 1: 先跑一次真 aider 抓 stdout 樣本**

```bash
cd /tmp && rm -rf pa && mkdir pa && cd pa && git init -q
echo "def add(a,b): return 0" > calc.py && git add -A && git commit -m init -q
aider --model openrouter/openrouter/elephant-alpha --yes --no-stream --no-pretty \
  --message "fix calc.py add function — should return a+b" --exit \
  > ~/aider-watch/tests/fixtures/aider_stdout_sample.txt 2>&1 || true
cp .aider.chat.history.md ~/aider-watch/tests/fixtures/aider_chat_history_sample.md
```

- [ ] **Step 2: 寫測試 tests/test_parsers.py（讀 fixture 驗解析）**

```python
# 2026-04-19 @ Asia/Taipei
from pathlib import Path
from aider_watch.parsers import parse_stdout_banner, parse_chat_history, parse_git_diff_stat

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

def test_parse_banner():
    out = (FIX / "aider_stdout_sample.txt").read_text()
    info = parse_stdout_banner(out)
    assert info["model"] is not None and "elephant" in info["model"].lower()

def test_parse_chat_history_token_totals():
    md = (FIX / "aider_chat_history_sample.md").read_text()
    stats = parse_chat_history(md)
    assert stats["tokens_sent"] >= 0
    assert stats["tokens_received"] >= 0
    assert isinstance(stats["file_edits"], list)

def test_parse_git_diff_stat_empty():
    # 空 diff = 沒改檔
    assert parse_git_diff_stat("") == []
```

- [ ] **Step 3: 跑驗失敗**

```bash
pytest tests/test_parsers.py -v
```

Expected: `ModuleNotFoundError: No module named 'aider_watch.parsers'`

- [ ] **Step 4: 寫 aider_watch/parsers.py**

```python
# aider-watch parsers | 2026-04-19 @ Asia/Taipei
"""從 aider stdout / chat_history.md / git diff 抽事件。"""
from __future__ import annotations
import re, subprocess
from pathlib import Path

_BANNER_MODEL = re.compile(r"^\s*Model:\s*(\S+)", re.MULTILINE)
_BANNER_VER = re.compile(r"^\s*Aider v([\d\.]+)", re.MULTILINE)
_TOKENS_LINE = re.compile(r"Tokens:\s+([\d\.]+)([km]?)\s+sent,\s+([\d\.]+)([km]?)\s+received", re.IGNORECASE)


def _parse_num(n: str, suffix: str) -> int:
    v = float(n)
    if suffix.lower() == "k":
        v *= 1000
    elif suffix.lower() == "m":
        v *= 1_000_000
    return int(v)


def parse_stdout_banner(stdout: str) -> dict:
    m_model = _BANNER_MODEL.search(stdout)
    m_ver = _BANNER_VER.search(stdout)
    return {
        "model": m_model.group(1) if m_model else None,
        "cli_version": m_ver.group(1) if m_ver else None,
    }


def parse_chat_history(md: str) -> dict:
    """從 .aider.chat.history.md 抽 token 累計 + 檔案提及。"""
    total_sent = 0
    total_recv = 0
    for m in _TOKENS_LINE.finditer(md):
        total_sent += _parse_num(m.group(1), m.group(2))
        total_recv += _parse_num(m.group(3), m.group(4))
    # 檔案提及：>>>>>>> REPLACE 之前的 block 頭有檔名，這裡先簡版，用 file_edit 回 git diff 為主
    file_edits: list[str] = []  # 留給後續增強，canonical 以 git diff 為準
    return {"tokens_sent": total_sent, "tokens_received": total_recv,
            "file_edits": file_edits}


def parse_git_diff_stat(diff_stat: str) -> list[dict]:
    """解析 `git diff --numstat <pre>..<post>` 輸出：\"12\\t3\\tpath/to/file\"。"""
    out = []
    for line in diff_stat.strip().splitlines():
        parts = line.split("\t")
        if len(parts) != 3:
            continue
        add, dele, path = parts
        try:
            a = int(add) if add != "-" else 0
            d = int(dele) if dele != "-" else 0
        except ValueError:
            continue
        out.append({"path": path, "lines_added": a, "lines_deleted": d,
                    "is_new_file": d == 0 and not Path(path).exists() is False})
    return out


def git_numstat(cwd: Path, pre_sha: str, post_sha: str = "HEAD") -> list[dict]:
    """從 repo 取 diff numstat。若 cwd 不是 repo 或 pre_sha 無效回空 list。"""
    try:
        r = subprocess.run(
            ["git", "-C", str(cwd), "diff", "--numstat", pre_sha, post_sha],
            capture_output=True, text=True, timeout=10)
        if r.returncode != 0:
            return []
        return parse_git_diff_stat(r.stdout)
    except Exception:
        return []


def git_diff_head(cwd: Path, path: str, pre_sha: str, n: int = 3) -> list[str]:
    """取某檔 diff 前 n 行（給 Telegram 預覽用）。"""
    try:
        r = subprocess.run(
            ["git", "-C", str(cwd), "diff", "-U0", pre_sha, "--", path],
            capture_output=True, text=True, timeout=5)
        lines = r.stdout.splitlines()
        picks = []
        for l in lines:
            if l.startswith("@@") or l.startswith("+") or l.startswith("-"):
                if l.startswith("+++") or l.startswith("---"):
                    continue
                picks.append(l)
                if len(picks) >= n:
                    break
        return picks
    except Exception:
        return []
```

- [ ] **Step 5: 跑測試驗通過**

```bash
pytest tests/test_parsers.py -v
```

Expected: 3 passed

- [ ] **Step 6: Commit**

```bash
git add aider_watch/parsers.py tests/test_parsers.py tests/fixtures/
git commit -m "feat(parsers): stdout banner + chat_history tokens + git numstat"
```

---

## Task 9: Wrapper — subprocess 生命週期 + Dispatcher

**Files:**
- Create: `~/aider-watch/aider_watch/wrapper.py`
- Create: `~/aider-watch/bin/aiderw`

- [ ] **Step 1: 寫 aider_watch/wrapper.py**

```python
# aider-watch wrapper | 2026-04-19 @ Asia/Taipei
"""aider subprocess wrapper：stdout tee、即時 event、退出時 canonical 重建。"""
from __future__ import annotations
import atexit, json, os, socket, subprocess, sys, threading, time
from datetime import datetime
from pathlib import Path
from ulid import ULID
from aider_watch import config
from aider_watch.config import TAIPEI, SESSIONS_DIR, LIVE_LOG
from aider_watch.events import (
    SessionStart, FileEdit, ErrorEvent, SilentTimeout, SessionEnd, _EventBase)
from aider_watch.storage import Storage
from aider_watch.telegram import send_event
from aider_watch.parsers import parse_stdout_banner, parse_chat_history, git_numstat, git_diff_head

IDLE_THRESHOLD_SEC = 120


class Dispatcher:
    def __init__(self, store: Storage, session_id: str, session_jsonl: Path):
        self.store = store
        self.session_id = session_id
        self.jsonl = session_jsonl
        self.jsonl.parent.mkdir(parents=True, exist_ok=True)

    def emit(self, ev: _EventBase) -> None:
        # 1. JSONL source-of-truth
        with self.jsonl.open("a") as f:
            f.write(ev.model_dump_json() + "\n")
        # 2. live.log
        try:
            with LIVE_LOG.open("a") as f:
                f.write(f"[{ev.ts.isoformat()}] {ev.type} {json.dumps(ev.payload, ensure_ascii=False, default=str)[:200]}\n")
        except Exception:
            pass
        # 3. PG (with fallback)
        try:
            self.store.write_event(ev)
        except Exception:
            pass
        # 4. Telegram
        try:
            send_event(ev)
        except Exception:
            pass


def _now() -> datetime:
    return datetime.now(TAIPEI)


def _pre_git_sha(cwd: Path) -> str | None:
    try:
        r = subprocess.run(["git", "-C", str(cwd), "rev-parse", "HEAD"],
                           capture_output=True, text=True, timeout=3)
        return r.stdout.strip() if r.returncode == 0 else None
    except Exception:
        return None


def main(argv: list[str] | None = None) -> int:
    argv = list(argv if argv is not None else sys.argv[1:])
    config.ensure_dirs()
    cwd = Path.cwd()

    sid = str(ULID())
    started = _now()
    date_dir = SESSIONS_DIR / started.strftime("%Y/%m/%d")
    jsonl = date_dir / f"{sid}_{cwd.name}_{os.getpid()}.jsonl"
    stdout_buf: list[str] = []
    last_output_ts = [time.time()]
    error_count = [0]
    wrapper_crashed = [False]
    pre_sha = _pre_git_sha(cwd)

    store = Storage()
    dispatch = Dispatcher(store, sid, jsonl)

    # 找 aider 實體（不包自己）
    aider_bin = os.environ.get("AIDER_BIN") or str(Path.home() / ".local/bin/aider")

    # 啟 subprocess
    proc = subprocess.Popen(
        [aider_bin] + argv, cwd=str(cwd),
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
        text=True, bufsize=1,  # line-buffered
    )

    # 即時發 session_start
    store.begin_session(
        session_id=sid, started_at=started, cwd=str(cwd),
        model="openrouter/openrouter/elephant-alpha",  # default; parse_stdout_banner 後更新
        aider_args=argv, aider_pid=proc.pid,
    )
    dispatch.emit(SessionStart(
        ts=started, session_id=sid,
        payload={"cwd": str(cwd), "model": "openrouter/openrouter/elephant-alpha",
                 "aider_args": argv, "aider_pid": proc.pid, "cli_version": "unknown"}))

    # stdout 讀取 thread + tee
    def _reader():
        for line in proc.stdout:  # type: ignore[arg-type]
            sys.stdout.write(line)
            sys.stdout.flush()
            stdout_buf.append(line)
            last_output_ts[0] = time.time()
            if "error" in line.lower() or "exception" in line.lower():
                error_count[0] += 1
    t = threading.Thread(target=_reader, daemon=True)
    t.start()

    # silent timeout monitor
    silent_fired = [False]
    def _silent_watch():
        while proc.poll() is None:
            time.sleep(10)
            idle = time.time() - last_output_ts[0]
            if idle >= IDLE_THRESHOLD_SEC and not silent_fired[0]:
                silent_fired[0] = True
                tail = "".join(stdout_buf[-1:])[-80:] if stdout_buf else ""
                dispatch.emit(SilentTimeout(
                    ts=_now(), session_id=sid,
                    payload={"idle_sec": int(idle), "last_output_tail": tail}))
    w = threading.Thread(target=_silent_watch, daemon=True)
    w.start()

    # atexit 保險：wrapper 崩潰時仍寫半份 session_end
    def _emergency():
        if wrapper_crashed[0]:
            return  # 已正常結束
        try:
            store.end_session(
                session_id=sid, ended_at=_now(), duration_sec=int(time.time()),
                tokens_sent=0, tokens_received=0, cost_usd=0,
                files_changed=0, error_count=error_count[0], exit_code=-999)
        except Exception:
            pass
    atexit.register(_emergency)

    # 等結束
    exit_code = proc.wait()
    wrapper_crashed[0] = True  # 正常路徑，關掉 atexit 保險
    duration = int((_now() - started).total_seconds())

    # 銜接 stdout banner 資訊
    stdout_text = "".join(stdout_buf)
    banner = parse_stdout_banner(stdout_text)

    # canonical file edits from git diff
    if pre_sha:
        edits = git_numstat(cwd, pre_sha, "HEAD")
        for e in edits:
            head = git_diff_head(cwd, e["path"], pre_sha, 3)
            dispatch.emit(FileEdit(
                ts=_now(), session_id=sid,
                payload={"path": e["path"], "lines_added": e["lines_added"],
                         "lines_deleted": e["lines_deleted"], "diff_head": head,
                         "is_new_file": e["is_new_file"]}))

    # tokens 從 chat_history.md
    tokens_sent = tokens_recv = 0
    chat_md = cwd / ".aider.chat.history.md"
    if chat_md.exists():
        s = parse_chat_history(chat_md.read_text(errors="ignore"))
        tokens_sent = s["tokens_sent"]
        tokens_recv = s["tokens_received"]

    # error event 若 exit code 非 0
    if exit_code != 0:
        dispatch.emit(ErrorEvent(
            ts=_now(), session_id=sid,
            payload={"kind": "non_zero_exit",
                     "message": f"aider exited with {exit_code}",
                     "context_50chars": stdout_text[-50:]}))

    files_changed = len(git_numstat(cwd, pre_sha, "HEAD")) if pre_sha else 0

    # 最終 session_end
    dispatch.emit(SessionEnd(
        ts=_now(), session_id=sid,
        payload={"duration_sec": duration, "tokens_sent": tokens_sent,
                 "tokens_received": tokens_recv, "cost_usd": 0,
                 "files_changed": files_changed, "error_count": error_count[0],
                 "exit_code": exit_code}))
    store.end_session(
        session_id=sid, ended_at=_now(), duration_sec=duration,
        tokens_sent=tokens_sent, tokens_received=tokens_recv, cost_usd=0,
        files_changed=files_changed, error_count=error_count[0], exit_code=exit_code)

    return exit_code


if __name__ == "__main__":
    sys.exit(main())
```

- [ ] **Step 2: 寫 bin/aiderw（shim）**

```bash
#!/usr/bin/env python3
# aider-watch shim | 2026-04-19 @ Asia/Taipei
import sys
from aider_watch.wrapper import main
sys.exit(main())
```

```bash
chmod +x ~/aider-watch/bin/aiderw
```

- [ ] **Step 3: Commit**

```bash
git add aider_watch/wrapper.py bin/aiderw
git commit -m "feat(wrapper): subprocess + stdout tee + silent timeout + canonical rebuild"
```

---

## Task 10: CLI — `aider-watch {doctor,flush,replay,report}`

**Files:**
- Create: `~/aider-watch/aider_watch/cli.py`

- [ ] **Step 1: 寫 aider_watch/cli.py**

```python
# aider-watch CLI | 2026-04-19 @ Asia/Taipei
from __future__ import annotations
import argparse, json, os, subprocess, sys
from pathlib import Path
from datetime import datetime
from aider_watch import config
from aider_watch.config import TAIPEI, LOGS_DIR, STATE_FILE
from aider_watch.storage import Storage


def cmd_doctor(_args) -> int:
    ok = True
    print("== aider-watch doctor ==")
    # 1. env
    for k in ("AIDER_WATCH_DATABASE_URL", "AIDER_WATCH_TELEGRAM_TOKEN",
              "AIDER_WATCH_TELEGRAM_CHAT_ID"):
        v = config.get(k)
        status = "✅" if v else "❌"
        print(f"  env {k:40s} {status}")
        if not v: ok = False
    # 2. PG
    try:
        s = Storage()
        r = s.query("SELECT count(*) AS c FROM sessions")
        print(f"  PG reachable ✅  sessions={r[0]['c']}")
    except Exception as e:
        print(f"  PG reachable ❌  {e}")
        ok = False
    # 3. Telegram（getMe，不發訊息）
    try:
        import requests
        tok = config.get("AIDER_WATCH_TELEGRAM_TOKEN", required=True)
        r = requests.get(f"https://api.telegram.org/bot{tok}/getMe", timeout=5)
        if r.status_code == 200 and r.json().get("ok"):
            print(f"  Telegram bot ✅ @{r.json()['result']['username']}")
        else:
            print(f"  Telegram bot ❌ {r.status_code}"); ok = False
    except Exception as e:
        print(f"  Telegram bot ❌ {e}"); ok = False
    # 4. launchd
    res = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
    for label in ("com.awoooi.aider-watch.flush",
                  "com.awoooi.aider-watch.daily",
                  "com.awoooi.aider-watch.weekly"):
        mark = "✅" if label in res.stdout else "❌"
        print(f"  launchd {label:40s} {mark}")
        if mark == "❌": ok = False
    # 5. 磁碟
    import shutil
    du = shutil.disk_usage(str(config.WATCH_ROOT))
    print(f"  disk free: {du.free/1e9:.1f}G / {du.total/1e9:.1f}G")
    return 0 if ok else 1


def cmd_flush(_args) -> int:
    s = Storage()
    n = s.flush_buffer()
    STATE_FILE.write_text(json.dumps({"last_flush_ts": datetime.now(TAIPEI).isoformat(),
                                       "applied": n}))
    print(f"flushed {n} records")
    return 0


def cmd_replay(args) -> int:
    """從本機 JSONL 重建 events 到 PG（幂等依 ON CONFLICT DO NOTHING）。"""
    sid = args.session_id
    hits = list(config.SESSIONS_DIR.rglob(f"{sid}_*.jsonl"))
    if not hits:
        print(f"session {sid} not found in sessions/"); return 1
    from aider_watch.events import from_json
    s = Storage()
    for fp in hits:
        for line in fp.read_text().splitlines():
            if not line.strip(): continue
            ev = from_json(line)
            s.write_event(ev)
    print(f"replayed {sid}")
    return 0


def cmd_report(args) -> int:
    from aider_watch.reporter import daily, weekly
    if args.kind == "daily": return daily()
    if args.kind == "weekly": return weekly()
    print("unknown report kind"); return 1


def main() -> int:
    p = argparse.ArgumentParser(prog="aider-watch")
    sub = p.add_subparsers(dest="cmd", required=True)
    sub.add_parser("doctor").set_defaults(func=cmd_doctor)
    sub.add_parser("flush").set_defaults(func=cmd_flush)
    rp = sub.add_parser("replay"); rp.add_argument("session_id"); rp.set_defaults(func=cmd_replay)
    rr = sub.add_parser("report"); rr.add_argument("kind", choices=["daily","weekly"]); rr.set_defaults(func=cmd_report)
    a = p.parse_args()
    return a.func(a)


if __name__ == "__main__":
    sys.exit(main())
```

- [ ] **Step 2: Commit**

```bash
git add aider_watch/cli.py
git commit -m "feat(cli): doctor/flush/replay/report subcommands"
```

---

## Task 11: Reporter — daily/weekly aggregation

**Files:**
- Create: `~/aider-watch/aider_watch/reporter.py`

- [ ] **Step 1: 寫 aider_watch/reporter.py**

```python
# aider-watch reporter | 2026-04-19 @ Asia/Taipei
from __future__ import annotations
from datetime import datetime, timedelta
from aider_watch.config import TAIPEI
from aider_watch.storage import Storage
from aider_watch.telegram import send


def _fmt_dur(sec: int) -> str:
    h, rem = divmod(sec, 3600); m, s = divmod(rem, 60)
    if h: return f"{h}h{m}m"
    if m: return f"{m}m{s}s"
    return f"{s}s"


def _fmt_tokens(n: int) -> str:
    return f"{n/1000:.1f}k" if n >= 1000 else str(n)


def _render(title: str, start, stats: dict, top_files: list[dict]) -> str:
    lines = [f"📊 <b>{title}</b>  <i>({start})</i>\n"]
    if stats["sessions"] == 0:
        lines.append("（無 aider 活動）")
        return "\n".join(lines)
    lines.append(f"• Sessions: {stats['sessions']}")
    lines.append(f"• 總時長: {_fmt_dur(stats['total_sec'] or 0)}")
    lines.append(f"• Tokens 送/收: {_fmt_tokens(stats['toks_sent'] or 0)} / {_fmt_tokens(stats['toks_recv'] or 0)}")
    lines.append(f"• 檔案變更: {stats['files'] or 0}")
    lines.append(f"• 錯誤: {stats['errors'] or 0}")
    if top_files:
        lines.append("\n<b>Top 檔案</b>:")
        for f in top_files[:5]:
            lines.append(f"  • <code>{f['path']}</code>  {f['touches']}× (+{f['adds']}/-{f['dels']})")
    return "\n".join(lines)


def _report(start_dt: datetime, end_dt: datetime, title: str) -> int:
    s = Storage()
    row = s.query(
        "SELECT count(*) AS sessions, sum(duration_sec) AS total_sec, "
        "sum(tokens_sent) AS toks_sent, sum(tokens_recv) AS toks_recv, "
        "sum(files_changed) AS files, sum(error_count) AS errors "
        "FROM sessions WHERE started_at >= %s AND started_at < %s",
        (start_dt, end_dt))[0]
    top = s.query(
        "SELECT path, count(*) AS touches, sum(lines_added) AS adds, "
        "sum(lines_deleted) AS dels "
        "FROM file_touches WHERE touched_at >= %s AND touched_at < %s "
        "GROUP BY path ORDER BY touches DESC LIMIT 5",
        (start_dt, end_dt))
    msg = _render(title, start_dt.strftime("%Y-%m-%d"), row, top)
    ok = send(msg)
    return 0 if ok else 1


def daily() -> int:
    now = datetime.now(TAIPEI)
    start = now.replace(hour=0, minute=0, second=0, microsecond=0)
    end = start + timedelta(days=1)
    return _report(start, end, "aider 日報")


def weekly() -> int:
    now = datetime.now(TAIPEI)
    start = now.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=now.weekday())
    end = start + timedelta(days=7)
    return _report(start, end, "aider 週報")
```

- [ ] **Step 2: Commit**

```bash
git add aider_watch/reporter.py
git commit -m "feat(reporter): daily/weekly aggregation → TG"
```

---

## Task 12: Launchd plist 3 支

**Files:**
- Create: `~/aider-watch/launchd/com.awoooi.aider-watch.flush.plist`
- Create: `~/aider-watch/launchd/com.awoooi.aider-watch.daily.plist`
- Create: `~/aider-watch/launchd/com.awoooi.aider-watch.weekly.plist`
- Create: `~/aider-watch/scripts/install_launchd.sh`

- [ ] **Step 1: 寫 flush.plist（每 5 分鐘）**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- aider-watch flush | 2026-04-19 @ Asia/Taipei -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>com.awoooi.aider-watch.flush</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/ogt/.local/bin/aider-watch</string>
    <string>flush</string>
  </array>
  <key>StartInterval</key><integer>300</integer>
  <key>EnvironmentVariables</key>
  <dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
  <key>StandardOutPath</key><string>/Users/ogt/aider-watch/logs/flush.log</string>
  <key>StandardErrorPath</key><string>/Users/ogt/aider-watch/logs/flush.log</string>
  <key>KeepAlive</key><false/>
  <key>RunAtLoad</key><false/>
</dict></plist>
```

- [ ] **Step 2: 寫 daily.plist（每日 23:50）**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- aider-watch daily | 2026-04-19 @ Asia/Taipei -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>com.awoooi.aider-watch.daily</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/ogt/.local/bin/aider-watch</string>
    <string>report</string><string>daily</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict><key>Hour</key><integer>23</integer><key>Minute</key><integer>50</integer></dict>
  <key>EnvironmentVariables</key>
  <dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
  <key>StandardOutPath</key><string>/Users/ogt/aider-watch/logs/daily.log</string>
  <key>StandardErrorPath</key><string>/Users/ogt/aider-watch/logs/daily.log</string>
</dict></plist>
```

- [ ] **Step 3: 寫 weekly.plist（週日 22:00）**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- aider-watch weekly | 2026-04-19 @ Asia/Taipei -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>com.awoooi.aider-watch.weekly</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/ogt/.local/bin/aider-watch</string>
    <string>report</string><string>weekly</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict><key>Weekday</key><integer>0</integer>
        <key>Hour</key><integer>22</integer><key>Minute</key><integer>0</integer></dict>
  <key>EnvironmentVariables</key>
  <dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
  <key>StandardOutPath</key><string>/Users/ogt/aider-watch/logs/weekly.log</string>
  <key>StandardErrorPath</key><string>/Users/ogt/aider-watch/logs/weekly.log</string>
</dict></plist>
```

- [ ] **Step 4: 寫 scripts/install_launchd.sh**

```bash
#!/usr/bin/env bash
# aider-watch launchd install | 2026-04-19 @ Asia/Taipei
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
DEST="$HOME/Library/LaunchAgents"
mkdir -p "$DEST" "$HOME/aider-watch/logs"

for p in flush daily weekly; do
  cp "${HERE}/../launchd/com.awoooi.aider-watch.${p}.plist" "${DEST}/"
  launchctl unload "${DEST}/com.awoooi.aider-watch.${p}.plist" 2>/dev/null || true
  launchctl load -w "${DEST}/com.awoooi.aider-watch.${p}.plist"
  echo "loaded: com.awoooi.aider-watch.${p}"
done
launchctl list | grep aider-watch
```

- [ ] **Step 5: Commit**

```bash
chmod +x scripts/install_launchd.sh
git add launchd/ scripts/install_launchd.sh
git commit -m "feat(launchd): flush 5min + daily 23:50 + weekly Sun 22:00"
```

---

## Task 13: 總安裝腳本

**Files:**
- Create: `~/aider-watch/scripts/install.sh`

- [ ] **Step 1: 寫 scripts/install.sh**

```bash
#!/usr/bin/env bash
# aider-watch one-shot installer | 2026-04-19 @ Asia/Taipei
set -euo pipefail
REPO="$HOME/aider-watch"
cd "$REPO"

# 1. 套件
pipx install --python /Users/ogt/.pyenv/versions/3.11.7/bin/python3 -e . || pipx upgrade aider-watch
which aiderw aider-watch

# 2. symlink 入 PATH
ln -sf "$HOME/.local/bin/aiderw" /opt/homebrew/bin/aiderw
echo "installed: aiderw → /opt/homebrew/bin/aiderw"

# 3. env 檢查（使用者需已建）
if [[ ! -f "$HOME/.aider-watch.env" ]]; then
  echo "❌ 請先建 ~/.aider-watch.env（至少 AIDER_WATCH_DATABASE_URL/TELEGRAM_TOKEN/CHAT_ID）"
  exit 1
fi
chmod 600 "$HOME/.aider-watch.env"

# 4. 工作目錄
mkdir -p "$HOME/aider-watch/"{sessions,buffer,logs}

# 5. launchd
bash "$REPO/scripts/install_launchd.sh"

# 6. doctor
aider-watch doctor
```

- [ ] **Step 2: Commit**

```bash
chmod +x scripts/install.sh
git add scripts/install.sh
git commit -m "feat(install): one-shot installer"
```

---

## Task 14: 寫 ~/.aider-watch.env + 跑 install + E2E

**Files:**
- Create: `~/.aider-watch.env`（手動，含 Task 6 Step 3 輸出的 DSN）

- [ ] **Step 1: 寫 ~/.aider-watch.env（chmod 600）**

```bash
cat > ~/.aider-watch.env <<'EOF'
# aider-watch secrets | 2026-04-19 @ Asia/Taipei
AIDER_WATCH_DATABASE_URL=postgresql://aider_watch:<Task6產生的PW>@192.168.0.188:5432/aider_watch
AIDER_WATCH_TELEGRAM_TOKEN=<TELEGRAM_BOT_TOKEN_FROM_OWNER_SECRET_STORE>
AIDER_WATCH_TELEGRAM_CHAT_ID=5619078117
AIDER_WATCH_HOSTNAME=ogt-mac
EOF
chmod 600 ~/.aider-watch.env
```

- [ ] **Step 2: 跑安裝**

```bash
cd ~/aider-watch && bash scripts/install.sh
```

Expected: `aider-watch doctor` 全 ✅

- [ ] **Step 3: E2E — happy path**

```bash
mkdir -p /tmp/aw-e2e && cd /tmp/aw-e2e
git init -q && echo "def f(): return 0" > calc.py && git add -A && git commit -m init -q
aiderw --yes --no-stream --no-pretty \
  --message "fix calc.py f should return 42" --exit
```

Expected:
- Telegram 收到 🚀 + ✏️ (或 🆕) + 🏁（若改檔；若沒改也至少有 🚀 + 🏁）
- `psql ...` 查 `sessions` 有 1 筆 `exit_code=0`
- `~/aider-watch/sessions/2026/04/19/<sid>_aw-e2e_<pid>.jsonl` 存在

- [ ] **Step 4: E2E — PG 斷線降級**

```bash
# 模擬：用錯的 DSN 跑一次
export AIDER_WATCH_DATABASE_URL="postgresql://x:x@127.0.0.1:1/nope"
cd /tmp/aw-e2e && aiderw --yes --no-stream --no-pretty --message "add comment to calc.py" --exit
ls ~/aider-watch/buffer/  # → 應有 pending_*.jsonl
unset AIDER_WATCH_DATABASE_URL
aider-watch flush
ls ~/aider-watch/buffer/  # → 應為空
```

Expected: buffer 產生 → flush 後 PG 補齊、buffer 清空

- [ ] **Step 5: E2E — 日報**

```bash
aider-watch report daily
```

Expected: Telegram 收到日報，數字含今天的 2 場 session

- [ ] **Step 6: 最終 commit**

```bash
cd ~/aider-watch
git add -A
git commit --allow-empty -m "chore: E2E verified 2026-04-19"
```

---

## Self-Review 結論

**Spec 覆蓋檢查**（設計稿 §1-§7 逐段）：

| 設計稿章節 | 對應 Task |
|-----------|-----------|
| §1 架構總覽 | Task 9 (wrapper), 11 (reporter), 12 (launchd) |
| §2 元件分工 5 個單元 | Task 1 (events), 2+4+5 (redactor/tg), 7 (storage), 8 (parsers), 9 (wrapper), 10 (cli), 11 (reporter) |
| §3 Event Schema 7 種 | Task 1 全部 7 類 |
| §4 PG schema 3 張表 | Task 6 |
| §5 檔案佈局 | Task 0 (scaffold), 3 (config), 12 (launchd), 13 (install), 14 (env) |
| §6 降級：PG/TG/crash/secret | Task 5 (TG backoff), 7 (PG fallback), 9 (atexit), 2 (redactor) |
| §7 測試 unit/integration/E2E | Task 1,2,4,8 (unit); 5,7 (integration); 14 (E2E 三種) |

**沒有漏**。

**Placeholder 掃描**：無「TBD / TODO / 略」。`<Task6產生的PW>` 是 Task 6 Step 3 實際生成的真實值，執行時填入，非設計漏洞。

**Type 一致性**：event 欄位名稱 `tokens_sent`/`tokens_received` 在 events.py → telegram.py → storage.py → reporter.py 全統一（storage 寫入 PG 時映射到欄位 `tokens_recv` 是 PG DDL 名，程式內仍用 `tokens_received` 傳參）。

---

## Execution Handoff

**Plan complete and saved to** [docs/superpowers/plans/2026-04-19-aider-watch.md](2026-04-19-aider-watch.md)。

兩種執行方式：

1. **Subagent-Driven（推薦）** — 每個 Task 派一個獨立 subagent 跑，Claude 逐任務 review、測試、commit 再進下一個。優點：隔離、失敗不汙染 context、每 task 有 review gate
2. **Inline Execution** — 本 session 逐步跑，批次到 checkpoint 停一下讓你 review

選哪個？
