Files
awoooi/docs/superpowers/plans/2026-04-20-aider-watch-v2.md

71 KiB
Raw Blame History

aider-watch v2 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: 把 aider CLI 監控完全整合進 awoooi AI 自主化飛輪 — server 端新增 webhook + 處理 job + AI Router feedback hookMac client 薄殼只做 event capture + HTTP POST。

Architecture: Mac aiderw wrapper 包 aider subprocess → HMAC-signed HTTP POST 到 awoooi API webhook → Redis stream → background processor → aider_events table + 若 error 自動建 incident(走既有 TG gateway+ 日聚合寫 playbooks.symptom_pattern + AI Router 讀 feedback 調整 model routing。

Tech Stack: FastAPI, SQLAlchemy async, Pydantic v2, Redis stream, psycopg2, requests, pexpect, HMAC-SHA256, launchd, pipx / Python 3.11

Spec: docs/superpowers/specs/2026-04-20-aider-watch-v2-design.md


File Structure

Server side — awoooi repo

檔案 職責
apps/api/migrations/adr091_aider_events_schema.sql DB migration新表 aider_events
apps/api/src/models/aider.py Pydantic: AiderEventIn, 6 種 event type payload schema
apps/api/src/utils/secret_redactor.py 泛用 secret 遮罩(通用 util不只 aider 用)
apps/api/src/repositories/aider_event_repository.py CRUD aider_events + 聚合查詢
apps/api/src/services/aider_event_service.py 分類、建 incident、抽 symptom_pattern
apps/api/src/api/v1/aider_events.py POST webhook + HMAC 驗證
apps/api/src/jobs/aider_event_processor_job.py Redis stream consumer + dispatcher + 日 batch pattern extractor
apps/api/src/services/ai_router.py 新增 feedback_from_aider_events() method修改既有檔
apps/api/src/core/config.py 新增 3 settings修改既有檔
apps/api/src/main.py lifespan 註冊新 job修改既有檔
apps/api/tests/test_aider_event_models.py Pydantic schema unit tests
apps/api/tests/test_secret_redactor.py Redactor unit tests
apps/api/tests/test_aider_event_service.py Service logic tests
apps/api/tests/test_aider_events_api.py API webhook + HMAC tests
apps/api/tests/integration/test_aider_event_flow.py E2E: webhook → Redis → job → DB

Mac client — awoooi/scripts/aider_watch_client/

檔案 職責
scripts/aider_watch_client/pyproject.toml pipx 安裝定義
scripts/aider_watch_client/aider_watch_client/__init__.py 套件版本
scripts/aider_watch_client/aider_watch_client/parsers.py aider stdout banner + chat_history.md + git numstat從 v1 搬)
scripts/aider_watch_client/aider_watch_client/api_client.py HTTP POST + HMAC-SHA256 + retry backoff
scripts/aider_watch_client/aider_watch_client/buffer.py JSONL buffer write + flush
scripts/aider_watch_client/aider_watch_client/config.py 讀 ~/.aider-watch.env + 路徑常數
scripts/aider_watch_client/aider_watch_client/redactor.py 與 server 同步的 secret redactor複本獨立可部署
scripts/aider_watch_client/aider_watch_client/aiderw.py 主 wrappersubprocess + event capture + dispatcher
scripts/aider_watch_client/bin/aiderw CLI shim
scripts/aider_watch_client/scripts/install.sh 安裝pipx install + 寫 ~/.aider-watch.env + 註冊 launchd
scripts/aider_watch_client/launchd/com.awoooi.aider-flush.plist 每 5min flush buffer
scripts/aider_watch_client/tests/test_api_client.py HMAC 簽章 + retry 單元測試
scripts/aider_watch_client/tests/test_buffer.py Buffer write/flush 測試

Phase A: Server-side10 tasks

Task A0: 前置 — 取 PG 密碼 + 生 HMAC secret + 本機 env

Files:

  • Create: /tmp/aider_webhook_secret.txt(安裝後 shred 刪)

  • Step 1: 從 K8s Secret 取 PG 密碼🆕 盲點 4明確 secret 來源)

# 需 kubectl 已有 awoooi cluster 權限(~/.kube/config 指向正確 context
kubectl config current-context    # 期待 awoooi-* 或 k3s-188
kubectl get secret awoooi-secrets -n awoooi \
  -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d > /tmp/pg_pw.txt
export AWOOOI_PG_PW="$(cat /tmp/pg_pw.txt)"
shred -u /tmp/pg_pw.txt           # 立即銷毀檔案
test -n "$AWOOOI_PG_PW" && echo "PG PW loaded (${#AWOOOI_PG_PW} chars)" || exit 1

kubectl 不可用或 context 不對,統帥需手動提供 AWOOOI_PG_PW — 不自動生成、不 fallback。

  • Step 2: 生成 HMAC secret32 bytes hex
openssl rand -hex 32 > /tmp/aider_webhook_secret.txt
chmod 600 /tmp/aider_webhook_secret.txt
cat /tmp/aider_webhook_secret.txt   # 記下!之後進 K8s secret + Mac ~/.aider-watch.env
  • Step 3: 設定本機 dev env
export AIDER_WEBHOOK_SECRET="$(cat /tmp/aider_webhook_secret.txt)"
export AIDER_EVENTS_STREAM_KEY="signals:aider:events"
export AIDER_PATTERN_EXTRACT_INTERVAL_HOURS="24"
export USE_AIDER_FEEDBACK="false"   # 🆕 盲點 3預設關閉驗證後再開
  • Step 4: 驗證 Redis + PG 可達
redis-cli -h 192.168.0.120 -p 6380 ping            # 期待 PONG
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi -c 'SELECT 1'

Expected: 兩個命令都成功。任一失敗 → STOP先解決再往下。


Task A1: DB Migration — aider_events 表

Files:

  • Create: apps/api/migrations/adr091_aider_events_schema.sql

  • Step 1: 寫 migration SQL

-- adr091: aider_events schema
-- 2026-04-20 @ Asia/Taipei
-- 紀錄統帥本機 aider CLI 活動,供 AI Router feedback + symptom_pattern 抽取

CREATE TABLE IF NOT EXISTS aider_events (
  id              BIGSERIAL PRIMARY KEY,
  session_id      TEXT NOT NULL,
  ts              TIMESTAMPTZ NOT NULL,
  type            TEXT NOT NULL,                  -- session_start|file_edit|error|commit|silent_timeout|session_end|raw
  host            TEXT DEFAULT 'ogt-mac',
  payload         JSONB NOT NULL,
  incident_id     TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS aider_events_session_idx ON aider_events(session_id);
CREATE INDEX IF NOT EXISTS aider_events_type_ts_idx ON aider_events(type, ts DESC);
CREATE INDEX IF NOT EXISTS aider_events_ts_idx ON aider_events(ts DESC);
CREATE INDEX IF NOT EXISTS aider_events_payload_gin ON aider_events USING GIN (payload);

COMMENT ON TABLE aider_events IS 'aider CLI 事件流Mac 端 aiderw wrapper 推入)';
COMMENT ON COLUMN aider_events.incident_id IS '若觸發建 incident記 FK 至 incidents.incident_id';
COMMENT ON COLUMN aider_events.payload IS 'Type-specific payload JSON見 src/models/aider.py schema';
  • Step 2: 套 migration 到 dev DB
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi \
  -f apps/api/migrations/adr091_aider_events_schema.sql
  • Step 3: 驗證表 + index 建立
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi \
  -c '\d+ aider_events' -c '\di aider_events*'

Expected: 表有 9 欄id/session_id/ts/type/host/payload/incident_id/created_at4 indexes。

  • Step 4: Commit
git add apps/api/migrations/adr091_aider_events_schema.sql
git commit -m "feat(migration): adr091 aider_events table"

Task A2: 泛用 Secret Redactor

Files:

  • Create: apps/api/src/utils/secret_redactor.py
  • Create: apps/api/tests/test_secret_redactor.py

(從 v1 ~/aider-watch/aider_watch/redactor.py 搬,改為 awoooi 一般用途)

  • Step 1: 寫測試 apps/api/tests/test_secret_redactor.py
# 2026-04-20 @ Asia/Taipei
from apps.api.src.utils.secret_redactor import redact


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


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


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


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


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


def test_aws_key_redacted():
    assert "<redacted:aws>" in redact("key=AKIAIOSFODNN7EXAMPLE")


def test_clean_passthrough():
    assert redact("normal text here") == "normal text here"


def test_nested_dict():
    d = {"a": "ghp_example_redaction_test_token", "b": {"c": "AIzaSy-example-redaction-test-key"}}
    out = redact(d)
    assert "ghp_example" not in str(out)
    assert "AIzaSy" not in str(out)
  • Step 2: 跑測試驗失敗
cd /Users/ogt/awoooi && pytest apps/api/tests/test_secret_redactor.py -v

Expected: ModuleNotFoundError

  • Step 3: 寫實作 apps/api/src/utils/secret_redactor.py
# awoooi utils: generic secret redactor | 2026-04-20 @ Asia/Taipei
"""把字串/dict/list 內 secret pattern 遮罩為 <redacted:kind>。
符合 feedback_secrets_leak_incidents_2026-04-18.md — 任何進 PG/TG/log 前都該過這層。"""
from __future__ import annotations
import re
from typing import Any

# 順序specific 在前generic 在後sk-or-v1 / sk-ant 排 sk- 之前)
_PATTERNS: list[tuple[re.Pattern[str], str]] = [
    (re.compile(r"sk-or-v1-[A-Za-z0-9]{36,}"),           "openrouter"),
    (re.compile(r"sk-ant-api\d{2}-[A-Za-z0-9_\-]{12,}"), "anthropic"),
    (re.compile(r"sk-[A-Za-z0-9]{40,}"),                 "openai"),
    (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: 跑測試驗通過
pytest apps/api/tests/test_secret_redactor.py -v

Expected: 8 passed

  • Step 5: Commit
git add apps/api/src/utils/secret_redactor.py apps/api/tests/test_secret_redactor.py
git commit -m "feat(utils): generic secret_redactor (7 patterns)"

Task A3: Pydantic Models — aider event schema

Files:

  • Create: apps/api/src/models/aider.py

  • Create: apps/api/tests/test_aider_event_models.py

  • Step 1: 寫測試

# apps/api/tests/test_aider_event_models.py | 2026-04-20 @ Asia/Taipei
from datetime import datetime, timezone, timedelta
import pytest
from pydantic import ValidationError
from apps.api.src.models.aider import AiderEventIn, AiderBatchIn

TAIPEI = timezone(timedelta(hours=8))


def _base(t="session_start", payload=None):
    return {
        "ts": datetime(2026, 4, 20, 10, 0, tzinfo=TAIPEI).isoformat(),
        "session_id": "01J7XYZABC",
        "host": "ogt-mac",
        "type": t,
        "payload": payload or {},
    }


def test_accepts_all_7_types():
    for t in ("session_start", "file_edit", "error", "commit",
              "silent_timeout", "session_end", "raw"):
        ev = AiderEventIn(**_base(t=t))
        assert ev.type == t


def test_rejects_unknown_type():
    with pytest.raises(ValidationError):
        AiderEventIn(**_base(t="bogus"))


def test_rejects_missing_session_id():
    d = _base(); del d["session_id"]
    with pytest.raises(ValidationError):
        AiderEventIn(**d)


def test_rejects_missing_ts():
    d = _base(); del d["ts"]
    with pytest.raises(ValidationError):
        AiderEventIn(**d)


def test_batch_max_50():
    with pytest.raises(ValidationError):
        AiderBatchIn(events=[_base() for _ in range(51)])


def test_batch_accepts_1():
    b = AiderBatchIn(events=[_base()])
    assert len(b.events) == 1


def test_host_default():
    d = _base(); del d["host"]
    ev = AiderEventIn(**d)
    assert ev.host == "ogt-mac"
  • Step 2: 跑測試驗失敗
pytest apps/api/tests/test_aider_event_models.py -v

Expected: ModuleNotFoundError

  • Step 3: 寫實作
# apps/api/src/models/aider.py | 2026-04-20 @ Asia/Taipei
"""aider-watch event pydantic schemas。由 Mac client POST 送來。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator

EventType = Literal[
    "session_start", "file_edit", "error", "commit",
    "silent_timeout", "session_end", "raw",
]


class AiderEventIn(BaseModel):
    """單筆 aider event 輸入 schema。"""
    model_config = ConfigDict(frozen=True, extra="forbid")

    ts: datetime
    session_id: str = Field(min_length=1, max_length=64)
    host: str = Field(default="ogt-mac", max_length=64)
    type: EventType
    payload: dict[str, Any] = Field(default_factory=dict)

    @field_validator("ts")
    @classmethod
    def _ensure_tz(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("ts must be timezone-aware")
        return v


class AiderBatchIn(BaseModel):
    """批次 event 輸入(最多 50 筆/次)。"""
    model_config = ConfigDict(frozen=True, extra="forbid")

    events: list[AiderEventIn] = Field(min_length=1, max_length=50)
  • Step 4: 跑測試驗通過
pytest apps/api/tests/test_aider_event_models.py -v

Expected: 7 passed

  • Step 5: Commit
git add apps/api/src/models/aider.py apps/api/tests/test_aider_event_models.py
git commit -m "feat(models): AiderEventIn + AiderBatchIn pydantic schemas"

Task A4: Repository

Files:

  • Create: apps/api/src/repositories/aider_event_repository.py

  • Step 1: 寫 repository 實作SQLAlchemy async 風格,沿用 awoooi 既有)

# apps/api/src/repositories/aider_event_repository.py | 2026-04-20 @ Asia/Taipei
"""aider_events CRUD + 聚合查詢。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession


class AiderEventRepository:
    def __init__(self, session: AsyncSession):
        self.session = session

    async def insert(self, *, session_id: str, ts: datetime, type_: str,
                     host: str, payload: dict[str, Any],
                     incident_id: str | None = None) -> int:
        r = await self.session.execute(text("""
            INSERT INTO aider_events (session_id, ts, type, host, payload, incident_id)
            VALUES (:sid, :ts, :t, :h, CAST(:p AS JSONB), :inc)
            RETURNING id
        """), {"sid": session_id, "ts": ts, "t": type_, "h": host,
               "p": _json(payload), "inc": incident_id})
        return r.scalar_one()

    async def link_incident(self, event_ids: list[int], incident_id: str) -> None:
        await self.session.execute(text("""
            UPDATE aider_events SET incident_id = :inc WHERE id = ANY(:ids)
        """), {"inc": incident_id, "ids": event_ids})

    async def count_by_session(self, session_id: str) -> int:
        r = await self.session.execute(text(
            "SELECT count(*) FROM aider_events WHERE session_id = :sid"
        ), {"sid": session_id})
        return r.scalar_one()

    async def model_stats_since(self, days: int = 7) -> list[dict]:
        """回傳 [{repo, model, total, errors, success_rate}, ...]"""
        cutoff = datetime.now().astimezone() - timedelta(days=days)
        r = await self.session.execute(text("""
            WITH session_models AS (
                SELECT session_id,
                       MAX(CASE WHEN type='session_start'
                                THEN payload->>'model' END) AS model,
                       MAX(CASE WHEN type='session_start'
                                THEN payload->>'cwd' END) AS repo,
                       COUNT(*) FILTER (WHERE type='error') AS err_count,
                       COUNT(*) FILTER (WHERE type='session_end'
                           AND (payload->>'exit_code')::int != 0) AS nonzero_exit
                FROM aider_events
                WHERE ts >= :cutoff
                GROUP BY session_id
            )
            SELECT repo, model,
                   COUNT(*) AS total,
                   SUM(CASE WHEN err_count>0 OR nonzero_exit>0 THEN 1 ELSE 0 END) AS errors,
                   1.0 - AVG(CASE WHEN err_count>0 OR nonzero_exit>0 THEN 1.0 ELSE 0.0 END) AS success_rate
            FROM session_models
            WHERE model IS NOT NULL AND repo IS NOT NULL
            GROUP BY repo, model
        """), {"cutoff": cutoff})
        return [dict(row._mapping) for row in r.fetchall()]

    async def daily_pattern_candidates(self, *, days: int = 7,
                                        threshold: float = 0.25,
                                        min_samples: int = 3) -> list[dict]:
        """抽 symptom_pattern 候選error_rate >= threshold, samples >= min_samples。"""
        rows = await self.model_stats_since(days=days)
        out = []
        for row in rows:
            if row["total"] < min_samples:
                continue
            err_rate = (row["errors"] or 0) / row["total"]
            if err_rate < threshold:
                continue
            out.append({
                "repo": row["repo"],
                "model": row["model"],
                "error_rate": round(err_rate, 3),
                "samples": row["total"],
            })
        return out


def _json(obj: Any) -> str:
    import json as _j
    return _j.dumps(obj, ensure_ascii=False, default=str)
  • Step 2: 驗 import 無誤
cd /Users/ogt/awoooi && /Users/ogt/.pyenv/versions/3.11.7/bin/python3 -c "from apps.api.src.repositories.aider_event_repository import AiderEventRepository; print('OK')"

Expected: OK

  • Step 3: Commit
git add apps/api/src/repositories/aider_event_repository.py
git commit -m "feat(repo): AiderEventRepository CRUD + model_stats + pattern candidates"

Task A5: Service — classify + incident coalesce + pattern

Files:

  • Create: apps/api/src/services/aider_event_service.py
  • Create: apps/api/tests/test_aider_event_service.py

🆕 盲點 2incident 洗版):同 session_id + aider_activity 在 60s 窗口內只建 1 個 incident第 2+ error 合進該 incident 的 signals 陣列。Redis key aider_incident_coalesce:{session_id} TTL=60s 存 incident_id

  • Step 1: 寫測試mock incident_service因這是 service 的單元邊界)
# apps/api/tests/test_aider_event_service.py | 2026-04-20 @ Asia/Taipei
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, MagicMock
from apps.api.src.services.aider_event_service import (
    classify_severity, should_create_incident,
)
from apps.api.src.models.aider import AiderEventIn

TAIPEI = timezone(timedelta(hours=8))


def _ev(t, p=None):
    return AiderEventIn(
        ts=datetime.now(TAIPEI), session_id="s1",
        host="ogt-mac", type=t, payload=p or {},
    )


def test_classify_error_is_p2():
    assert classify_severity(_ev("error")) == "P2"


def test_classify_silent_timeout_is_p3():
    assert classify_severity(_ev("silent_timeout")) == "P3"


def test_classify_session_end_nonzero_exit_is_p2():
    assert classify_severity(_ev("session_end", {"exit_code": 1})) == "P2"


def test_classify_session_end_multi_error_is_p1():
    assert classify_severity(_ev("session_end", {"exit_code": 1, "error_count": 5})) == "P1"


def test_classify_session_end_ok_no_incident():
    ev = _ev("session_end", {"exit_code": 0, "error_count": 0})
    assert classify_severity(ev) is None
    assert should_create_incident(ev) is False


def test_session_start_no_incident():
    assert should_create_incident(_ev("session_start")) is False


def test_error_creates_incident():
    assert should_create_incident(_ev("error")) is True


@pytest.mark.asyncio
async def test_coalesce_reuses_existing_incident():
    """盲點 2同 session 60s 內只建 1 個 incident。"""
    from apps.api.src.services.aider_event_service import create_incident_for_event

    # Fake incident_svc + append_signal
    append_calls = []
    class FakeIncSvc:
        async def create_incident(self, **kw): return "inc-first"
        async def append_signal(self, inc_id, sig): append_calls.append((inc_id, sig))

    # Fake redis: 第一次 get 回 Noneset 存;第二次 get 回 "inc-first"
    store = {}
    class FakeRedis:
        async def get(self, k): return store.get(k)
        async def set(self, k, v, ex=None): store[k] = v

    svc = FakeIncSvc(); redis = FakeRedis()
    e1 = _ev("error"); e2 = _ev("error")
    id1 = await create_incident_for_event(e1, svc, redis_client=redis)
    id2 = await create_incident_for_event(e2, svc, redis_client=redis)
    assert id1 == id2 == "inc-first"
    assert len(append_calls) == 1  # 第二個 event 只 append不建新
    assert append_calls[0][1]["event_type"] == "error"


@pytest.mark.asyncio
async def test_coalesce_different_session_id_builds_new():
    from apps.api.src.services.aider_event_service import create_incident_for_event
    seen = []
    class FakeIncSvc:
        async def create_incident(self, **kw):
            seen.append(kw); return f"inc-{len(seen)}"
        async def append_signal(self, *_): pass
    store = {}
    class FakeRedis:
        async def get(self, k): return store.get(k)
        async def set(self, k, v, ex=None): store[k] = v

    ea = AiderEventIn(ts=datetime.now(TAIPEI), session_id="sA",
                      host="m", type="error", payload={"cwd":"/x","model":"m1"})
    eb = AiderEventIn(ts=datetime.now(TAIPEI), session_id="sB",
                      host="m", type="error", payload={"cwd":"/x","model":"m1"})
    id_a = await create_incident_for_event(ea, FakeIncSvc(), redis_client=FakeRedis())
    id_b = await create_incident_for_event(eb, FakeIncSvc(), redis_client=FakeRedis())
    # 不同 session 各自建
    assert id_a == "inc-1"
    assert id_b == "inc-1"  # 注意:每個 FakeIncSvc 是獨立 instance所以都是 inc-1
  • Step 2: 跑測試驗失敗
pytest apps/api/tests/test_aider_event_service.py -v

Expected: ModuleNotFoundError

  • Step 3: 寫實作
# apps/api/src/services/aider_event_service.py | 2026-04-20 @ Asia/Taipei
"""分類 aider events、觸發 incident、抽 symptom_pattern → playbooks。"""
from __future__ import annotations
from typing import Any, Protocol
from apps.api.src.models.aider import AiderEventIn
from apps.api.src.utils.secret_redactor import redact


# ---- 分類邏輯(純函式,好測)----

def classify_severity(ev: AiderEventIn) -> str | None:
    """回 P0-P3 severity或 None 表不該建 incident。"""
    t = ev.type
    if t == "error":
        return "P2"
    if t == "silent_timeout":
        return "P3"
    if t == "session_end":
        exit_code = ev.payload.get("exit_code", 0)
        err_count = ev.payload.get("error_count", 0)
        if exit_code != 0 and err_count >= 3:
            return "P1"
        if exit_code != 0 or err_count >= 1:
            return "P2"
    return None


def should_create_incident(ev: AiderEventIn) -> bool:
    return classify_severity(ev) is not None


# ---- incident 建立接口(需要 incident_service 協作)----

class IncidentServiceLike(Protocol):
    async def create_incident(self, *, title: str, severity: str,
                              alert_category: str, affected_services: list[str],
                              signals: dict[str, Any]) -> str: ...


COALESCE_TTL_SEC = 60
COALESCE_KEY_PREFIX = "aider_incident_coalesce:"


async def create_incident_for_event(ev: AiderEventIn,
                                     incident_svc: IncidentServiceLike,
                                     redis_client=None) -> str | None:
    """若 event 應觸發 incident建立並回傳 incident_id否則 None。
    🆕 盲點 2同 session_id 60s 內只建 1 個 incident後續合進 signals。"""
    sev = classify_severity(ev)
    if not sev:
        return None
    p = redact(ev.payload)
    repo = p.get("cwd") or "<unknown>"
    model = p.get("model") or "<unknown>"

    # Coalesce 檢查
    existing_id: str | None = None
    if redis_client is not None:
        key = f"{COALESCE_KEY_PREFIX}{ev.session_id}"
        try:
            raw = await redis_client.get(key)
            if raw:
                existing_id = raw.decode() if isinstance(raw, bytes) else raw
        except Exception:
            pass  # Redis 失敗不阻塞 fallback 建新 incident

    if existing_id:
        # 同 session 已有 incident → 合進 signals不建新的
        if hasattr(incident_svc, "append_signal"):
            try:
                await incident_svc.append_signal(existing_id, {
                    "ts": ev.ts.isoformat(),
                    "event_type": ev.type,
                    "payload_excerpt": p,
                })
            except Exception:
                pass
        return existing_id

    title_map = {
        "error":           f"[aider] error in {repo}",
        "silent_timeout":  f"[aider] silent timeout in {repo}",
        "session_end":     f"[aider] session exit={p.get('exit_code')} in {repo}",
    }
    title = title_map.get(ev.type, f"[aider] {ev.type} in {repo}")
    inc_id = await incident_svc.create_incident(
        title=title, severity=sev,
        alert_category="aider_activity",
        affected_services=[_repo_name(repo)],
        signals={
            "source": "aider_watch",
            "session_id": ev.session_id,
            "model": model,
            "event_type": ev.type,
            "payload_excerpt": p,
        },
    )
    # 寫 Redis coalesce key
    if redis_client is not None and inc_id:
        try:
            await redis_client.set(f"{COALESCE_KEY_PREFIX}{ev.session_id}",
                                    inc_id, ex=COALESCE_TTL_SEC)
        except Exception:
            pass
    return inc_id


def _repo_name(cwd: str) -> str:
    """/Users/ogt/awoooi → awoooi"""
    return cwd.rstrip("/").rsplit("/", 1)[-1] if cwd else "unknown"


# ---- symptom_pattern 抽取 → playbooks由 job 日 batch 呼叫)----

async def extract_and_persist_patterns(
    repo_candidates: list[dict],
    playbook_repo,  # PlaybookRepository (awoooi 既有)
) -> int:
    """收 repository.daily_pattern_candidates() 的結果,寫 playbooks 候選。回寫入數。"""
    written = 0
    for c in repo_candidates:
        symptom_pattern = {
            "source": "aider_watch",
            "repo": c["repo"],
            "model": c["model"],
            "task_kind": "aider_usage",
            "error_rate": c["error_rate"],
            "samples": c["samples"],
        }
        await playbook_repo.upsert_candidate(
            symptom_pattern=symptom_pattern,
            source_label="aider_watch_daily_extract",
        )
        written += 1
    return written
  • Step 4: 跑測試驗通過
pytest apps/api/tests/test_aider_event_service.py -v

Expected: 7 passed

  • Step 5: Commit
git add apps/api/src/services/aider_event_service.py apps/api/tests/test_aider_event_service.py
git commit -m "feat(service): aider_event_service — classify + incident + pattern extract"

Task A6: API Webhook + HMAC

Files:

  • Create: apps/api/src/api/v1/aider_events.py

  • Create: apps/api/tests/test_aider_events_api.py

  • Step 1: 寫測試

# apps/api/tests/test_aider_events_api.py | 2026-04-20 @ Asia/Taipei
import hmac, hashlib, json
import pytest
from fastapi.testclient import TestClient
from apps.api.src.main import app


SECRET = "testsecret_for_unittest_do_not_use_in_prod_" + "x" * 20


def _sign(body: bytes, secret: str = SECRET) -> str:
    return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()


@pytest.fixture
def client(monkeypatch):
    monkeypatch.setenv("AIDER_WEBHOOK_SECRET", SECRET)
    return TestClient(app)


def _ev():
    return {
        "ts": "2026-04-20T10:00:00+08:00",
        "session_id": "s1", "host": "ogt-mac",
        "type": "session_start",
        "payload": {"cwd": "/t/x", "model": "elephant", "aider_args": [],
                    "aider_pid": 1, "cli_version": "0.86"},
    }


def test_accepts_signed_batch(client):
    body = json.dumps({"events": [_ev()]}).encode()
    r = client.post("/api/v1/aider/events", data=body,
                    headers={"X-Aider-Signature": _sign(body),
                             "Content-Type": "application/json"})
    assert r.status_code == 202
    assert r.json()["accepted"] == 1


def test_rejects_invalid_signature(client):
    body = json.dumps({"events": [_ev()]}).encode()
    r = client.post("/api/v1/aider/events", data=body,
                    headers={"X-Aider-Signature": "sha256=0" * 65,
                             "Content-Type": "application/json"})
    assert r.status_code == 401


def test_rejects_missing_signature(client):
    body = json.dumps({"events": [_ev()]}).encode()
    r = client.post("/api/v1/aider/events", data=body,
                    headers={"Content-Type": "application/json"})
    assert r.status_code == 401


def test_rejects_malformed_event(client):
    body = json.dumps({"events": [{"bad": "payload"}]}).encode()
    r = client.post("/api/v1/aider/events", data=body,
                    headers={"X-Aider-Signature": _sign(body),
                             "Content-Type": "application/json"})
    assert r.status_code == 400


def test_rejects_oversize_batch(client):
    body = json.dumps({"events": [_ev()] * 51}).encode()
    r = client.post("/api/v1/aider/events", data=body,
                    headers={"X-Aider-Signature": _sign(body),
                             "Content-Type": "application/json"})
    assert r.status_code == 400
  • Step 2: 跑測試驗失敗
pytest apps/api/tests/test_aider_events_api.py -v

Expected: 404 / ModuleNotFoundError

  • Step 3: 寫實作
# apps/api/src/api/v1/aider_events.py | 2026-04-20 @ Asia/Taipei
"""POST /api/v1/aider/events — Mac aiderw client 推事件入口。HMAC-verified。"""
from __future__ import annotations
import hmac, hashlib, os, json, logging
from fastapi import APIRouter, Header, HTTPException, Request, status
from pydantic import ValidationError
from apps.api.src.models.aider import AiderBatchIn

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/aider", tags=["aider"])


def _verify_signature(body: bytes, signature: str | None) -> bool:
    if not signature or not signature.startswith("sha256="):
        return False
    secret = os.environ.get("AIDER_WEBHOOK_SECRET", "")
    if not secret:
        return False
    expected = "sha256=" + hmac.new(secret.encode(), body,
                                     hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


@router.post("/events", status_code=status.HTTP_202_ACCEPTED)
async def receive_aider_events(
    request: Request,
    x_aider_signature: str | None = Header(default=None, alias="X-Aider-Signature"),
):
    body = await request.body()
    if not _verify_signature(body, x_aider_signature):
        raise HTTPException(status_code=401, detail="invalid signature")
    try:
        batch = AiderBatchIn.model_validate_json(body)
    except ValidationError as e:
        raise HTTPException(status_code=400, detail=e.errors()[:5])

    # push 到 Redis stream讓 job 非同步處理;失敗時回 503 讓 client 走 buffer
    from apps.api.src.core.redis_client import get_redis  # 既有 awoooi util
    stream_key = os.environ.get("AIDER_EVENTS_STREAM_KEY", "signals:aider:events")
    try:
        r = await get_redis()
        pipe = r.pipeline()
        for ev in batch.events:
            pipe.xadd(stream_key, {"payload": ev.model_dump_json()})
        ids = await pipe.execute()
    except Exception as exc:
        logger.exception("aider_events: redis push failed")
        raise HTTPException(status_code=503, detail="queue unavailable") from exc
    return {"accepted": len(batch.events), "stream_ids": [str(i) for i in ids]}
  • Step 4: 註冊 router 到 awoooi FastAPI app

apps/api/src/main.py 看既有 router 註冊 patternapp.include_router(...)),在相同地方加:

from apps.api.src.api.v1.aider_events import router as aider_router
app.include_router(aider_router)
  • Step 5: 跑測試驗通過
AIDER_WEBHOOK_SECRET="testsecret_for_unittest_do_not_use_in_prod_xxxxxxxxxxxxxxxxxxxx" \
  pytest apps/api/tests/test_aider_events_api.py -v

Expected: 5 passed

  • Step 6: Commit
git add apps/api/src/api/v1/aider_events.py apps/api/tests/test_aider_events_api.py apps/api/src/main.py
git commit -m "feat(api): POST /api/v1/aider/events HMAC-verified webhook → Redis stream"

Task A7: Background Job — Redis stream consumer + 日 batch

Files:

  • Create: apps/api/src/jobs/aider_event_processor_job.py

  • Step 1: 寫 job

# apps/api/src/jobs/aider_event_processor_job.py | 2026-04-20 @ Asia/Taipei
"""背景 job消費 signals:aider:events stream寫 aider_events 表、觸發 incident
日 batch 抽 symptom_pattern。沿用 Phase 7 scanner loop pattern。"""
from __future__ import annotations
import asyncio, logging, os, json
from datetime import datetime, timezone, timedelta
from apps.api.src.models.aider import AiderEventIn
from apps.api.src.services.aider_event_service import (
    create_incident_for_event, extract_and_persist_patterns,
)

logger = logging.getLogger(__name__)
TAIPEI = timezone(timedelta(hours=8))
CONSUMER_GROUP = "aider_processor"
CONSUMER_NAME = "aider_processor_1"
POLL_BLOCK_MS = 5000


async def _process_one(ev: AiderEventIn, session_factory, incident_svc) -> None:
    """處理一筆 event寫 DB + (若需要)建 incident。"""
    from apps.api.src.repositories.aider_event_repository import AiderEventRepository
    incident_id: str | None = None
    try:
        incident_id = await create_incident_for_event(ev, incident_svc)
    except Exception:
        logger.exception("aider_job: create_incident failed; continue to DB write")
    async with session_factory() as sess:
        repo = AiderEventRepository(sess)
        await repo.insert(
            session_id=ev.session_id, ts=ev.ts, type_=ev.type,
            host=ev.host, payload=ev.payload, incident_id=incident_id,
        )
        await sess.commit()


async def _ensure_group(r, stream_key: str) -> None:
    try:
        await r.xgroup_create(stream_key, CONSUMER_GROUP, id="0", mkstream=True)
    except Exception as e:
        if "BUSYGROUP" not in str(e):
            raise


async def _consume_loop():
    from apps.api.src.core.redis_client import get_redis
    from apps.api.src.db.session import get_session_factory
    from apps.api.src.services.incident_service import get_incident_service
    r = await get_redis()
    session_factory = get_session_factory()
    incident_svc = get_incident_service()
    stream_key = os.environ.get("AIDER_EVENTS_STREAM_KEY", "signals:aider:events")
    await _ensure_group(r, stream_key)
    while True:
        try:
            res = await r.xreadgroup(CONSUMER_GROUP, CONSUMER_NAME,
                                     {stream_key: ">"}, count=10, block=POLL_BLOCK_MS)
            if not res:
                continue
            for _sk, msgs in res:
                for msg_id, data in msgs:
                    try:
                        raw = data.get(b"payload") or data.get("payload")
                        if isinstance(raw, bytes):
                            raw = raw.decode()
                        ev = AiderEventIn.model_validate_json(raw)
                        await _process_one(ev, session_factory, incident_svc)
                        await r.xack(stream_key, CONSUMER_GROUP, msg_id)
                    except Exception:
                        logger.exception("aider_job: process msg %s failed", msg_id)
        except asyncio.CancelledError:
            raise
        except Exception:
            logger.exception("aider_job: consume loop error; retry 3s")
            await asyncio.sleep(3)


async def _daily_pattern_loop():
    from apps.api.src.db.session import get_session_factory
    from apps.api.src.repositories.aider_event_repository import AiderEventRepository
    from apps.api.src.repositories.playbook_repository import get_playbook_repository
    interval_h = float(os.environ.get("AIDER_PATTERN_EXTRACT_INTERVAL_HOURS", "24"))
    session_factory = get_session_factory()
    while True:
        try:
            async with session_factory() as sess:
                repo = AiderEventRepository(sess)
                candidates = await repo.daily_pattern_candidates()
            if candidates:
                pb_repo = await get_playbook_repository()
                n = await extract_and_persist_patterns(candidates, pb_repo)
                logger.info("aider_job: persisted %d pattern candidates", n)
        except asyncio.CancelledError:
            raise
        except Exception:
            logger.exception("aider_job: daily pattern extract failed")
        await asyncio.sleep(interval_h * 3600)


async def run_aider_event_processor_loop():
    """對外入口:同時跑 consume loop + daily pattern loop。"""
    await asyncio.gather(_consume_loop(), _daily_pattern_loop())
  • Step 2: 驗 import 無誤
/Users/ogt/.pyenv/versions/3.11.7/bin/python3 -c "from apps.api.src.jobs.aider_event_processor_job import run_aider_event_processor_loop; print('OK')"

Expected: OK

  • Step 3: Commit
git add apps/api/src/jobs/aider_event_processor_job.py
git commit -m "feat(job): aider_event_processor_job — consume Redis + daily pattern"

Task A8: AI Router feedback hook

Files:

  • Modify: apps/api/src/services/ai_router.py(既有檔;加 1 method + 改 route()

  • Step 1: 讀既有 ai_router.py 了解 route() 簽章

grep -n "def route\|class AIRouter\|AIProvider" apps/api/src/services/ai_router.py | head -20
  • Step 2: 在 AIRouter class 新增 method

class AIRouter 定義底部(或相近處)加:

    async def feedback_from_aider_events(self, repo: str | None = None,
                                          days: int = 7) -> dict[str, float]:
        """從 aider_events 聚合近 N 日 success ratekey 為 model name。
        若 repo 給定只算該 repo否則全站。"""
        from apps.api.src.db.session import get_session_factory
        from apps.api.src.repositories.aider_event_repository import AiderEventRepository
        sf = get_session_factory()
        async with sf() as sess:
            repo_obj = AiderEventRepository(sess)
            stats = await repo_obj.model_stats_since(days=days)
        out: dict[str, float] = {}
        for row in stats:
            if repo and row["repo"] != repo:
                continue
            out[row["model"]] = float(row["success_rate"] or 0)
        return out
  • Step 3: 在 AIRouter.route() 內加入 feedback 影響(🆕 盲點 3Flag 保護)

找到 def route(...)async def route(...) 決策主體,在 provider 優先序計算後、return 前加:

        # Phase 24 feedback hook: aider 實戰成功率 × 原權重
        # 🆕 盲點 3USE_AIDER_FEEDBACK flag 保護首次 rollout
        from apps.api.src.core.config import settings
        if getattr(settings, "USE_AIDER_FEEDBACK", False):
            try:
                feedback = await self.feedback_from_aider_events(days=7)
                # feedback = {"elephant-alpha": 0.85, "gemini-pro": 0.92}
                if feedback:
                    for p in providers:  # 假設 providers 是 list[ProviderCandidate]
                        key = p.name.lower()
                        rate = next((v for k, v in feedback.items()
                                     if key in k.lower()), None)
                        if rate is not None:
                            p.weight *= max(rate, 0.1)  # 最低 10% 保底
                    logger.info("ai_router: aider feedback applied (%d models)", len(feedback))
            except Exception:
                logger.debug("ai_router: feedback hook skipped (non-critical)")
        else:
            logger.debug("ai_router: feedback disabled via USE_AIDER_FEEDBACK=false")

注意

  1. 實際 providersp.weight 命名需依 route() 當下代碼調整
  2. 預設 Flag 關:上線後跑 7 天真實 aider 資料,比對 ai_router 決策是否合理SignOz trace 差異 < 5%)再打開 Flag
  3. 若 Flag 關 → 該 hook 完全不影響既有 route 決策 — zero-risk rollout
  • Step 4: 寫最小測試(若 route 難測,測 feedback method 即可)
# apps/api/tests/test_ai_router_feedback.py | 2026-04-20 @ Asia/Taipei
import pytest
from unittest.mock import AsyncMock, patch
from apps.api.src.services.ai_router import AIRouter


@pytest.mark.asyncio
async def test_feedback_reads_model_stats(monkeypatch):
    mock_stats = [
        {"repo": "awoooi", "model": "elephant-alpha", "total": 10,
         "errors": 2, "success_rate": 0.8},
        {"repo": "awoooi", "model": "gemini-pro", "total": 5,
         "errors": 0, "success_rate": 1.0},
    ]
    class FakeRepo:
        async def model_stats_since(self, days): return mock_stats
    class FakeSess:
        async def __aenter__(self): return self
        async def __aexit__(self, *a): return False
    def fake_sf(): return FakeSess()
    # patch
    monkeypatch.setattr("apps.api.src.jobs.aider_event_processor_job.get_session_factory",
                        lambda: (lambda: FakeSess()), raising=False)
    # 若你的架構不同,這個測試可暫時 skip
    # ai_router = AIRouter()
    # feedback = await ai_router.feedback_from_aider_events()
    # assert feedback["elephant-alpha"] == 0.8
    pytest.skip("depends on repo factory mock — enable when di pattern clarified")
  • Step 5: Commit
git add apps/api/src/services/ai_router.py apps/api/tests/test_ai_router_feedback.py
git commit -m "feat(ai_router): feedback_from_aider_events hook (Phase 24 ADR-052)"

Task A9: Config 3 settings + main.py lifespan register

Files:

  • Modify: apps/api/src/core/config.py

  • Modify: apps/api/src/main.py

  • Step 1: config.py 加 4 個 settings🆕 盲點 3 新增 USE_AIDER_FEEDBACK

找到 class Settings(BaseSettings) 或類似,加:

    # aider-watch integration
    AIDER_WEBHOOK_SECRET: str = ""
    AIDER_EVENTS_STREAM_KEY: str = "signals:aider:events"
    AIDER_PATTERN_EXTRACT_INTERVAL_HOURS: float = 24.0
    USE_AIDER_FEEDBACK: bool = False      # 🆕 預設關;灰度 7 天後手動開
  • Step 2: main.py lifespan 註冊 aider job

找 asyncio.create_task 區段line 322+),加:

    from apps.api.src.jobs.aider_event_processor_job import run_aider_event_processor_loop
    logger.info("aider-watch: starting event processor + daily pattern extract")
    asyncio.create_task(run_aider_event_processor_loop())
  • Step 3: 本機冒煙
cd /Users/ogt/awoooi/apps/api
AIDER_WEBHOOK_SECRET="smoke-test-$(openssl rand -hex 16)" \
  /Users/ogt/.pyenv/versions/3.11.7/bin/python3 -c "from src.main import app; print('app OK:', app.title)"

Expected: app OK: AWOOOI API or similar

  • Step 4: Commit
git add apps/api/src/core/config.py apps/api/src/main.py
git commit -m "feat(config): aider-watch 3 settings + lifespan register"

Phase B: Mac Client5 tasks

Task B1: Client scaffolding + parsers + config

Files:

  • Create: scripts/aider_watch_client/pyproject.toml

  • Create: scripts/aider_watch_client/aider_watch_client/__init__.py

  • Create: scripts/aider_watch_client/aider_watch_client/config.py

  • Create: scripts/aider_watch_client/aider_watch_client/parsers.py

  • Create: scripts/aider_watch_client/aider_watch_client/redactor.py(與 server 同內容)

  • Step 1: 寫 pyproject.toml

[project]
name = "aider-watch-client"
version = "0.2.0"
description = "Mac client for aider-watch (posts events to awoooi API)"
requires-python = ">=3.11"
dependencies = ["requests>=2.31", "python-ulid>=3.0"]

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

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

[tool.setuptools.packages.find]
include = ["aider_watch_client*"]
  • Step 2: 寫 init.py
# aider-watch-client | 2026-04-20 @ Asia/Taipei
__version__ = "0.2.0"
  • Step 3: 寫 config.pyclient 端精簡版)
# aider-watch-client config | 2026-04-20 @ Asia/Taipei
from __future__ import annotations
import os
from pathlib import Path
from datetime import timezone, timedelta

HOME = Path.home()
WATCH_ROOT = HOME / "aider-watch"
BUFFER_DIR = WATCH_ROOT / "buffer"
LIVE_LOG = WATCH_ROOT / "live.log"
LOGS_DIR = WATCH_ROOT / "logs"
ENV_FILE = HOME / ".aider-watch.env"
TAIPEI = timezone(timedelta(hours=8))
_loaded = False


def load_env() -> None:
    global _loaded
    if _loaded or not ENV_FILE.exists():
        _loaded = True
        return
    for line in ENV_FILE.read_text().splitlines():
        s = line.strip()
        if not s or s.startswith("#") or "=" not in s:
            continue
        k, _, v = s.partition("=")
        os.environ.setdefault(k.strip(), v.strip())
    _loaded = True


def get(name: str, default: str = "", required: bool = False) -> str:
    load_env()
    v = os.environ.get(name, default)
    if required and not v:
        raise RuntimeError(f"{name} not set in {ENV_FILE}")
    return v


def ensure_dirs() -> None:
    for d in (WATCH_ROOT, BUFFER_DIR, LOGS_DIR):
        d.mkdir(parents=True, exist_ok=True)
  • Step 4: 寫 parsers.py從 v1 搬)

與 v1 ~/aider-watch/aider_watch/parsers.py 相同 — 若 v1 未完成,照 v1 plan Task 8 Step 4 的 parsers.py 內容寫banner / chat_history / git numstat / git diff head

  • Step 5: 寫 redactor.py與 server 一模一樣)

複製 apps/api/src/utils/secret_redactor.py_PATTERNSredact() 函式改檔頭註解為「aider-watch-client secret redactor」。

  • Step 6: Commit
git add scripts/aider_watch_client/
git commit -m "feat(client): scaffolding + config + parsers + redactor"

Task B2: API Client (HTTP POST + HMAC + retry + buffer fallback)

Files:

  • Create: scripts/aider_watch_client/aider_watch_client/api_client.py

  • Create: scripts/aider_watch_client/tests/test_api_client.py

  • Step 1: 寫測試

# scripts/aider_watch_client/tests/test_api_client.py | 2026-04-20 @ Asia/Taipei
import hmac, hashlib
from aider_watch_client.api_client import sign_body

def test_sign_body_format():
    body = b'{"events":[]}'
    sig = sign_body(body, "secret")
    assert sig.startswith("sha256=")
    expected = "sha256=" + hmac.new(b"secret", body, hashlib.sha256).hexdigest()
    assert sig == expected


def test_sign_body_deterministic():
    assert sign_body(b"same", "secret") == sign_body(b"same", "secret")
  • Step 2: 寫實作
# aider-watch-client api_client | 2026-04-20 @ Asia/Taipei
from __future__ import annotations
import hmac, hashlib, json, time
from typing import Any
import requests
from aider_watch_client.config import get


def sign_body(body: bytes, secret: str) -> str:
    return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()


def post_events(events: list[dict[str, Any]]) -> bool:
    """POST batch to awoooi. True=2xx; False=client should buffer。絕不 raise。"""
    url = get("AIDER_API_URL", required=True)   # e.g. http://192.168.0.120:32334/api/v1/aider/events
    secret = get("AIDER_WEBHOOK_SECRET", required=True)
    body = json.dumps({"events": events}, ensure_ascii=False).encode()
    sig = sign_body(body, secret)
    backoff = 1
    for _ in range(3):
        try:
            r = requests.post(url, data=body,
                              headers={"Content-Type": "application/json",
                                       "X-Aider-Signature": sig,
                                       "X-Aider-Client": "aiderw/0.2.0"},
                              timeout=5)
            if 200 <= r.status_code < 300:
                return True
            if r.status_code == 429:
                time.sleep(backoff); backoff *= 4; continue
            if r.status_code >= 500:
                time.sleep(backoff); backoff *= 4; continue
            # 400/401/403 — 不重試client 端問題
            return False
        except Exception:
            time.sleep(backoff); backoff *= 4
    return False
  • Step 3: 跑測試
cd scripts/aider_watch_client && pip install -e . --quiet && pytest tests/test_api_client.py -v

Expected: 2 passed

  • Step 4: Commit
git add scripts/aider_watch_client/aider_watch_client/api_client.py scripts/aider_watch_client/tests/test_api_client.py
git commit -m "feat(client): api_client with HMAC sign + retry backoff"

Task B3: JSONL Buffer + flush

Files:

  • Create: scripts/aider_watch_client/aider_watch_client/buffer.py

  • Create: scripts/aider_watch_client/tests/test_buffer.py

  • Step 1: 寫測試

# scripts/aider_watch_client/tests/test_buffer.py | 2026-04-20 @ Asia/Taipei
import json
from pathlib import Path
from aider_watch_client import buffer


def test_write_reads_back(tmp_path, monkeypatch):
    monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path)
    ev = {"type":"session_start","session_id":"s1","ts":"2026-04-20T10:00:00+08:00",
          "host":"ogt-mac","payload":{}}
    buffer.write([ev])
    files = list(tmp_path.glob("pending_*.jsonl"))
    assert len(files) == 1
    lines = [json.loads(l) for l in files[0].read_text().splitlines() if l.strip()]
    assert lines[0]["session_id"] == "s1"


def test_flush_all_drained(tmp_path, monkeypatch):
    monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path)
    ev = {"type":"session_start","session_id":"s1","ts":"2026-04-20T10:00:00+08:00",
          "host":"ogt-mac","payload":{}}
    buffer.write([ev])
    sent = []
    def fake_post(evs): sent.extend(evs); return True
    n = buffer.flush(post_fn=fake_post)
    assert n == 1
    assert len(sent) == 1
    assert list(tmp_path.glob("pending_*.jsonl")) == []


def test_flush_keeps_on_fail(tmp_path, monkeypatch):
    monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path)
    ev = {"type":"session_start","session_id":"s1","ts":"2026-04-20T10:00:00+08:00",
          "host":"ogt-mac","payload":{}}
    buffer.write([ev])
    n = buffer.flush(post_fn=lambda evs: False)
    assert n == 0
    assert len(list(tmp_path.glob("pending_*.jsonl"))) == 1
  • Step 2: 寫實作
# aider-watch-client buffer | 2026-04-20 @ Asia/Taipei
from __future__ import annotations
import json
from pathlib import Path
from typing import Callable
from aider_watch_client.config import BUFFER_DIR, ensure_dirs
from ulid import ULID


def write(events: list[dict]) -> Path:
    """批次寫一個 pending file。"""
    ensure_dirs()
    fp = BUFFER_DIR / f"pending_{ULID()}.jsonl"
    with fp.open("w") as f:
        for ev in events:
            f.write(json.dumps(ev, ensure_ascii=False, default=str) + "\n")
    return fp


def flush(post_fn: Callable[[list[dict]], bool]) -> int:
    """把所有 pending_*.jsonl 批次送出。成功一批刪一個檔;失敗保留。回傳總成功筆數。"""
    ensure_dirs()
    total = 0
    for fp in list(BUFFER_DIR.glob("pending_*.jsonl")):
        lines = [l for l in fp.read_text().splitlines() if l.strip()]
        if not lines:
            fp.unlink(); continue
        batch = [json.loads(l) for l in lines]
        # 一次最多 50 筆(配合 API limit
        ok_all = True
        for i in range(0, len(batch), 50):
            chunk = batch[i:i+50]
            if not post_fn(chunk):
                ok_all = False
                break
            total += len(chunk)
        if ok_all:
            fp.unlink()
    return total
  • Step 3: 跑測試
cd scripts/aider_watch_client && pytest tests/test_buffer.py -v

Expected: 3 passed

  • Step 4: Commit
git add scripts/aider_watch_client/aider_watch_client/buffer.py scripts/aider_watch_client/tests/test_buffer.py
git commit -m "feat(client): JSONL buffer write + flush"

Task B4: aiderw.py 主 wrapper + CLI

Files:

  • Create: scripts/aider_watch_client/aider_watch_client/aiderw.py

  • Create: scripts/aider_watch_client/aider_watch_client/cli.py

  • Create: scripts/aider_watch_client/bin/aiderw

  • Step 1: 寫 aiderw.py

# aider-watch-client aiderw | 2026-04-20 @ Asia/Taipei
"""aider subprocess wrapper — capture events, POST to awoooi (fallback buffer)."""
from __future__ import annotations
import atexit, json, os, subprocess, sys, threading, time
from datetime import datetime
from pathlib import Path
from typing import Any
from ulid import ULID
from aider_watch_client import config, buffer
from aider_watch_client.api_client import post_events
from aider_watch_client.parsers import (
    parse_stdout_banner, parse_chat_history, git_numstat, git_diff_head,
)
from aider_watch_client.redactor import redact

IDLE_THRESHOLD_SEC = 120


def _now_iso() -> str:
    return datetime.now(config.TAIPEI).isoformat()


def _emit_live(ev: dict) -> None:
    try:
        with config.LIVE_LOG.open("a") as f:
            f.write(f"[{ev['ts']}] {ev['type']} "
                    f"{json.dumps(redact(ev['payload']), ensure_ascii=False, default=str)[:200]}\n")
    except Exception:
        pass


def _dispatch(events: list[dict]) -> None:
    """把一批 event(1) 寫 live.log  (2) POST失敗進 buffer。"""
    redacted = [{**e, "payload": redact(e.get("payload", {}))} for e in events]
    for e in redacted:
        _emit_live(e)
    if not post_events(redacted):
        buffer.write(redacted)


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())
    start_ts = _now_iso()
    stdout_buf: list[str] = []
    last_output = [time.time()]
    error_count = [0]
    pre_sha = _pre_git_sha(cwd)
    host = os.environ.get("AIDER_WATCH_HOSTNAME") or os.uname().nodename
    aider_bin = os.environ.get("AIDER_BIN") or str(Path.home() / ".local/bin/aider")

    # dispatch session_start ASAP
    start_ev = {
        "ts": start_ts, "session_id": sid, "host": host,
        "type": "session_start",
        "payload": {"cwd": str(cwd), "model": "unknown",
                    "aider_args": argv, "aider_pid": 0, "cli_version": "unknown"},
    }
    # PID fill 在 subprocess 啟動後
    proc = subprocess.Popen([aider_bin] + argv, cwd=str(cwd),
                            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                            text=True, bufsize=1)
    start_ev["payload"]["aider_pid"] = proc.pid
    _dispatch([start_ev])

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

    # silent watcher
    silent_fired = [False]
    def _silent_watch():
        while proc.poll() is None:
            time.sleep(10)
            idle = time.time() - last_output[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([{"ts": _now_iso(), "session_id": sid, "host": host,
                            "type": "silent_timeout",
                            "payload": {"idle_sec": int(idle), "last_output_tail": tail}}])
    w = threading.Thread(target=_silent_watch, daemon=True); w.start()

    # atexit: 即使 wrapper 崩潰也補一筆 session_end
    emergency_fired = [False]
    def _emergency():
        if emergency_fired[0]:
            return
        emergency_fired[0] = True
        _dispatch([{"ts": _now_iso(), "session_id": sid, "host": host,
                    "type": "session_end",
                    "payload": {"duration_sec": 0, "tokens_sent": 0, "tokens_received": 0,
                                "cost_usd": 0, "files_changed": 0,
                                "error_count": error_count[0], "exit_code": -999,
                                "wrapper_crash": True}}])
    atexit.register(_emergency)

    exit_code = proc.wait()
    emergency_fired[0] = True  # 關掉 atexit 保險

    duration = int(time.time() - time.mktime(datetime.fromisoformat(start_ts).timetuple()))
    stdout_text = "".join(stdout_buf)

    # canonical file edits from git diff
    events_tail: list[dict] = []
    if pre_sha:
        for e in git_numstat(cwd, pre_sha, "HEAD"):
            head = git_diff_head(cwd, e["path"], pre_sha, 3)
            events_tail.append({
                "ts": _now_iso(), "session_id": sid, "host": host,
                "type": "file_edit",
                "payload": {"path": e["path"], "lines_added": e["lines_added"],
                            "lines_deleted": e["lines_deleted"],
                            "diff_head": head, "is_new_file": e.get("is_new_file", False)},
            })

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

    if exit_code != 0:
        events_tail.append({
            "ts": _now_iso(), "session_id": sid, "host": host,
            "type": "error",
            "payload": {"kind": "non_zero_exit",
                        "message": f"aider exited with {exit_code}",
                        "context_50chars": stdout_text[-50:]},
        })

    events_tail.append({
        "ts": _now_iso(), "session_id": sid, "host": host,
        "type": "session_end",
        "payload": {"duration_sec": duration, "tokens_sent": tokens_sent,
                    "tokens_received": tokens_recv, "cost_usd": 0,
                    "files_changed": len([e for e in events_tail if e["type"] == "file_edit"]),
                    "error_count": error_count[0], "exit_code": exit_code},
    })

    if events_tail:
        _dispatch(events_tail)

    return exit_code


if __name__ == "__main__":
    sys.exit(main())
  • Step 2: 寫 cli.py
# aider-watch-client cli | 2026-04-20 @ Asia/Taipei
"""aider-watch subcommands: doctor, flush."""
from __future__ import annotations
import argparse, sys, subprocess
from aider_watch_client import config, buffer
from aider_watch_client.api_client import post_events


def cmd_doctor(_a) -> int:
    ok = True
    for k in ("AIDER_API_URL", "AIDER_WEBHOOK_SECRET"):
        v = config.get(k)
        mark = "✅" if v else "❌"
        print(f"  env {k:30s} {mark}")
        if not v: ok = False
    import requests
    try:
        url = config.get("AIDER_API_URL", required=True).replace("/events", "/healthz")
        r = requests.get(url, timeout=3)
        print(f"  awoooi API reachable ✅ {r.status_code}")
    except Exception as e:
        print(f"  awoooi API reachable ❌ {e}")
        ok = False
    res = subprocess.run(["launchctl","list"], capture_output=True, text=True)
    mark = "✅" if "com.awoooi.aider-flush" in res.stdout else "❌"
    print(f"  launchd aider-flush     {mark}")
    return 0 if ok else 1


def cmd_flush(_a) -> int:
    n = buffer.flush(post_fn=post_events)
    print(f"flushed {n} events")
    return 0


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)
    a = p.parse_args()
    return a.func(a)


if __name__ == "__main__":
    sys.exit(main())
  • Step 3: 寫 bin/aiderw
#!/usr/bin/env python3
# aider-watch-client shim | 2026-04-20 @ Asia/Taipei
import sys
from aider_watch_client.aiderw import main
sys.exit(main())
chmod +x scripts/aider_watch_client/bin/aiderw
  • Step 4: 本機煙測(需啟動 awoooi dev server 或 mock API
cd scripts/aider_watch_client && pip install -e . --quiet
AIDER_API_URL="http://httpbin.org/post" \
AIDER_WEBHOOK_SECRET="test" \
python -c "from aider_watch_client.api_client import post_events; \
  print(post_events([{'ts':'2026-04-20T10:00:00+08:00','session_id':'s','host':'m','type':'raw','payload':{}}]))"

Expected: True (httpbin returns 200 for POST, 雖簽章無效但不做驗證 server 能回 200真 awoooi 會 401 — 這步只驗連線通)

  • Step 5: Commit
git add scripts/aider_watch_client/aider_watch_client/aiderw.py \
        scripts/aider_watch_client/aider_watch_client/cli.py \
        scripts/aider_watch_client/bin/aiderw
git commit -m "feat(client): aiderw main wrapper + CLI (doctor/flush)"

Task B5: install.sh + launchd flush plist

Files:

  • Create: scripts/aider_watch_client/scripts/install.sh

  • Create: scripts/aider_watch_client/launchd/com.awoooi.aider-flush.plist

  • Step 1: 寫 launchd plist

<?xml version="1.0" encoding="UTF-8"?>
<!-- aider-watch-client flush | 2026-04-20 @ 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-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>RunAtLoad</key><false/>
</dict></plist>

🆕 盲點 1Mac 外網可達性)— 三選一配置

安裝前,統帥需先決定 AIDER_API_URL 的來源:

方式 URL 範例 前置工作
a. awoooi nginx 公網 TLS https://awoooi.example/api/v1/aider/events awoooi nginx 加一條 location blockproxy_pass 到 K3s svc 32334enable cert
b. Tailscale若已裝 http://<tailscale-100-ip>:32334/api/v1/aider/events tailscale up確認 awoooi 188 host 也在 Tailnet
c. VPNWireGuard http://192.168.0.120:32334/api/v1/aider/events WireGuard peer config

裝 install.sh 前,寫 ~/.aider-watch.env 填入選定的 URL。

  • Step 2: 寫 install.sh
#!/usr/bin/env bash
# aider-watch-client installer | 2026-04-20 @ Asia/Taipei
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"

# 1. 裝 clientpipx 或直接 pip 到使用者目錄)
if command -v pipx >/dev/null 2>&1; then
  pipx install --python /Users/ogt/.pyenv/versions/3.11.7/bin/python3 -e "$ROOT"
else
  /Users/ogt/.pyenv/versions/3.11.7/bin/python3 -m pip install --user -e "$ROOT"
fi

# 2. symlink aiderw 到 PATH
ln -sf "$HOME/.local/bin/aiderw" /opt/homebrew/bin/aiderw
echo "✓ aiderw linked to /opt/homebrew/bin/aiderw"

# 3. 檢查 ~/.aider-watch.env
if [[ ! -f "$HOME/.aider-watch.env" ]]; then
  cat <<EOF
⚠️  ~/.aider-watch.env 不存在。請建立:

cat > ~/.aider-watch.env <<'EV'
AIDER_API_URL=http://192.168.0.120:32334/api/v1/aider/events
AIDER_WEBHOOK_SECRET=<statesman-provides>
AIDER_WATCH_HOSTNAME=ogt-mac
EV
chmod 600 ~/.aider-watch.env
EOF
  exit 1
fi
chmod 600 "$HOME/.aider-watch.env"

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

# 5. launchd
DEST="$HOME/Library/LaunchAgents"
mkdir -p "$DEST"
cp "$ROOT/launchd/com.awoooi.aider-flush.plist" "$DEST/"
launchctl unload "$DEST/com.awoooi.aider-flush.plist" 2>/dev/null || true
launchctl load -w "$DEST/com.awoooi.aider-flush.plist"
launchctl list | grep aider-flush || { echo "launchd load failed"; exit 2; }

# 6. doctor
aider-watch doctor || { echo "doctor failed — fix env and re-run"; exit 3; }
echo "✓ install OK"
  • Step 3: Commit
chmod +x scripts/aider_watch_client/scripts/install.sh
git add scripts/aider_watch_client/scripts/install.sh scripts/aider_watch_client/launchd/
git commit -m "feat(client): install.sh + launchd flush plist (5min)"

Phase C: 整合驗收3 tasks

Task C1: E2E Happy PathMac → awoooi

需要前置

  • awoooi API 跑起來K3s 或本機 dev

  • ~/.aider-watch.env 有 AIDER_API_URL + AIDER_WEBHOOK_SECRET

  • K8s Secret 或 env 有 AIDER_WEBHOOK_SECRET 匹配

  • Step 1: 跑 install.sh

cd /Users/ogt/awoooi && bash scripts/aider_watch_client/scripts/install.sh

Expected: install OK + doctor 全

  • Step 2: 跑一次 aiderw
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: aider 正常跑terminal 有輸出。

  • Step 3: 查 awoooi PG 有事件
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi -c \
  "SELECT type, ts, payload->>'model' AS model FROM aider_events ORDER BY ts DESC LIMIT 10"

Expected: 至少 session_start + session_end 2 筆;若 aider 改了檔有 file_edit 若改 exit=0 無 error。

  • Step 4: 若 aider 失敗e.g. 模型 timeout看 TG

(若 Step 2 產生 error event檢查統帥 @tsenyangbot DM 有無 incident 告警。

Expected: TG 收到 [aider] ... incident 告警incidents 表有新記錄且 alert_category='aider_activity'


Task C2: E2E Degradation斷線 → buffer → flush

  • Step 1: 拔網路模擬 API 不可達
# 方法 A改 env 指向壞 URL
export AIDER_API_URL="http://127.0.0.1:1/nope"
cd /tmp/aw-e2e && aiderw --yes --no-stream --no-pretty --message "add comment" --exit
unset AIDER_API_URL
  • Step 2: 檢查 buffer 有檔
ls ~/aider-watch/buffer/

Expected: 至少 1 個 pending_*.jsonl

  • Step 3: 恢復正常 env跑 flush
aider-watch flush

Expected: flushed N events (N ≥ 1)

  • Step 4: buffer 應清空PG 應有記錄
ls ~/aider-watch/buffer/                                 # 應為空(或無 pending_*
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi -c \
  "SELECT count(*) FROM aider_events WHERE ts > now() - interval '10 min'"

Expected: buffer 清空PG count 增加。


Task C3: AI Router Feedback 驗證

  • Step 1: 灌 fixture3 場模擬 session2 成功 1 失敗 elephant-alpha1 成功 gemini-pro
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi <<'SQL'
INSERT INTO aider_events (session_id, ts, type, host, payload) VALUES
  ('t-e1', now()-interval '2h', 'session_start', 'm', '{"cwd":"/repo/x","model":"elephant-alpha","aider_args":[],"aider_pid":1,"cli_version":"0"}'),
  ('t-e1', now()-interval '2h', 'session_end', 'm', '{"duration_sec":60,"tokens_sent":100,"tokens_received":50,"cost_usd":0,"files_changed":1,"error_count":0,"exit_code":0}'),
  ('t-e2', now()-interval '1h', 'session_start', 'm', '{"cwd":"/repo/x","model":"elephant-alpha","aider_args":[],"aider_pid":2,"cli_version":"0"}'),
  ('t-e2', now()-interval '1h', 'session_end', 'm', '{"duration_sec":120,"tokens_sent":200,"tokens_received":80,"cost_usd":0,"files_changed":0,"error_count":3,"exit_code":1}'),
  ('t-e3', now()-interval '30min', 'session_start', 'm', '{"cwd":"/repo/x","model":"elephant-alpha","aider_args":[],"aider_pid":3,"cli_version":"0"}'),
  ('t-e3', now()-interval '30min', 'session_end', 'm', '{"duration_sec":90,"tokens_sent":150,"tokens_received":60,"cost_usd":0,"files_changed":2,"error_count":0,"exit_code":0}'),
  ('t-g1', now()-interval '20min', 'session_start', 'm', '{"cwd":"/repo/x","model":"gemini-pro","aider_args":[],"aider_pid":4,"cli_version":"0"}'),
  ('t-g1', now()-interval '20min', 'session_end', 'm', '{"duration_sec":50,"tokens_sent":80,"tokens_received":40,"cost_usd":0,"files_changed":1,"error_count":0,"exit_code":0}');
SQL
  • Step 2: 呼叫 ai_router.feedback_from_aider_events()
cd /Users/ogt/awoooi && /Users/ogt/.pyenv/versions/3.11.7/bin/python3 <<'PY'
import asyncio
from apps.api.src.services.ai_router import AIRouter

async def main():
    r = AIRouter()
    out = await r.feedback_from_aider_events(days=7)
    print(out)

asyncio.run(main())
PY

Expected: {'elephant-alpha': 0.67, 'gemini-pro': 1.0} 或相近success_rate = 2/3 for elephant, 1/1 for gemini

  • Step 3: 清理 fixture
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi -c \
  "DELETE FROM aider_events WHERE session_id LIKE 't-%'"
  • Step 4: 最終 commit
cd /Users/ogt/awoooi
git add -A
git commit --allow-empty -m "chore(aider-watch): v2 E2E verified 2026-04-20"

Self-Review

Spec coverage:

spec 章節 Plan Task
§1 架構總覽 A5+A6+A7+B4 一起湊成完整路徑
§2.1 server 元件 10 項 A1(S1) + A2(S8) + A3(S2) + A4(S3) + A5(S4) + A6(S5) + A7(S6) + A8(S7) + A9(S9+S10)
§2.2 client 元件 6 項 B1(M1-M2) + B2(M3) + B3(M4) + B4(M5 aiderw) + B5(M6 plist + install)
§2.3 v1 產物去向 隱含在 A2/A3/B1 的「從 v1 搬」註解
§3 Event Schema A3
§4 DB schema A1
§5 API contract HMAC A6
§6 AI 學習回路 (incident/pattern/router) A5 + A7 + A8
§7 Telegram 走既有 gateway零新增A5 的 incident_service 會自動觸發
§8 降級策略 A6 回 503 + B2 retry + B3 buffer + B4 atexit
§9 測試策略 Unit: A2/A3/A5/B2/B3Integration: A6E2E: C1/C2AI feedback: C3
§10 Memory 依據 (cross-cut)

Placeholder 掃描:無 TBD / TODO / "similar to Task N"。每個 code step 都有完整 code。

Type 一致性AiderEventIn 在 A3 定義A4/A5/A6/A7 全部引用同名;post_events(events: list[dict]) 在 B2 定義B3/B4 一致使用。


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-04-20-aider-watch-v2.md

兩種執行方式(你選一):

  1. Subagent-Drivenspec 原推薦)— 每 task 派 subagent + 兩階段 review。
  2. Inline Executionv1 後段我已切成這個)— 主 session 直接跑,效率高但 context 消耗大。

考量本 plan 跨兩個 subsystemserver + client+ 整合點多(既有 ai_router / incident_service / telegram_gateway 都要對接),建議分段:

  • Phase A 1-5模型/util/repo/serviceinline — 單純 code好測
  • Phase A 6-9webhook/job/router hook/configsubagent-driven — 整合點多,需 independent review
  • Phase B 全部 clientinline — 獨立 module 無耦合
  • Phase C E2Einline + 統帥監督

你要照這策略,還是要求全 subagent / 全 inline