Skill: zeron-g/heartbeat-manager (version 1.2.0)

--- name: heartbeat-manager description: Agent 心跳管理系统：自动检查任务状态、智能超时分析、日报/周报、健康度评分。与 OpenClaw 心跳同步运行。 --- # Heartbeat Manager > 自动化任务监控 · 智能超时分析 · 日报/周报 · 健康度评分 --- ## ⚠️ 安装前须知 ### Git / subprocess 声明 本 Skill 在 `tools/git_ops.py` 中使用 `subprocess.run` 调用系统 `git` 命令（`git add`、`git commit`、`git push`）。 **默认行为：Git 功能完全关闭（`git.enabled: false`）**，不会执行任何 git 操作，除非你在 `config/settings.yaml` 中显式开启。 安全措施： - 所有 `subprocess.run` 使用参数列表，**禁止 `shell=True`**（杜绝 shell 注入） - commit message 经过净化，移除控制字符 - `auto_push` 单独开关，默认同样关闭 ### 凭证声明 本 Skill **不内置、不存储任何邮件凭证**。邮件功能（告警、日报、周报）需要你在安装后自行配置： - 提供一个 Gmail 账号及其 App Password - 填入 `config/.env`（该文件永远不会被上传或共享） > 若不配置邮件，Skill 仍可正常运行心跳检查（任务监控、健康度评分），仅邮件通知功能不可用。 ### 副作用声明 本 Skill 会在运行时产生以下副作用： | 操作 | 说明 | 可关闭 | |------|------|--------| | 写入本地文件 | 更新 `workspace/` 下的 MASTER.md、state.json 等 | ❌ 核心功能 | | 写入日志 | 追加 `logs/heartbeat.log` | ✅ `settings.yaml` | | IMAP 读取邮件 | 检查指定邮箱未读邮件 | ✅ `email.enabled: false` | | SMTP 发送邮件 | 发送告警、日报、周报 | ✅ `email.enabled: false` | | Git commit + push | 自动提交工作区变更至远程 | ✅ `git.enabled: false`（默认关闭） | --- ## Quick Start ### 1. 安装依赖 ```bash pip install pyyaml jinja2 python-dotenv # 或使用 uv（推荐） uv venv .venv && uv pip install --python .venv/bin/python pyyaml jinja2 python-dotenv ``` ### 2. 配置邮件（可选，但强烈推荐） ```bash cp config/.env.example config/.env ``` 编辑 `config/.env`： ```env # Gmail 发件账号 EMAIL_SENDER=[REDACTED_SECRET] # Gmail 应用专用密码（非登录密码） # 获取方式：Google 账号 → 安全性 → 两步验证 → 应用专用密码 EMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx # 收件人列表（逗号分隔） EMAIL_RECIPIENTS=[REDACTED_SECRET] ``` > 如何获取 Gmail App Password： > 1. 开启 Google 账号两步验证 > 2. 访问 myaccount.google.com/apppasswords > 3. 生成一个应用专用密码并粘贴到上方 ### 3. 调整配置（可选） 编辑 `config/settings.yaml`： ```yaml email: enabled: true # 改为 false 可完全禁用邮件功能 git: enabled: false # 改为 true 可开启 Git 同步（默认关闭） auto_push: false # 改为 true 可开启自动推送 ``` ### 4. 配置 Git 同步（可选，默认关闭） > ⚠️ Git 功能会调用系统 `git` 命令向远程仓库推送内容，请确认你了解并信任此操作后再开启。 如需开启，编辑 `config/settings.yaml`： ```yaml git: enabled: true # 第一步：开启 git 功能 auto_commit: true # 每次心跳自动 commit auto_push: false # 推送到远程（确认 remote 已配置后再开启） ``` 开启前请确认： 1. 当前目录已初始化 git 仓库（`git status`） 2. 已配置远程 remote（`git remote -v`） 3. 你信任此 Skill 向该 remote 推送内容 ### 5. 验证配置 ```bash python tools/heartbeat_run.py status ``` ### 6. 首次心跳 ```bash python tools/heartbeat_run.py beat ``` --- ## 功能概览 ### `beat` — 心跳检查（每30分钟） 0. 检查 `.last_heartbeat` 标记文件，距上次 < 30 分钟则静默退出（v1.2.0 watchdog） 1. 检查 `daily.md` 例行任务完成情况 2. 检查 `todo.md` 待办 + `@due:HH:MM` 超期检测 3. 检查 `ongoing.json` 任务状态机 4. 智能超时分析（正常推进 vs 完全卡死） 4.5. 检查 `upcoming.md` 未来7天事件 — 🔴🟡🔵分级预警（v1.1.0） 4.7. 检测 Chrome relay → 在线则同步 Canvas+FSP → `📡 同步`任务自动打勾（v1.1.0） 5. 检查邮件（需配置凭证） 6. 清理已完成 todo 7. Git 同步（可选） 8. 计算健康度评分（0-100） 9. 更新 `MASTER.md` 主控表（含 `## UPCOMING 7D` 段） ### `reset` — 每日重置（00:00） - 发送昨日完成任务日报邮件（需配置凭证） - 重置 `daily.md` 为新一天 - 清理已完成的 ongoing 任务 ### `weekly` — 周报（每周日 23:59） - 汇总本周健康度趋势与任务统计（需配置凭证） ### `status` — 查看状态 - 无需凭证，打印当前 MASTER 快照 --- ## OpenClaw 集成 在 `HEARTBEAT.md` 中添加： ```bash cd /path/to/heartbeat-manager && python tools/heartbeat_run.py beat ``` OpenClaw 内置心跳触发时将自动执行本 Skill。 --- ## 任务文件格式 **`daily.md`** — 每日例行任务 ```markdown # DAILY | 2026-02-25 - [ ] 晨间邮件检查 - [ ] 更新记忆库 - [x] 系统状态确认 @done:14:30 ``` **`todo.md`** — 动态待办 ```markdown - [ ] 修复登录 bug @due:18:00 - [ ] 写周报 ``` **`ongoing.json`** — 任务状态机 ```json { "tasks": [{ "id": "01", "title": "毕业论文", "status": "WIP", "priority": "P0", "eta": "2026-03-01", "progress": 65, "context": "第三章进行中" }] } ``` 状态流转：`IDLE → WIP → DONE`，`WIP → WAIT → WIP`，`WIP → BLOCK`（智能检测卡死） --- ## 健康度评分 | 维度 | 权重 | 说明 | |------|------|------| | Daily 完成率 | 25% | 例行任务完成比例 | | Todo 完成率 | 20% | 超期扣分 | | Ongoing 状态 | 25% | BLOCK/超期扣分 | | 邮件处理 | 15% | 未读过多扣分 | | Git 同步 | 15% | push 成功满分；Git 禁用时不扣分 | 连续 3 次低于 60 分 → 邮件告警 --- ## 未来事件监控 (v1.1.0) 每次心跳自动检测 Chrome 扩展 relay，在线时同步 Canvas + FSP 数据到 `workspace/upcoming.md`；离线时保留现有数据不做任何删除。 ### upcoming.md 四分区格式 ```markdown # Upcoming Events ## 🔮 FUTURE （待完成事件） - 2026-03-01 | Canvas: CS601 Quiz 3 | [作业] @due:23:59 @src:canvas @id:canvas-xxx - 2026-03-02 | 飞行训练 KGAI N12345 | [飞行] @time:14:00-17:00 @src:fsp @id:fsp-yyy ## 📌 MANUAL （手动添加，不受自动清理影响） - 2026-05-15 | 期末考试周 | [考试] ## ✅ DONE （已完成，事件日期+7天后自动删除） - [x] 2026-02-25 | Internet Systems Project #1 | [作业] @done:2026-02-25 ## ⏰ OVERDUE （已过期未完成） （暂无） ``` **标签说明：** - `@src:canvas` / `@src:fsp` — 自动同步来源，未标记 src 的为手动事件 - `@id:xxx` — 来源系统唯一ID，用于去重更新 - `@due:HH:MM` — 截止时间；`@time:HH:MM-HH:MM` — 事件时段 - `@done:YYYY-MM-DD` — 完成日期，超过7天后自动清理 **7天预警颜色（MASTER.md 中显示）：** - 🔴 ≤1天（紧急）→ ALERTS 区也会出现 - 🟡 ≤3天（注意） - 🔵 ≤7天（提醒） ### Canvas LMS 配置 ```bash # 获取方式：Canvas → Account → Settings → Approved Integrations → New Access Token # 注意：部分机构学生账户可能无权生成 token（可手动维护 upcoming.md） ``` 在 `config/.env` 中填入： ```env CANVAS_API_TOKEN=your_token_here ``` 在 `config/settings.yaml` 中配置： ```yaml monitoring: canvas: enabled: true base_url: "https://your-canvas-url.instructure.com" lookahead_days: 30 ``` ### Flight Schedule Pro (FSP) 配置 FSP API 为机构级权限，普通学员账户通常无法获取。如有权限： ```env FSP_API_TOKEN=your_token_here FSP_OPERATOR_ID=your_operator_id ``` ### Chrome 浏览器自动同步 每次 `beat` 自动检测本地 Chrome 扩展 relay（`127.0.0.1:18792`）是否在线： - **在线** → 调用 `tools/site_monitor.py` 同步 Canvas + FSP → 自动在 daily.md 的 `📡 同步` 任务打勾 - **离线** → 静默跳过，保留现有 upcoming.md 数据不变 **配置 Chrome 扩展 relay：** ```bash openclaw browser extension install openclaw browser extension path # → 在 Chrome 中加载该路径的扩展，填入 Gateway token，attach 目标标签页 ``` > 💡 若无 API token，可每天打开 Canvas/FSP 网页并 attach Chrome 扩展，让 Eva 通过浏览器直接抓取数据，无需 API 凭证。 **安全保证：** `site_monitor.py` 使用 `active_sources` 机制，仅删除已激活来源中不再存在的事件；未配置 token 的来源完全跳过，现有数据100%保留。 --- ## 看门狗机制 (v1.2.0) 配合 `*/15 * * * *` cron 运行，实现可靠的30分钟心跳间隔： - **原理**：beat 完成后 `touch workspace/.last_heartbeat`；cron 每 15 分钟触发，先检查 mtime，距上次不到 30 分钟则静默退出 - **最大延迟**：15 分钟（cron 最差情况下一个间隔） - **无外部依赖**：只依赖文件系统，不调用 openclaw CLI - **自愈**：cron 是幂等的，leaky 或 missed 触发自动修正 ```yaml # openclaw cron 建议配置： schedule: "*/15 * * * *" ``` --- ## 许可 MIT License

path: _meta.json
content: {
  "name": "heartbeat-manager",
  "version": "1.2.0",
  "display_name": "Heartbeat Manager",
  "description": "Agent 心跳管理系统：自动检查任务状态、未来事件7天预警（Canvas/FSP自动同步）、智能超时分析、日报/周报、健康度评分。支持 Chrome 浏览器自动同步。",
  "author": "Eva Agent",
  "license": "MIT",
  "keywords": ["heartbeat", "monitoring", "task-management", "alert", "email", "upcoming-events", "canvas", "fsp", "browser-sync", "watchdog", "reliability"],
  "category": "system",
  "platform": "openclaw",
  "min_openclaw_version": "0.1.0",
  "python_version": ">=3.10",
  "dependencies": [
    "pyyaml>=6.0",
    "jinja2>=3.1",
    "python-dotenv>=1.0",
    "requests>=2.28"
  ],

  "credentials": {
    "required": false,
    "note": "邮件功能需自行配置 Gmail App Password。Canvas/FSP 监控需对应 API token（可选，不配置时跳过但不删除现有数据）。所有凭证填入 config/.env，不会上传或共享。"
  },

  "side_effects": {
    "file_writes": [
      "workspace/MASTER.md — 每次心跳自动更新（主控表状态）",
      "workspace/state.json — 心跳运行状态持久化",
      "workspace/daily.md — 每日 00:00 重置",
      "workspace/upcoming.md — Canvas+FSP 同步后更新（未来事件）",
      "logs/heartbeat.log — 运行日志追加"
    ],
    "subprocess": {
      "declared": true,
      "commands": ["git add", "git commit", "git push", "git status", "git rev-parse"],
      "shell_injection_risk": "low",
      "mitigation": "所有 subprocess.run 调用使用参数列表，禁止 shell=True；git 功能默认关闭（git.enabled: false）",
      "enabled_by_default": false
    },
    "network": [
      "IMAP 读取邮件（需配置凭证，未配置时静默跳过）",
      "SMTP 发送告警/日报/周报（需配置凭证，未配置时静默跳过）",
      "Canvas REST API 获取作业截止日期（需 CANVAS_API_TOKEN，未配置时跳过）",
      "FSP API 获取飞行预约（需 FSP_API_TOKEN+FSP_OPERATOR_ID，未配置时跳过）",
      "HTTP GET 127.0.0.1:18792 检测 Chrome 扩展 relay 是否在线（本地检测，无外网请求）",
      "Git push 至远程仓库（默认关闭，需 git.enabled=true 且 git.auto_push=true）"
    ],
    "note": "所有网络功能均可独立禁用。Canvas/FSP 同步在未配置 token 时安全跳过，不影响已有数据。"
  },

  "new_in_v1_1_0": {
    "upcoming_events": "workspace/upcoming.md 四分区格式（FUTURE/MANUAL/DONE/OVERDUE），7天内事件🔴🟡🔵分级预警",
    "site_monitor": "tools/site_monitor.py 整合 Canvas LMS + FSP 自动同步，未配置 token 时安全跳过不删数据",
    "browser_sync": "每次 beat 检测 Chrome 扩展 relay（127.0.0.1:18792），在线则自动同步并在 daily.md 打勾",
    "daily_template": "每日例行任务模板新增 📡 同步 Canvas + FSP 日程任务"
  },

  "new_in_v1_2_0": {
    "watchdog": "标记文件看门狗机制：beat 完成后 touch workspace/.last_heartbeat；配合 */15 cron 实现双重可靠性，最大延迟15分钟，无外部依赖，自动防漂移"
  },

  "setup_required": true,
  "setup_steps": [
    "cp config/.env.example config/.env",
    "编辑 config/.env，填入 EMAIL_SENDER / EMAIL_APP_PASSWORD / EMAIL_RECIPIENTS",
    "（可选）填入 CANVAS_API_TOKEN 启用 Canvas 作业同步",
    "（可选）填入 FSP_API_TOKEN + FSP_OPERATOR_ID 启用 FSP 飞行预约同步",
    "编辑 config/settings.yaml，按需调整配置",
    "python tools/heartbeat_run.py status  # 验证配置"
  ],

  "entry_point": "tools/heartbeat_run.py",
  "commands": {
    "beat":   { "description": "执行一次心跳检查", "usage": "python tools/heartbeat_run.py beat" },
    "reset":  { "description": "每日重置 + 发送日报", "usage": "python tools/heartbeat_run.py reset" },
    "weekly": { "description": "生成并发送周报", "usage": "python tools/heartbeat_run.py weekly" },
    "status": { "description": "查看当前状态摘要", "usage": "python tools/heartbeat_run.py status" }
  },

  "cron": {
    "heartbeat":    { "schedule": "*/15 * * * *", "command": "python tools/heartbeat_run.py beat",   "description": "每15分钟触发（看门狗保证30分钟间隔）" },
    "daily_reset":  { "schedule": "0 0 * * *",    "command": "python tools/heartbeat_run.py reset",  "description": "每天0点重置+日报" },
    "weekly_report":{ "schedule": "59 23 * * 0",  "command": "python tools/heartbeat_run.py weekly", "description": "每周日23:59周报" }
  },

  "repository": "https://clawhub.com/heartbeat-manager",
  "created": "2026-02-25",
  "updated": "2026-02-25"
}


path: config/settings.yaml
content: # heartbeat-manager 配置文件
# 邮件凭证（账号、密码、收件人）请配置在 config/.env，不要写在此文件中

# 心跳间隔（分钟）
heartbeat_interval: 30

# ── 邮件 ──────────────────────────────────────
email:
  enabled: true                 # 改为 false 可完全禁用所有邮件功能
  smtp_host: smtp.gmail.com
  smtp_port: 587
  imap_host: imap.gmail.com
  imap_port: 993
  # 高优先级发件人关键词（邮件检查时优先标记，填发件人邮箱的一部分即可）
  high_priority_senders: []

# ── 智能超时分析 ───────────────────────────────
timeout:
  eta_grace_hours: 2            # 超过 eta 多少小时后触发分析
  stale_beats: 3                # 连续 N 个心跳无进展视为卡死
  progress_threshold: 5         # progress 变化低于此值视为无进展

# ── 健康度评分 ─────────────────────────────────
health:
  alert_threshold: 60           # 低于此分数计为低分
  alert_consecutive: 3          # 连续低分次数触发邮件告警

# ── 日志 ───────────────────────────────────────
logging:
  level: INFO
  rotate_days: 7

# ── Git 同步 ───────────────────────────────────
# ⚠️ 警告：git 功能会调用系统 git 命令向远程仓库推送变更，默认关闭。
git:
  enabled: true                 # 本地 eva-shared-memory remote 已配置
  auto_commit: true
  auto_push: true
  commit_prefix: "[heartbeat]"

# ── 网站监控 (v1.1.0) ──────────────────────────
# 自动同步 Canvas 作业截止日期 + FSP 飞行预约到 workspace/upcoming.md
# 需在 config/.env 中配置对应 token；未配置时跳过但不删除现有数据
monitoring:
  canvas:
    enabled: true
    base_url: "https://jhu.instructure.com"
    lookahead_days: 30
  fsp:
    enabled: true
    lookahead_days: 7

# ── 工作区 ─────────────────────────────────────
workspace_dir: workspace

# ── 心跳看门狗 (v1.2.0) ───────────────────────────
# 配合 */15 * * * * cron 使用
# 每次触发先检查距上次心跳时间，未到间隔则静默跳过
watchdog:
  enabled: true
  interval_seconds: 1800    # 期望间隔（30 分钟）
  tolerance_seconds: 90     # 容差（防止边界跳过）


path: templates/daily_template.md
content: # Daily Tasks | {{ date }}

- [ ] 晨间邮件检查
- [ ] 更新记忆库
- [ ] 工作区整理
- [ ] 系统状态确认
- [ ] 晚间回顾总结
- [ ] 📡 同步 Canvas + FSP 日程（打开浏览器后心跳自动抓取）


path: tools/__init__.py
content: #!/usr/bin/env python3
"""heartbeat-manager 工具包"""


path: tools/canvas_monitor.py
content: #!/usr/bin/env python3
"""
Canvas LMS 监控模块

从 JHU Canvas 获取未来作业列表，返回标准化事件。

API 文档: https://canvas.instructure.com/doc/api/
认证: Bearer token (从 config/.env 读取 CANVAS_API_TOKEN)
"""

import logging
from datetime import datetime, timedelta
from pathlib import Path

import requests

logger = logging.getLogger("heartbeat.canvas")

CONFIG_DIR = Path(__file__).parent.parent / "config"


def _load_settings() -> dict:
    import yaml
    p = CONFIG_DIR / "settings.yaml"
    with open(p, "r", encoding="utf-8") as f:
        return yaml.safe_load(f).get("monitoring", {}).get("canvas", {})


def _load_token() -> str:
    from dotenv import dotenv_values
    env_path = CONFIG_DIR / ".env"
    if not env_path.exists():
        return ""
    values = dotenv_values(env_path)
    return values.get("CANVAS_API_TOKEN", "").strip()


def is_configured() -> bool:
    """检查 Canvas token 是否已配置"""
    settings = _load_settings()
    if not settings.get("enabled", True):
        return False
    return bool(_load_token())


def sync() -> list[dict]:
    """
    从 Canvas API 获取未来作业列表。

    返回:
        [{"id": "canvas-{id}", "date": "YYYY-MM-DD", "description": "课程: 作业名",
          "category": "作业", "time": "HH:MM", "src": "canvas"}]

    如果 token 未配置或功能禁用，返回空列表。
    """
    settings = _load_settings()
    if not settings.get("enabled", True):
        logger.debug("Canvas 监控已禁用")
        return []

    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"CANVAS_API_TOKEN 未配置，跳过 Canvas 同步")
        return []

    base_url = settings.get("base_url", "https://jhu.instructure.com")
    lookahead = settings.get("lookahead_days", 30)
    api_url = f"{base_url}/api/v1"
    headers = {"Authorization": f"Bearer {token}"}
    timeout = 30

    events = []

    try:
        # 1. 获取活跃课程
        courses = _get_active_courses(api_url, headers, timeout)
        logger.info("Canvas: 获取到 %d 个活跃课程", len(courses))

        # 2. 获取每个课程的作业
        deadline = datetime.now() + timedelta(days=lookahead)
        now = datetime.now()

        for course in courses:
            course_id = course["id"]
            course_name = course.get("name", f"Course {course_id}")
            try:
                assignments = _get_assignments(
                    api_url, headers, timeout, course_id, now, deadline
                )
                for a in assignments:
                    events.append(_normalize_assignment(a, course_name))
            except requests.RequestException as e:
                logger.warning("Canvas: 课程 %s 作业获取失败: %s", course_name, e)

        logger.info("Canvas: 共获取 %d 个待完成作业", len(events))

    except requests.RequestException as e:
        logger.error("Canvas API 请求失败: %s", e)

    return events


def _get_active_courses(api_url: str, headers: dict, timeout: int) -> list:
    """获取当前学期活跃课程"""
    courses = []
    url = f"{api_url}/courses"
    params = {
        "enrollment_state": "active",
        "per_page": 100,
    }

    while url:
        resp = requests.get(url, headers=headers, params=params, timeout=timeout)
        resp.raise_for_status()
        courses.extend(resp.json())

        # Canvas 分页通过 Link header
        url = _next_page_url(resp)
        params = {}  # 后续页不需要 params

    return courses


def _get_assignments(
    api_url: str, headers: dict, timeout: int,
    course_id: int, now: datetime, deadline: datetime,
) -> list:
    """获取课程中未来的未完成作业"""
    url = f"{api_url}/courses/{course_id}/assignments"
    params = {
        "per_page": 100,
        "order_by": "due_at",
        "include[]": "submission",
    }

    assignments = []

    while url:
        resp = requests.get(url, headers=headers, params=params, timeout=timeout)
        resp.raise_for_status()

        for a in resp.json():
            due_at = a.get("due_at")
            if not due_at:
                continue

            try:
                due_dt = datetime.fromisoformat(due_at.replace("Z", "+00:00"))
                due_local = due_dt.astimezone()  # 转换为本地时区
            except (ValueError, TypeError):
                continue

            # 筛选：在范围内且未完成
            if due_local.replace(tzinfo=None) > deadline:
                continue

            # 检查是否已提交
            submission = a.get("submission", {})
            if submission and submission.get("workflow_state") == "graded":
                if submission.get("score") is not None:
                    continue  # 已评分，跳过

            # 已提交的也包含（可能还没评分但已交）
            if a.get("has_submitted_submissions"):
                submitted = submission.get("submitted_at") if submission else None
                if submitted:
                    continue  # 已提交

            assignments.append({
                "id": a["id"],
                "name": a.get("name", "Untitled"),
                "due_at": due_local,
            })

        url = _next_page_url(resp)
        params = {}

    return assignments


def _normalize_assignment(assignment: dict, course_name: str) -> dict:
    """将 Canvas 作业标准化为事件格式"""
    due = assignment["due_at"]
    return {
        "id": f"canvas-{assignment['id']}",
        "date": due.strftime("%Y-%m-%d"),
        "description": f"{course_name}: {assignment['name']}",
        "category": "作业",
        "time": due.strftime("%H:%M"),
        "src": "canvas",
    }


def _next_page_url(resp: requests.Response) -> str | None:
    """从 Canvas Link header 解析下一页 URL"""
    link = resp.headers.get("Link", "")
    for part in link.split(","):
        if 'rel="next"' in part:
            url = part.split(";")[0].strip().strip("<>")
            return url
    return None


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    results = sync()
    for r in results:
        print(f"  {r['date']} | {r['description']} | [{r['category']}] @time:{r['time']} @src:{r['src']} @id:{r['id']}")
    if not results:
        print("  (无结果 - 请检查 CANVAS_API_TOKEN 配置)")


path: tools/checker.py
content: #!/usr/bin/env python3
"""各项检查逻辑模块：daily / todo / ongoing 状态检查"""

import json
import logging
import re
from datetime import datetime
from pathlib import Path
from typing import Optional

logger = logging.getLogger("heartbeat.checker")

WORKSPACE = Path(__file__).parent.parent / "workspace"


def _read_file(name: str) -> Optional[str]:
    """安全读取工作区文件"""
    path = WORKSPACE / name
    if not path.exists():
        logger.warning("文件不存在: %s", path)
        return None
    try:
        return path.read_text(encoding="utf-8")
    except Exception as e:
        logger.error("读取文件失败 %s: %s", name, e)
        return None


def check_daily() -> dict:
    """
    检查 daily.md 完成情况

    返回:
        {
            "total": int,
            "done": int,
            "pending": [str],
            "items": [{"text": str, "done": bool}],
            "error": str | None
        }
    """
    result = {"total": 0, "done": 0, "pending": [], "items": [], "error": None}
    content = _read_file("daily.md")
    if content is None:
        result["error"] = "daily.md 不存在"
        return result

    for line in content.splitlines():
        line = line.strip()
        # 匹配 - [x] 或 - [ ] 格式
        m = re.match(r"^-\s*\[([ xX])\]\s*(.+)$", line)
        if m:
            done = m.group(1).lower() == "x"
            text = m.group(2).strip()
            result["items"].append({"text": text, "done": done})
            result["total"] += 1
            if done:
                result["done"] += 1
            else:
                result["pending"].append(text)

    logger.info("daily 检查: %d/%d 完成", result["done"], result["total"])
    return result


def check_todo() -> dict:
    """
    检查 todo.md 待办事项

    返回:
        {
            "total": int,
            "done": int,
            "pending": [str],
            "overdue": [{"text": str, "due": str}],
            "items": [{"text": str, "done": bool, "due": str | None}],
            "error": str | None
        }
    """
    result = {
        "total": 0, "done": 0, "pending": [], "overdue": [],
        "items": [], "error": None,
    }
    content = _read_file("todo.md")
    if content is None:
        result["error"] = "todo.md 不存在"
        return result

    now = datetime.now()

    for line in content.splitlines():
        line = line.strip()
        m = re.match(r"^-\s*\[([ xX])\]\s*(.+)$", line)
        if m:
            done = m.group(1).lower() == "x"
            text = m.group(2).strip()

            # 解析 @due:HH:MM
            due_str = None
            due_match = re.search(r"@due:(\d{1,2}:\d{2})", text)
            if due_match:
                due_str = due_match.group(1)

            item = {"text": text, "done": done, "due": due_str}
            result["items"].append(item)
            result["total"] += 1

            if done:
                result["done"] += 1
            else:
                result["pending"].append(text)

                # 检查是否超期
                if due_str and not done:
                    try:
                        due_time = datetime.strptime(due_str, "%H:%M").replace(
                            year=now.year, month=now.month, day=now.day
                        )
                        if now > due_time:
                            result["overdue"].append({"text": text, "due": due_str})
                    except ValueError:
                        pass

    logger.info(
        "todo 检查: %d/%d 完成, %d 超期",
        result["done"], result["total"], len(result["overdue"]),
    )
    return result


def check_ongoing() -> dict:
    """
    检查 ongoing.json 任务状态

    返回:
        {
            "total": int,
            "by_status": {"WIP": int, "WAIT": int, ...},
            "tasks": [dict],
            "stale": [dict],       # 可能卡死的任务
            "overdue": [dict],     # 超过 eta 的任务
            "error": str | None
        }
    """
    result = {
        "total": 0, "by_status": {}, "tasks": [],
        "stale": [], "overdue": [], "error": None,
    }
    content = _read_file("ongoing.json")
    if content is None:
        result["error"] = "ongoing.json 不存在"
        return result

    try:
        data = json.loads(content)
    except json.JSONDecodeError as e:
        result["error"] = f"ongoing.json 解析失败: {e}"
        logger.error(result["error"])
        return result

    tasks = data if isinstance(data, list) else data.get("tasks", [])
    result["total"] = len(tasks)
    result["tasks"] = tasks

    now = datetime.now()

    for task in tasks:
        status = task.get("status", "IDLE")
        result["by_status"][status] = result["by_status"].get(status, 0) + 1

        # 检查 eta 是否过期（仅对活跃任务）
        if status in ("WIP", "WAIT"):
            eta = task.get("eta", "")
            if eta and eta != "持续":
                try:
                    # 支持 MM-DD 或 YYYY-MM-DD 格式
                    if len(eta) <= 5:
                        eta_date = datetime.strptime(eta, "%m-%d").replace(year=now.year)
                    else:
                        eta_date = datetime.strptime(eta, "%Y-%m-%d")
                    if now.date() > eta_date.date():
                        result["overdue"].append(task)
                except ValueError:
                    pass

    logger.info(
        "ongoing 检查: 共%d, 状态=%s, 超期=%d",
        result["total"], result["by_status"], len(result["overdue"]),
    )
    return result


def clean_done_todos() -> int:
    """
    清理已完成的 todo 项（完成即删除）

    返回: 删除的条目数
    """
    path = WORKSPACE / "todo.md"
    if not path.exists():
        return 0

    content = path.read_text(encoding="utf-8")
    lines = content.splitlines()
    new_lines = []
    removed = 0

    for line in lines:
        stripped = line.strip()
        if re.match(r"^-\s*\[[xX]\]", stripped):
            removed += 1
        else:
            new_lines.append(line)

    if removed > 0:
        # 原子写入
        tmp_path = path.with_suffix(".tmp")
        tmp_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
        tmp_path.rename(path)
        logger.info("清理了 %d 条已完成 todo", removed)

    return removed


path: tools/daily_reset.py
content: #!/usr/bin/env python3
"""每日 00:00 重置 + 日报发送模块"""

import json
import logging
from datetime import datetime, timedelta
from pathlib import Path

logger = logging.getLogger("heartbeat.daily_reset")

WORKSPACE = Path(__file__).parent.parent / "workspace"
TEMPLATES = Path(__file__).parent.parent / "templates"


def _load_daily_template() -> str:
    """加载 daily 模板"""
    path = TEMPLATES / "daily_template.md"
    if path.exists():
        return path.read_text(encoding="utf-8")
    # 默认模板
    return (
        "# Daily Tasks | {{ date }}\n\n"
        "- [ ] 晨间邮件检查\n"
        "- [ ] 更新记忆库\n"
        "- [ ] 工作区整理\n"
        "- [ ] 系统状态确认\n"
        "- [ ] 晚间回顾总结\n"
        "- [ ] 📡 同步 Canvas + FSP 日程（打开浏览器后心跳自动抓取）\n"
    )


def _collect_yesterday_summary() -> dict:
    """收集昨日完成情况"""
    summary = {
        "date": (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d"),
        "daily": {"done": 0, "total": 0, "items": []},
        "todo": {"completed": 0, "items": []},
        "ongoing": {"done": [], "active": []},
        "health": {"scores": [], "average": 0},
    }

    # 读取当前 daily.md（重置前）
    daily_path = WORKSPACE / "daily.md"
    if daily_path.exists():
        import re
        content = daily_path.read_text(encoding="utf-8")
        for line in content.splitlines():
            m = re.match(r"^-\s*\[([ xX])\]\s*(.+)$", line.strip())
            if m:
                done = m.group(1).lower() == "x"
                text = m.group(2).strip()
                summary["daily"]["total"] += 1
                if done:
                    summary["daily"]["done"] += 1
                summary["daily"]["items"].append({"text": text, "done": done})

    # 读取 ongoing.json 中已完成的任务
    ongoing_path = WORKSPACE / "ongoing.json"
    if ongoing_path.exists():
        try:
            data = json.loads(ongoing_path.read_text(encoding="utf-8"))
            tasks = data if isinstance(data, list) else data.get("tasks", [])
            for task in tasks:
                if task.get("status") == "DONE":
                    summary["ongoing"]["done"].append(task.get("title", "?"))
                elif task.get("status") in ("WIP", "WAIT"):
                    summary["ongoing"]["active"].append(task.get("title", "?"))
        except (json.JSONDecodeError, Exception):
            pass

    # 读取健康度历史
    state_path = WORKSPACE / "state.json"
    if state_path.exists():
        try:
            state = json.loads(state_path.read_text(encoding="utf-8"))
            today_str = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
            for h in state.get("health_history", []):
                if h.get("time", "").startswith(today_str):
                    summary["health"]["scores"].append(h["score"])
            if summary["health"]["scores"]:
                summary["health"]["average"] = round(
                    sum(summary["health"]["scores"]) / len(summary["health"]["scores"]), 1
                )
        except (json.JSONDecodeError, Exception):
            pass

    return summary


def generate_daily_report(summary: dict) -> str:
    """生成日报邮件内容"""
    try:
        from jinja2 import Environment, FileSystemLoader
        env = Environment(loader=FileSystemLoader(str(TEMPLATES)))
        template = env.get_template("email_review.j2")
        return template.render(summary=summary)
    except ImportError:
        logger.info("jinja2 不可用，使用纯文本日报")
    except Exception as e:
        logger.warning("模板渲染失败，回退纯文本: %s", e)

    # 纯文本回退
    lines = []
    lines.append(f"📋 EVA 日报 | {summary['date']}")
    lines.append("=" * 40)
    lines.append("")

    # Daily
    d = summary["daily"]
    lines.append(f"## 例行任务 [{d['done']}/{d['total']}]")
    for item in d["items"]:
        mark = "✅" if item["done"] else "❌"
        lines.append(f"  {mark} {item['text']}")
    lines.append("")

    # Ongoing
    o = summary["ongoing"]
    if o["done"]:
        lines.append("## 已完成任务")
        for t in o["done"]:
            lines.append(f"  ✅ {t}")
        lines.append("")
    if o["active"]:
        lines.append("## 进行中任务")
        for t in o["active"]:
            lines.append(f"  🔄 {t}")
        lines.append("")

    # Health
    h = summary["health"]
    if h["scores"]:
        lines.append(f"## 系统健康度")
        lines.append(f"  平均分: {h['average']}")
        lines.append(f"  心跳次数: {len(h['scores'])}")
        lines.append(f"  分数范围: {min(h['scores'])} - {max(h['scores'])}")
    lines.append("")
    lines.append("---")
    lines.append("Generated by EVA Heartbeat Manager")

    return "\n".join(lines)


def reset_daily():
    """
    执行每日重置:
    1. 收集昨日摘要
    2. 发送日报邮件
    3. 重置 daily.md
    4. 清理已完成的 ongoing 任务

    返回:
        {"report_sent": bool, "daily_reset": bool, "cleanup_count": int, "error": str | None}
    """
    result = {"report_sent": False, "daily_reset": False, "cleanup_count": 0, "error": None}

    try:
        # 1. 收集昨日摘要
        summary = _collect_yesterday_summary()

        # 2. 生成并发送日报
        report = generate_daily_report(summary)
        from tools.mail import send_mail
        subject = f"[EVA日报] {summary['date']} | daily:{summary['daily']['done']}/{summary['daily']['total']}"
        result["report_sent"] = send_mail(subject, report)

        # 3. 重置 daily.md
        template = _load_daily_template()
        today = datetime.now().strftime("%Y-%m-%d")
        new_content = template.replace("{{ date }}", today)

        daily_path = WORKSPACE / "daily.md"
        tmp = daily_path.with_suffix(".tmp")
        tmp.write_text(new_content, encoding="utf-8")
        tmp.rename(daily_path)
        result["daily_reset"] = True
        logger.info("daily.md 已重置")

        # 4. 清理已完成的 ongoing 任务（标记为 DONE 的移除或归档）
        ongoing_path = WORKSPACE / "ongoing.json"
        if ongoing_path.exists():
            data = json.loads(ongoing_path.read_text(encoding="utf-8"))
            tasks = data if isinstance(data, list) else data.get("tasks", [])
            active_tasks = [t for t in tasks if t.get("status") != "DONE"]
            removed = len(tasks) - len(active_tasks)
            result["cleanup_count"] = removed

            if removed > 0:
                tmp = ongoing_path.with_suffix(".tmp")
                tmp.write_text(
                    json.dumps({"tasks": active_tasks}, ensure_ascii=False, indent=2),
                    encoding="utf-8",
                )
                tmp.rename(ongoing_path)
                logger.info("清理了 %d 个已完成 ongoing 任务", removed)

        # 5. 同步网站监控（Canvas + FSP → upcoming.md）
        try:
            from tools.site_monitor import run_sync
            sync_result = run_sync()
            result["site_sync"] = sync_result
            logger.info(
                "网站监控同步: +%d ~%d -%d",
                sync_result["added"], sync_result["updated"], sync_result["removed"],
            )
            if sync_result["errors"]:
                for err in sync_result["errors"]:
                    logger.warning("  同步错误: %s", err)
        except Exception as e:
            logger.warning("网站监控同步失败（非致命）: %s", e)
            result["site_sync"] = {"error": str(e)}

    except Exception as e:
        result["error"] = str(e)
        logger.error("每日重置异常: %s", e)

    return result


path: tools/fsp_monitor.py
content: #!/usr/bin/env python3
"""
Flight Schedule Pro 监控模块

从 FSP API 获取未来飞行预约，返回标准化事件。

API 文档: https://developer.flightschedulepro.com/
认证: x-subscription-key header
"""

import logging
from datetime import datetime, timedelta
from pathlib import Path

import requests

logger = logging.getLogger("heartbeat.fsp")

CONFIG_DIR = Path(__file__).parent.parent / "config"
FSP_API_BASE = "https://api.flightschedulepro.com"


def _load_settings() -> dict:
    import yaml
    p = CONFIG_DIR / "settings.yaml"
    with open(p, "r", encoding="utf-8") as f:
        return yaml.safe_load(f).get("monitoring", {}).get("fsp", {})


def _load_credentials() -> tuple[str, str]:
    """
    返回 (api_token, operator_id)
    """
    from dotenv import dotenv_values
    env_path = CONFIG_DIR / ".env"
    if not env_path.exists():
        return "", ""
    values = dotenv_values(env_path)
    return (
        values.get("FSP_API_TOKEN", "").strip(),
        values.get("FSP_OPERATOR_ID", "").strip(),
    )


def is_configured() -> bool:
    """检查 FSP 凭证是否已配置"""
    settings = _load_settings()
    if not settings.get("enabled", True):
        return False
    api_token, operator_id = _load_credentials()
    return bool(api_token and operator_id)


def sync() -> list[dict]:
    """
    从 FSP API 获取未来飞行预约。

    返回:
        [{"id": "fsp-{id}", "date": "YYYY-MM-DD", "description": "飞行描述",
          "category": "飞行", "time": "HH:MM-HH:MM", "src": "fsp"}]

    如果凭证未配置或功能禁用，返回空列表。
    """
    settings = _load_settings()
    if not settings.get("enabled", True):
        logger.debug("FSP 监控已禁用")
        return []

    api_token, operator_id = _load_credentials()
    if not api_token or not operator_id:
        logger.warning(
            "FSP_API_TOKEN 或 FSP_OPERATOR_ID 未配置，跳过 FSP 同步。"
            "请在 config/.env 中配置。"
        )
        return []

    lookahead = settings.get("lookahead_days", 7)
    headers = {"x-subscription-key": api_token}
    timeout = 30

    events = []

    try:
        # 获取未来预约 (Scheduling API - Reservations)
        now = datetime.now()
        end = now + timedelta(days=lookahead)

        url = f"{FSP_API_BASE}/operators/{operator_id}/reservations"
        params = {
            "startTime": f"gte:{now.strftime('%Y-%m-%dT00:00:00')}",
            "endTime": f"lte:{end.strftime('%Y-%m-%dT23:59:59')}",
            "sortBy": "startTime",
            "limit": 100,
        }

        resp = requests.get(url, headers=headers, params=params, timeout=timeout)
        resp.raise_for_status()
        reservations = resp.json()

        # FSP API 可能返回 list 或 {"data": list}
        if isinstance(reservations, dict):
            reservations = reservations.get("data", reservations.get("items", []))

        for res in reservations:
            event = _normalize_reservation(res)
            if event:
                events.append(event)

        logger.info("FSP: 获取到 %d 个预约", len(events))

    except requests.HTTPError as e:
        if e.response is not None and e.response.status_code == 401:
            logger.error("FSP API 认证失败，请检查 FSP_API_TOKEN")
        elif e.response is not None and e.response.status_code == 403:
            logger.error("FSP API 权限不足，请检查 API 订阅和 OperatorId")
        else:
            logger.error("FSP API 请求失败: %s", e)
    except requests.RequestException as e:
        logger.error("FSP API 连接失败: %s", e)

    return events


def _normalize_reservation(res: dict) -> dict | None:
    """将 FSP 预约标准化为事件格式"""
    res_id = res.get("id") or res.get("reservationId")
    if not res_id:
        return None

    # 解析时间
    start_raw = res.get("startTime") or res.get("start")
    end_raw = res.get("endTime") or res.get("end")

    if not start_raw:
        return None

    try:
        start_dt = _parse_fsp_datetime(start_raw)
    except (ValueError, TypeError):
        logger.warning("FSP: 无法解析 startTime: %s", start_raw)
        return None

    end_str = ""
    if end_raw:
        try:
            end_dt = _parse_fsp_datetime(end_raw)
            end_str = f"-{end_dt.strftime('%H:%M')}"
        except (ValueError, TypeError):
            pass

    # 构建描述
    aircraft = res.get("aircraftName") or res.get("aircraft", {}).get("name", "")
    aircraft_tail = res.get("tailNumber") or res.get("aircraft", {}).get("tailNumber", "")
    instructor = res.get("instructorName") or ""
    location = res.get("location") or res.get("airport") or ""
    res_type = res.get("type") or res.get("reservationType") or "飞行训练"

    parts = []
    if aircraft:
        parts.append(aircraft)
    elif aircraft_tail:
        parts.append(aircraft_tail)
    if res_type and res_type not in ("Flight", "飞行训练"):
        parts.append(res_type)
    else:
        parts.append("飞行训练")
    if location:
        parts.append(location)

    description = " ".join(parts) if parts else "FSP 预约"

    return {
        "id": f"fsp-{res_id}",
        "date": start_dt.strftime("%Y-%m-%d"),
        "description": description,
        "category": "飞行",
        "time": f"{start_dt.strftime('%H:%M')}{end_str}",
        "src": "fsp",
    }


def _parse_fsp_datetime(raw: str) -> datetime:
    """解析 FSP 的时间格式（ISO 8601）"""
    if raw.endswith("Z"):
        raw = raw[:-1] + "+00:00"
    dt = datetime.fromisoformat(raw)
    return dt.astimezone().replace(tzinfo=None)  # 转本地时间


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    results = sync()
    for r in results:
        print(f"  {r['date']} | {r['description']} | [{r['category']}] @time:{r['time']} @src:{r['src']} @id:{r['id']}")
    if not results:
        print("  (无结果 - 请检查 FSP_API_TOKEN 和 FSP_OPERATOR_ID 配置)")


path: tools/git_ops.py
content: #!/usr/bin/env python3
"""
Git 同步模块

⚠️  安全说明
-----------
本模块使用 subprocess.run 调用系统 git 命令，会对本地仓库进行
git add / git commit / git push 操作。

安全措施：
  1. 所有 subprocess.run 调用均使用参数列表（list），禁止 shell=True，
     杜绝 shell 注入漏洞。
  2. commit message 经过 _sanitize_message() 净化，移除控制字符。
  3. 所有 git 功能默认关闭（settings.yaml → git.enabled: false），
     需用户显式开启。
  4. auto_push 单独设置，默认同样关闭，防止意外向远程推送。

启用方式：
  编辑 config/settings.yaml：
    git:
      enabled: true
      auto_push: true   # 如需自动推送到远程，再单独开启
"""

import re
import logging
import subprocess
from datetime import datetime
from pathlib import Path

logger = logging.getLogger("heartbeat.git")


# ──────────────────────────────────────────────────────────────────────────────
# 内部工具
# ──────────────────────────────────────────────────────────────────────────────

def _load_config() -> dict:
    import yaml
    p = Path(__file__).parent.parent / "config" / "settings.yaml"
    with open(p, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)


def _git_cfg() -> dict:
    return _load_config().get("git", {})


def _is_enabled() -> bool:
    """检查 git.enabled 开关（默认 false）"""
    return _git_cfg().get("enabled", False)


def _get_repo_root() -> Path:
    """返回当前 git 仓库根目录"""
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            capture_output=True, text=True, cwd=Path(__file__).parent.parent,
            # shell=False 是默认值，此处显式注释说明安全意图
        )
        if result.returncode == 0:
            return Path(result.stdout.strip())
    except FileNotFoundError:
        pass
    return Path(__file__).parent.parent


def _sanitize_message(msg: str) -> str:
    """
    净化 commit message：
    - 移除控制字符（防止终端转义注入）
    - 截断到 200 字符
    - 若为空则使用默认值
    """
    # 移除所有控制字符（除换行、制表符）
    clean = re.sub(r"[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]", "", msg)
    clean = clean.strip()[:200]
    return clean or f"[heartbeat] auto @ {datetime.now().strftime('%Y-%m-%d %H:%M')}"


def _run_git(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
    """
    执行 git 子命令。

    安全约束：
    - args 必须是 list（已在调用处保证）
    - 禁止使用 shell=True
    - 超时 30 秒，防止挂起
    """
    return subprocess.run(
        ["git"] + args,
        capture_output=True, text=True,
        cwd=cwd,
        timeout=30,
        # shell=False（Python 默认值，此处不传 shell= 参数）
    )


# ──────────────────────────────────────────────────────────────────────────────
# 公开接口
# ──────────────────────────────────────────────────────────────────────────────

def git_add_workspace() -> bool:
    """
    git add workspace/
    仅添加 workspace/ 目录，不影响其他文件。
    """
    if not _is_enabled():
        logger.debug("Git 功能已禁用（settings.yaml git.enabled=false）")
        return False

    repo = _get_repo_root()
    workspace = Path(__file__).parent.parent / "workspace"
    rel = workspace.relative_to(repo)

    result = _run_git(["add", str(rel)], cwd=repo)
    if result.returncode == 0:
        logger.info("git add workspace 成功")
        return True
    else:
        logger.error("git add 失败: %s", result.stderr.strip())
        return False


def git_commit(message: str = "") -> bool:
    """
    git commit，commit message 经过净化处理。
    若工作区无变更则跳过（不报错）。
    """
    if not _is_enabled():
        return False
    if not _git_cfg().get("auto_commit", True):
        return False

    repo = _get_repo_root()
    prefix = _sanitize_message(_git_cfg().get("commit_prefix", "[heartbeat]"))
    raw_msg = message or f"beat @ {datetime.now().strftime('%Y-%m-%d %H:%M')}"
    full_msg = _sanitize_message(f"{prefix} {raw_msg}")

    # 检查是否有变更（避免空 commit）
    status = _run_git(["status", "--porcelain"], cwd=repo)
    if not status.stdout.strip():
        logger.debug("git 工作区无变更，跳过 commit")
        return True  # 视为成功（无需操作）

    result = _run_git(["commit", "-m", full_msg], cwd=repo)
    if result.returncode == 0:
        logger.info("git commit 成功: %s", full_msg)
        return True
    else:
        logger.error("git commit 失败: %s", result.stderr.strip())
        return False


def git_push() -> bool:
    """
    git push 至远程仓库。

    ⚠️ 此操作会向远程仓库推送内容，需在 settings.yaml 中
       同时开启 git.enabled=true 和 git.auto_push=true。
    """
    if not _is_enabled():
        logger.debug("Git 功能已禁用，跳过 push")
        return False
    if not _git_cfg().get("auto_push", False):
        logger.debug("git.auto_push=false，跳过 push")
        return False

    repo = _get_repo_root()
    result = _run_git(["push"], cwd=repo)
    if result.returncode == 0:
        logger.info("git push 成功")
        return True
    else:
        logger.error("git push 失败: %s", result.stderr.strip())
        return False


def git_sync(message: str = "") -> dict:
    """
    一次性执行 add → commit → push（按配置决定是否执行每步）。
    返回各步骤结果字典。
    """
    if not _is_enabled():
        return {"enabled": False, "add": False, "commit": False, "push": False}

    added    = git_add_workspace()
    committed = git_commit(message)
    pushed   = git_push()
    return {"enabled": True, "add": added, "commit": committed, "push": pushed}


path: tools/health_score.py
content: #!/usr/bin/env python3
"""健康度评分模块：0-100分系统"""

import json
import logging
from datetime import datetime
from pathlib import Path

logger = logging.getLogger("heartbeat.health")

WORKSPACE = Path(__file__).parent.parent / "workspace"
STATE_FILE = WORKSPACE / "state.json"


def _load_state() -> dict:
    """加载持久化状态"""
    if not STATE_FILE.exists():
        return {
            "beat_count": 0,
            "beat_ok_count": 0,
            "streak": 0,
            "health_history": [],
            "last_beat": None,
        }
    try:
        return json.loads(STATE_FILE.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, Exception):
        return {
            "beat_count": 0,
            "beat_ok_count": 0,
            "streak": 0,
            "health_history": [],
            "last_beat": None,
        }


def _save_state(state: dict):
    """保存持久化状态（原子写入）"""
    tmp = STATE_FILE.with_suffix(".tmp")
    tmp.write_text(
        json.dumps(state, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    tmp.rename(STATE_FILE)


def _load_config() -> dict:
    """加载健康度配置"""
    import yaml
    config_path = Path(__file__).parent.parent / "config" / "settings.yaml"
    with open(config_path, "r", encoding="utf-8") as f:
        cfg = yaml.safe_load(f)
    return cfg.get("health", {})


def calculate_score(
    daily_result: dict,
    todo_result: dict,
    ongoing_result: dict,
    mail_result: dict,
    git_result: dict,
) -> int:
    """
    计算当次心跳健康度评分（0-100）

    评分维度:
    - daily 完成率: 25分
    - todo 完成率 + 超期扣分: 20分
    - ongoing 状态健康度: 25分
    - 邮件状态: 15分
    - git 同步: 15分
    """
    score = 0

    # 1. daily 完成率 (25分)
    if daily_result.get("error"):
        score += 10  # 文件缺失给基础分
    elif daily_result["total"] > 0:
        ratio = daily_result["done"] / daily_result["total"]
        score += int(ratio * 25)
    else:
        score += 25  # 无 daily 项视为全部完成

    # 2. todo 完成率 + 超期 (20分)
    if todo_result.get("error"):
        score += 10
    else:
        if todo_result["total"] > 0:
            ratio = todo_result["done"] / todo_result["total"]
            base = int(ratio * 20)
            # 每个超期项扣3分
            penalty = len(todo_result.get("overdue", [])) * 3
            score += max(0, base - penalty)
        else:
            score += 20

    # 3. ongoing 状态 (25分)
    if ongoing_result.get("error"):
        score += 10
    else:
        by_status = ongoing_result.get("by_status", {})
        total = ongoing_result.get("total", 0)
        if total == 0:
            score += 25
        else:
            # BLOCK 和超期严重扣分
            block_count = by_status.get("BLOCK", 0)
            overdue_count = len(ongoing_result.get("overdue", []))
            done_count = by_status.get("DONE", 0)

            base = 25
            base -= block_count * 8   # 每个 BLOCK 扣8分
            base -= overdue_count * 4  # 每个超期扣4分
            base += min(5, done_count * 2)  # 完成奖励，最多5分
            score += max(0, min(25, base))

    # 4. 邮件状态 (15分)
    if mail_result.get("error"):
        score += 5  # 邮件检查失败给基础分
    else:
        base = 15
        # 未读过多扣分
        unread = mail_result.get("unread_count", 0)
        if unread > 10:
            base -= 5
        elif unread > 5:
            base -= 2
        score += max(0, base)

    # 5. git 同步 (15分)
    if git_result is None or git_result.get("enabled") is False:
        score += 15  # git 未启用视为正常（不惩罚禁用 git 的用户）
    elif git_result.get("error"):
        score += 5
    else:
        if git_result.get("push", False):
            score += 15
        elif git_result.get("commit", False):
            score += 10
        else:
            score += 5

    return min(100, max(0, score))


def record_score(score: int) -> dict:
    """
    记录健康度分数，检查是否需要告警

    返回:
        {
            "score": int,
            "streak": int,
            "beat_count": int,
            "beat_ok_count": int,
            "alert_needed": bool,
            "consecutive_low": int,
        }
    """
    config = _load_config()
    state = _load_state()
    threshold = config.get("alert_threshold", 60)
    alert_consecutive = config.get("alert_consecutive", 3)

    state["beat_count"] = state.get("beat_count", 0) + 1
    now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
    state["last_beat"] = now_str

    # 记录分数历史（保留最近 100 条）
    history = state.get("health_history", [])
    history.append({"time": now_str, "score": score})
    if len(history) > 100:
        history = history[-100:]
    state["health_history"] = history

    # 计算连续低分次数
    consecutive_low = 0
    for entry in reversed(history):
        if entry["score"] < threshold:
            consecutive_low += 1
        else:
            break

    alert_needed = consecutive_low >= alert_consecutive

    # 更新 streak 和 ok 计数
    if score >= threshold:
        state["beat_ok_count"] = state.get("beat_ok_count", 0) + 1
        state["streak"] = state.get("streak", 0) + 1
    else:
        state["streak"] = 0

    _save_state(state)

    result = {
        "score": score,
        "streak": state["streak"],
        "beat_count": state["beat_count"],
        "beat_ok_count": state["beat_ok_count"],
        "alert_needed": alert_needed,
        "consecutive_low": consecutive_low,
    }

    if alert_needed:
        logger.warning("健康度告警: 连续 %d 次低于 %d 分", consecutive_low, threshold)

    return result


def get_stats() -> dict:
    """获取健康度统计信息"""
    state = _load_state()
    history = state.get("health_history", [])

    if not history:
        return {
            "current": 0,
            "average": 0,
            "min": 0,
            "max": 0,
            "streak": 0,
            "total_beats": 0,
        }

    scores = [h["score"] for h in history]
    recent = scores[-10:] if len(scores) >= 10 else scores

    return {
        "current": scores[-1] if scores else 0,
        "average": round(sum(recent) / len(recent), 1),
        "min": min(scores),
        "max": max(scores),
        "streak": state.get("streak", 0),
        "total_beats": state.get("beat_count", 0),
    }


path: tools/heartbeat_run.py
content: #!/usr/bin/env python3
"""
heartbeat-manager 主入口

用法:
    python heartbeat_run.py beat     # 执行一次心跳
    python heartbeat_run.py reset    # 执行每日重置（0点日报）
    python heartbeat_run.py weekly   # 生成并发送周报
    python heartbeat_run.py status   # 查看当前状态
"""

import sys
import os
import logging
import logging.handlers
import fcntl
import time
from datetime import datetime
from pathlib import Path

# 确保项目根目录在 Python 路径中
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))

LOG_DIR = PROJECT_ROOT / "logs"
LOCK_FILE = PROJECT_ROOT / ".heartbeat.lock"

HEARTBEAT_INTERVAL = 1800   # 期望心跳间隔：30 分钟
CHECK_TOLERANCE    = 90     # 容差：90 秒（防止边界跳过）
MARKER_FILE        = PROJECT_ROOT / "workspace" / ".last_heartbeat"


def setup_logging():
    """配置日志：控制台 + 文件轮转（保留7天）"""
    LOG_DIR.mkdir(exist_ok=True)

    root_logger = logging.getLogger("heartbeat")
    root_logger.setLevel(logging.INFO)

    # 避免重复添加 handler
    if root_logger.handlers:
        return root_logger

    # 控制台输出
    console = logging.StreamHandler()
    console.setLevel(logging.INFO)
    console.setFormatter(logging.Formatter(
        "[%(asctime)s] %(name)s %(levelname)s: %(message)s",
        datefmt="%H:%M:%S",
    ))
    root_logger.addHandler(console)

    # 文件轮转（按日，保留7天）
    log_file = LOG_DIR / "heartbeat.log"
    file_handler = logging.handlers.TimedRotatingFileHandler(
        log_file, when="midnight", backupCount=7, encoding="utf-8",
    )
    file_handler.setLevel(logging.DEBUG)
    file_handler.setFormatter(logging.Formatter(
        "[%(asctime)s] %(name)s %(levelname)s: %(message)s",
    ))
    root_logger.addHandler(file_handler)

    return root_logger


def acquire_lock():
    """文件锁防并发"""
    try:
        lock_fd = open(LOCK_FILE, "w")
        fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        lock_fd.write(str(os.getpid()))
        lock_fd.flush()
        return lock_fd
    except (IOError, OSError):
        return None


def release_lock(lock_fd):
    """释放文件锁"""
    if lock_fd:
        try:
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            lock_fd.close()
            LOCK_FILE.unlink(missing_ok=True)
        except Exception:
            pass


def should_beat() -> bool:
    """检查是否需要执行心跳（基于标记文件 mtime）"""
    if not MARKER_FILE.exists():
        return True
    elapsed = time.time() - MARKER_FILE.stat().st_mtime
    return elapsed >= (HEARTBEAT_INTERVAL - CHECK_TOLERANCE)


def _mark_daily_task_done(keyword: str):
    """将 daily.md 中包含 keyword 的未完成任务标记为完成"""
    import re
    workspace = Path(__file__).parent.parent / "workspace"
    daily_path = workspace / "daily.md"
    if not daily_path.exists():
        return
    lines = daily_path.read_text(encoding="utf-8").splitlines()
    new_lines = []
    for line in lines:
        if keyword in line and re.match(r"^-\s*\[ \]", line):
            line = line.replace("- [ ]", "- [x]", 1)
        new_lines.append(line)
    tmp = daily_path.with_suffix(".tmp")
    tmp.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
    tmp.rename(daily_path)


def _notify_discord_heartbeat(score, health_info, alerts, upcoming_result, daily_result, todo_result):
    """将心跳状态推送到 Discord #💓-心跳 频道"""
    import subprocess, json as _json
    from pathlib import Path as _Path
    import yaml as _yaml

    cfg_path = _Path(__file__).parent.parent / "config" / "settings.yaml"
    try:
        cfg = _yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
    except Exception:
        cfg = {}

    discord_cfg = cfg.get("discord_notify", {})
    if not discord_cfg.get("enabled", True):
        return

    [REDACTED_SECRET]"bot_token", "")
    channel_id = discord_cfg.get("heartbeat_channel_id", "1476378850819575882")

    # 从 openclaw.json 读取 token（若 settings.yaml 未配置）
    if not [REDACTED_SECRET] = _Path.home() / ".openclaw" / "openclaw.json"
        try:
            oc = _json.loads(oc_cfg_path.read_text(encoding="utf-8"))
            [REDACTED_SECRET]"channels", {}).get("discord", {}).get("token", "")
        except Exception:
            pass

    if not token or not channel_id:
        return

    # 构建状态消息
    now = datetime.now().strftime("%m-%d %H:%M")
    score_val = score if isinstance(score, int) else score.get("score", 0)
    streak = health_info.get("streak", 0)

    # 分数 emoji
    if score_val >= 90:   s_emoji = "🟢"
    elif score_val >= 70: s_emoji = "🟡"
    elif score_val >= 50: s_emoji = "🟠"
    else:                 s_emoji = "🔴"

    # daily 完成情况
    daily_done  = daily_result.get("done", 0) if isinstance(daily_result, dict) else 0
    daily_total = daily_result.get("total", 0) if isinstance(daily_result, dict) else 0

    # todo 情况
    todo_pending  = todo_result.get("pending", 0) if isinstance(todo_result, dict) else 0
    todo_overdue  = todo_result.get("overdue", 0) if isinstance(todo_result, dict) else 0

    # upcoming 紧急事件
    upcoming_urgent = []
    if upcoming_result and isinstance(upcoming_result, dict):
        for ev in upcoming_result.get("events", []):
            if ev.get("urgency") in ("🔴", "🟡"):
                upcoming_urgent.append(f"{ev.get('urgency')} {ev.get('date','')} {ev.get('title','')[:30]}")

    lines = [
        f"**{s_emoji} Eva 心跳** `{now} EST`",
        f"健康度 **{score_val}** 分 · 连续 {streak} 次 ✅",
        f"📋 今日任务 {daily_done}/{daily_total} · 待办 {todo_pending} 条" + (f" ⚠️ 超期 {todo_overdue}" if todo_overdue else ""),
    ]
    if upcoming_urgent:
        lines.append("📅 近期事项: " + " | ".join(upcoming_urgent[:3]))
    if alerts:
        lines.append("⚠️ 告警: " + " | ".join(alerts[:2]))

    content = "\n".join(lines)

    # 发送
    _log = logging.getLogger("heartbeat")
    try:
        subprocess.run([
            "curl", "-s", "-X", "POST",
            f"https://discord.com/api/v10/channels/{channel_id}/messages",
            "-H", f"Authorization: Bot {token}",
            "-H", "Content-Type: application/json",
            "-H", "User-Agent: DiscordBot (https://github.com/discord/discord-api-docs, 10)",
            "-d", _json.dumps({"content": content}),
        ], capture_output=True, timeout=10)
        _log.info("  Discord 心跳已推送 → #💓-心跳")
    except Exception as e:
        _log.warning("  Discord 推送失败: %s", e)


def cmd_beat():
    """
    执行一次心跳

    流程:
    0. 检查 .last_heartbeat 标记文件，距上次 < 30 分钟则静默退出（v1.2.0 watchdog）
    1. 检查 daily.md
    2. 检查 todo.md（含超期告警）
    3. 检查 ongoing.json（含智能超时分析）
    4. 智能超时分析
    5. 检查邮件
    6. 清理已完成 todo
    7. Git 同步
    8. 计算健康度（含 git 结果）
    9. 更新 MASTER.md
    10. 全绿 → HEARTBEAT_OK；有问题 → 告警
    """
    logger = logging.getLogger("heartbeat.beat")

    if not should_beat():
        logger.debug("距上次心跳未到30分钟，跳过本次触发")
        return True  # 静默成功退出
    logger.info("===== 心跳开始 =====")
    start_time = time.time()

    alerts = []
    all_ok = True
    upcoming_result = None  # 初始化，防止后续引用报错

    # 1. 检查 daily.md
    logger.info("[1/8] 检查 daily.md")
    from tools.checker import check_daily
    daily_result = check_daily()
    if daily_result.get("error"):
        alerts.append(f"daily: {daily_result['error']}")

    # 2. 检查 todo.md（含超期告警）
    logger.info("[2/8] 检查 todo.md")
    from tools.checker import check_todo
    todo_result = check_todo()
    if todo_result.get("error"):
        alerts.append(f"todo: {todo_result['error']}")

    # 超期告警
    if todo_result.get("overdue"):
        all_ok = False
        for od in todo_result["overdue"]:
            alerts.append(f"TODO超期: {od['text']} (due:{od['due']})")
        from tools.mail import send_alert
        overdue_texts = "\n".join(
            f"  - {od['text']} (due:{od['due']})" for od in todo_result["overdue"]
        )
        send_alert("TODO 超期告警", f"以下任务已超期:\n{overdue_texts}")

    # 3. 检查 ongoing.json
    logger.info("[3/8] 检查 ongoing.json")
    from tools.checker import check_ongoing
    ongoing_result = check_ongoing()
    if ongoing_result.get("error"):
        alerts.append(f"ongoing: {ongoing_result['error']}")

    # 4. 智能超时分析
    logger.info("[4/8] 智能超时分析")
    from tools.task_analyzer import analyze_all
    analysis = analyze_all()
    if analysis["stuck"]:
        all_ok = False
        for s in analysis["stuck"]:
            alerts.append(f"任务卡死: [{s['task_id']}] {s['title']}")
    for action in analysis.get("actions_taken", []):
        logger.info("  动作: %s", action)

    # 5a. 检查未来事件（最近7天）
    logger.info("[4.5/8] 检查即将发生事件")
    from tools.upcoming_checker import check_upcoming
    upcoming_result = check_upcoming(lookahead_days=7)
    if upcoming_result.get("error"):
        alerts.append(f"upcoming: {upcoming_result['error']}")
    if upcoming_result.get("has_urgent"):
        all_ok = False
        for ev in upcoming_result.get("urgent", []) + upcoming_result.get("overdue", []):
            alerts.append(
                f"⚠️ 紧急事件: {ev['date_str']} {ev['description']}"
                + (f" @{ev['time']}" if ev.get("time") else "")
            )

    # 4.7. 浏览器同步（若 Chrome 扩展在线，自动抓取 Canvas + FSP → upcoming.md）
    logger.info("[4.7/8] 检测浏览器在线状态")
    browser_available = False
    try:
        import urllib.request
        urllib.request.urlopen("http://127.0.0.1:18792/", timeout=2)
        browser_available = True
    except Exception:
        pass

    if browser_available:
        logger.info("  浏览器在线，尝试同步 Canvas + FSP")
        try:
            from tools.site_monitor import run_sync
            sync_result = run_sync()
            added = sync_result.get("added", 0)
            updated = sync_result.get("updated", 0)
            removed = sync_result.get("removed", 0)
            sync_errors = sync_result.get("errors", [])
            logger.info("  同步完成: +%d ~%d -%d", added, updated, removed)
            if sync_errors:
                for e in sync_errors:
                    logger.warning("  同步错误: %s", e)
            else:
                # 标记 daily.md 中的 📡 同步任务为完成
                _mark_daily_task_done("📡")
                logger.info("  已标记 Canvas+FSP 同步任务完成")
        except Exception as e:
            logger.warning("  浏览器同步失败（非致命）: %s", e)
    else:
        logger.info("  浏览器未在线，跳过同步（打开 Chrome 并 attach 扩展后自动执行）")

    # 5. 检查邮件
    logger.info("[5/8] 检查邮件")
    from tools.mail import check_mail
    mail_result = check_mail()
    if mail_result.get("error"):
        alerts.append(f"mail: {mail_result['error']}")
        # 邮件失败不算致命错误，降级继续

    # 6. 清理已完成 todo
    logger.info("[6/8] 清理已完成 todo")
    from tools.checker import clean_done_todos
    cleaned = clean_done_todos()
    if cleaned:
        logger.info("清理了 %d 条已完成 todo", cleaned)

    # 7. Git 同步（在计算健康度前执行，以获取真实 git 结果）
    logger.info("[7/8] Git 同步")
    from tools.git_ops import git_sync
    git_result = git_sync()
    if git_result.get("error"):
        alerts.append(f"git: {git_result['error']}")

    # 8. 计算健康度（使用真实的 git_result）
    logger.info("[8/8] 计算健康度")
    from tools.health_score import calculate_score, record_score
    score = calculate_score(daily_result, todo_result, ongoing_result, mail_result, git_result)

    health_info = record_score(score)
    logger.info("  健康度: %d 分 (streak:%d)", score, health_info["streak"])

    # 健康度告警
    if health_info["alert_needed"]:
        all_ok = False
        alerts.append(
            f"健康度告警: 连续 {health_info['consecutive_low']} 次低于阈值"
        )
        from tools.mail import send_alert
        send_alert(
            "健康度持续低分",
            f"连续 {health_info['consecutive_low']} 次健康度低于 60 分\n"
            f"当前分数: {score}",
        )

    # 9. 更新 MASTER.md
    logger.info("[+] 更新 MASTER.md")
    from tools.renderer import render_master, write_master
    master_content = render_master(
        daily_result, todo_result, ongoing_result,
        mail_result, health_info, alerts,
        upcoming_result=upcoming_result,
    )
    write_master(master_content)

    # 10. Discord 心跳状态推送
    _notify_discord_heartbeat(score, health_info, alerts, upcoming_result, daily_result, todo_result)

    # 更新标记文件
    MARKER_FILE.touch()

    # 最终状态
    elapsed = time.time() - start_time
    if all_ok and not alerts:
        logger.info("===== HEARTBEAT_OK (%.1fs) =====", elapsed)
        return True
    else:
        logger.warning(
            "===== 心跳完成（有告警: %d 条, %.1fs） =====",
            len(alerts), elapsed,
        )
        for a in alerts:
            logger.warning("  告警: %s", a)
        return False


def cmd_reset():
    """执行每日重置 + 日报"""
    logger = logging.getLogger("heartbeat.reset")
    logger.info("===== 每日重置开始 =====")

    from tools.daily_reset import reset_daily
    result = reset_daily()

    if result.get("error"):
        logger.error("每日重置异常: %s", result["error"])
    else:
        logger.info(
            "每日重置完成: 日报=%s, daily重置=%s, 清理=%d",
            "已发送" if result["report_sent"] else "未发送",
            "成功" if result["daily_reset"] else "失败",
            result["cleanup_count"],
        )

    # 重置后执行一次心跳
    cmd_beat()


def cmd_weekly():
    """生成并发送周报"""
    logger = logging.getLogger("heartbeat.weekly")
    logger.info("===== 周报生成 =====")

    from tools.weekly_report import send_weekly_report
    sent = send_weekly_report()

    if sent:
        logger.info("周报发送成功")
    else:
        logger.error("周报发送失败")


def cmd_status():
    """输出当前状态摘要"""
    from tools.health_score import get_stats
    from tools.checker import check_daily, check_todo, check_ongoing

    stats = get_stats()
    daily = check_daily()
    todo = check_todo()
    ongoing = check_ongoing()

    now = datetime.now().strftime("%Y-%m-%d %H:%M")
    print(f"\n📊 EVA Heartbeat Status | {now}")
    print("=" * 40)
    print(f"  健康度: {stats['current']} (avg:{stats['average']})")
    print(f"  连续OK: {stats['streak']} | 总心跳: {stats['total_beats']}")
    print(f"  Daily: {daily['done']}/{daily['total']}")
    print(f"  Todo: {todo['done']}/{todo['total']} (超期:{len(todo.get('overdue', []))})")
    print(f"  Ongoing: {ongoing['total']} (状态:{ongoing['by_status']})")
    print()


def main():
    """主入口"""
    setup_logging()
    logger = logging.getLogger("heartbeat")

    # 解析命令
    cmd = sys.argv[1] if len(sys.argv) > 1 else "beat"
    cmd = cmd.lower().strip()

    if cmd == "status":
        # status 不需要锁
        cmd_status()
        return

    # 获取文件锁
    lock_fd = acquire_lock()
    if not lock_fd:
        logger.error("无法获取锁，可能有另一个实例在运行")
        sys.exit(1)

    try:
        if cmd == "beat":
            ok = cmd_beat()
            sys.exit(0 if ok else 1)
        elif cmd == "reset":
            cmd_reset()
        elif cmd == "weekly":
            cmd_weekly()
        else:
            print(f"未知命令: {cmd}")
            print("可用命令: beat, reset, weekly, status")
            sys.exit(2)
    except Exception as e:
        logger.exception("执行异常: %s", e)
        # 单步失败不阻断——尝试发送告警
        try:
            from tools.mail import send_alert
            send_alert("心跳异常", f"命令 {cmd} 执行异常:\n{e}")
        except Exception:
            pass
        sys.exit(1)
    finally:
        release_lock(lock_fd)


if __name__ == "__main__":
    main()


path: tools/mail.py
content: #!/usr/bin/env python3
"""
邮件收发模块：IMAP 读取 + SMTP 发送

凭证来源：config/.env（EMAIL_SENDER / EMAIL_APP_PASSWORD / EMAIL_RECIPIENTS）
行为开关：config/settings.yaml → email.enabled（false 时所有邮件功能跳过）
"""

import smtplib
import imaplib
import email
import email.utils
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import decode_header
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional

logger = logging.getLogger("heartbeat.mail")

# 未配置时的占位返回值
_MAIL_DISABLED = {
    "unread_count": 0, "flagged_count": 0, "flagged_from": [],
    "high_priority": [], "last_check": "--:--", "error": None, "disabled": True
}


def _load_config() -> dict:
    import yaml
    p = Path(__file__).parent.parent / "config" / "settings.yaml"
    with open(p, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)


def _is_email_enabled() -> bool:
    """检查 settings.yaml 中 email.enabled 开关"""
    try:
        return _load_config().get("email", {}).get("enabled", True)
    except Exception:
        return False


def _load_credentials() -> tuple[str, str, list[str]]:
    """
    从 config/.env 读取邮件凭证。

    返回: (sender, app_password, recipients_list)
    若 .env 不存在或未配置，抛出 RuntimeError 并给出引导说明。
    """
    from dotenv import dotenv_values
    env_path = Path(__file__).parent.parent / "config" / ".env"

    if not env_path.exists():
        raise RuntimeError(
            "邮件功能未配置。\n"
            "请按以下步骤配置：\n"
            "  1. cp config/.env.example config/.env\n"
            "  2. 编辑 config/.env，填入 EMAIL_SENDER / EMAIL_APP_PASSWORD / EMAIL_RECIPIENTS\n"
            "  3. 获取 Gmail App Password：https://myaccount.google.com/apppasswords"
        )

    values = dotenv_values(env_path)
    sender   = values.get("EMAIL_SENDER", "").strip()
    [REDACTED_SECRET]"EMAIL_APP_PASSWORD", "").strip()
    raw_rcpt = values.get("EMAIL_RECIPIENTS", "").strip()

    missing = []
    if not sender:   missing.append("EMAIL_SENDER")
    if not password or password == "xxxx xxxx xxxx xxxx": missing.append("EMAIL_APP_PASSWORD")
    if not raw_rcpt: missing.append("EMAIL_RECIPIENTS")

    if missing:
        raise RuntimeError(
            f"config/.env 中以下字段未填写：{', '.join(missing)}\n"
            "请参考 config/.env.example 完成配置。"
        )

    recipients = [r.strip() for r in raw_rcpt.split(",") if r.strip()]
    return sender, password, recipients


def _decode_str(value: str) -> str:
    if not value:
        return ""
    parts = decode_header(value)
    result = []
    for part, charset in parts:
        if isinstance(part, bytes):
            result.append(part.decode(charset or "utf-8", errors="replace"))
        else:
            result.append(part)
    return "".join(result)


# ──────────────────────────────────────────────────────────────────────────────
# 公开接口
# ──────────────────────────────────────────────────────────────────────────────

def check_mail(since_hours: int = 1) -> dict:
    """
    检查邮箱未读/标记/高优先级邮件。

    若邮件功能未启用或未配置，静默返回空结果（不报错）。
    """
    if not _is_email_enabled():
        logger.debug("邮件功能已禁用（settings.yaml email.enabled=false）")
        return {**_MAIL_DISABLED, "disabled": True}

    result = {
        "unread_count": 0, "flagged_count": 0, "flagged_from": [],
        "high_priority": [], "last_check": datetime.now().strftime("%H:%M"),
        "error": None, "disabled": False
    }

    try:
        sender, password, _ = _load_credentials()
    except RuntimeError as e:
        result["error"] = str(e)
        logger.warning("邮件未配置，跳过邮件检查。\n%s", e)
        return result

    config = _load_config()
    email_cfg = config.get("email", {})
    conn = None

    try:
        conn = imaplib.IMAP4_SSL(
            email_cfg.get("imap_host", "imap.gmail.com"),
            email_cfg.get("imap_port", 993),
            timeout=30,
        )
        conn.login(sender, password)
        conn.select("INBOX", readonly=True)

        # 未读数量
        _, data = conn.search(None, "UNSEEN")
        if data[0]:
            result["unread_count"] = len(data[0].split())

        # 标记邮件
        since_date = (datetime.now() - timedelta(hours=since_hours)).strftime("%d-%b-%Y")
        _, data = conn.search(None, f"FLAGGED SINCE {since_date}")
        if data[0]:
            flagged_ids = data[0].split()
            result["flagged_count"] = len(flagged_ids)
            for mid in flagged_ids[-5:]:
                _, msg_data = conn.fetch(mid, "(RFC822.HEADER)")
                if msg_data and msg_data[0]:
                    msg = email.message_from_bytes(msg_data[0][1])
                    result["flagged_from"].append(_decode_str(msg.get("From", "")))

        # 高优先级发件人
        hp_senders = email_cfg.get("high_priority_senders", [])
        for keyword in hp_senders:
            _, data = conn.search(None, f'FROM "{keyword}" SINCE {since_date}')
            if data[0]:
                for mid in data[0].split()[-3:]:
                    _, msg_data = conn.fetch(mid, "(RFC822.HEADER)")
                    if msg_data and msg_data[0]:
                        msg = email.message_from_bytes(msg_data[0][1])
                        result["high_priority"].append({
                            "from":    _decode_str(msg.get("From", "")),
                            "subject": _decode_str(msg.get("Subject", "")),
                            "date":    msg.get("Date", ""),
                        })

        logger.info("邮件检查完成: 未读=%d, 标记=%d", result["unread_count"], result["flagged_count"])

    except (imaplib.IMAP4.error, OSError, TimeoutError) as e:
        result["error"] = f"IMAP 连接失败: {e}"
        logger.error("邮件检查异常: %s", e)
    except Exception as e:
        result["error"] = f"邮件检查未知错误: {e}"
        logger.error("邮件检查未知异常: %s", e)
    finally:
        if conn:
            try: conn.logout()
            except Exception: pass

    return result


def send_mail(
    subject: str,
    body: str,
    recipients: Optional[list] = None,
    html: bool = False,
) -> bool:
    """
    发送邮件。

    若邮件功能未启用或未配置，静默返回 False（不报错、不阻断心跳）。
    """
    if not _is_email_enabled():
        logger.debug("邮件功能已禁用，跳过发送：%s", subject)
        return False

    try:
        sender, password, default_recipients = _load_credentials()
    except RuntimeError as e:
        logger.warning("邮件未配置，跳过发送「%s」。\n%s", subject, e)
        return False

    to_list = recipients if recipients is not None else default_recipients
    config  = _load_config()
    email_cfg = config.get("email", {})

    try:
        msg = MIMEMultipart("alternative")
        msg["From"]    = sender
        msg["To"]      = ", ".join(to_list)
        msg["Subject"] = subject
        msg["Date"]    = email.utils.formatdate(localtime=True)
        msg.attach(MIMEText(body, "html" if html else "plain", "utf-8"))

        with smtplib.SMTP(
            email_cfg.get("smtp_host", "smtp.gmail.com"),
            email_cfg.get("smtp_port", 587),
            timeout=30,
        ) as server:
            server.starttls()
            server.login(sender, password)
            server.send_message(msg)

        logger.info("邮件发送成功: %s -> %s", subject, to_list)
        return True

    except (smtplib.SMTPException, OSError, TimeoutError) as e:
        logger.error("邮件发送失败: %s", e)
        return False
    except Exception as e:
        logger.error("邮件发送未知错误: %s", e)
        return False


def send_alert(title: str, details: str) -> bool:
    """发送告警邮件"""
    body = (
        f"⚠️ 心跳告警\n{'='*40}\n"
        f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
        f"告警: {title}\n{'='*40}\n\n{details}\n"
    )
    return send_mail(f"[ALERT] EVA Heartbeat: {title}", body)


path: tools/renderer.py
content: #!/usr/bin/env python3
"""MASTER.md 渲染模块：将各项检查结果渲染为 token 极简格式"""

import logging
from datetime import datetime
from pathlib import Path

logger = logging.getLogger("heartbeat.renderer")

WORKSPACE = Path(__file__).parent.parent / "workspace"


def render_master(
    daily_result: dict,
    todo_result: dict,
    ongoing_result: dict,
    mail_result: dict,
    health_info: dict,
    alerts: list = None,
    upcoming_result: dict = None,
) -> str:
    """
    渲染 MASTER.md 内容

    参数:
        daily_result: checker.check_daily() 的结果
        todo_result: checker.check_todo() 的结果
        ongoing_result: checker.check_ongoing() 的结果
        mail_result: mail.check_mail() 的结果
        health_info: health_score.record_score() 的结果
        alerts: 告警信息列表

    返回:
        MASTER.md 的完整内容
    """
    now = datetime.now()
    lines = []

    # 标题
    lines.append(f"# EVA MASTER | {now.strftime('%Y-%m-%d %H:%M')}")
    lines.append("")

    # SYS 段
    beat_count = health_info.get("beat_count", 0)
    beat_ok = health_info.get("beat_ok_count", 0)
    streak = health_info.get("streak", 0)
    score = health_info.get("score", 0)
    last = now.strftime("%H:%M")
    lines.append("## SYS")
    lines.append(f"beat:{beat_ok}/{beat_count}ok | streak:{streak} | score:{score} | last:{last}")
    lines.append("")

    # DAILY 段
    d_done = daily_result.get("done", 0)
    d_total = daily_result.get("total", 0)
    lines.append(f"## DAILY [{d_done}/{d_total}]")
    if daily_result.get("error"):
        lines.append(f"（{daily_result['error']}）")
    else:
        for item in daily_result.get("items", []):
            mark = "x" if item["done"] else " "
            lines.append(f"- [{mark}] {item['text']}")
    lines.append("")

    # TODO 段
    t_done = todo_result.get("done", 0)
    t_total = todo_result.get("total", 0)
    lines.append(f"## TODO [{t_done}/{t_total}]")
    if todo_result.get("error"):
        lines.append(f"（{todo_result['error']}）")
    else:
        for item in todo_result.get("items", []):
            mark = "x" if item["done"] else " "
            lines.append(f"- [{mark}] {item['text']}")
        if todo_result.get("overdue"):
            lines.append("")
            lines.append("**超期:**")
            for od in todo_result["overdue"]:
                lines.append(f"- ⏰ {od['text']} (due:{od['due']})")
    lines.append("")

    # ONGOING 段
    lines.append("## ONGOING")
    if ongoing_result.get("error"):
        lines.append(f"（{ongoing_result['error']}）")
    else:
        tasks = ongoing_result.get("tasks", [])
        if tasks:
            lines.append("|id|task|st|prio|prog|eta|")
            lines.append("|--|--|--|--|--|--|")
            for t in tasks:
                tid = t.get("id", "?")
                title = t.get("title", "?")
                st = t.get("status", "?")
                prio = t.get("priority", "—")
                prog = t.get("progress", "—")
                if isinstance(prog, (int, float)) and prog > 0:
                    prog = f"{prog}%"
                else:
                    prog = "—"
                eta = t.get("eta", "—")
                lines.append(f"|{tid}|{title}|{st}|{prio}|{prog}|{eta}|")
        else:
            lines.append("（无活跃任务）")
    lines.append("")

    # UPCOMING 段（最近7天内的事件）
    lines.append("## UPCOMING 7D")
    if upcoming_result is not None:
        from tools.upcoming_checker import format_upcoming_for_master
        upcoming_lines = format_upcoming_for_master(upcoming_result)
        lines.extend(upcoming_lines)
    else:
        lines.append("（未启用）")
    lines.append("")

    # MAIL 段
    lines.append("## MAIL")
    if mail_result.get("error"):
        lines.append(f"error:{mail_result['error']}")
    else:
        unread = mail_result.get("unread_count", 0)
        flagged = mail_result.get("flagged_count", 0)
        flagged_from = mail_result.get("flagged_from", [])
        last_check = mail_result.get("last_check", "—")

        flag_detail = ""
        if flagged > 0 and flagged_from:
            # 提取发件人关键词
            senders = []
            for f in flagged_from[:3]:
                # 取邮件地址中 @ 前面的部分
                if "<" in f:
                    addr = f.split("<")[1].rstrip(">")
                else:
                    addr = f
                name = addr.split("@")[0] if "@" in addr else addr
                senders.append(name)
            flag_detail = f"({','.join(senders)})"

        lines.append(f"unread:{unread} flag:{flagged}{flag_detail} last:{last_check}")
    lines.append("")

    # ALERTS 段
    lines.append("## ALERTS")
    if alerts:
        for alert in alerts:
            lines.append(f"- {alert}")
    else:
        lines.append("（无）")
    lines.append("")

    return "\n".join(lines)


def write_master(content: str):
    """原子写入 MASTER.md"""
    path = WORKSPACE / "MASTER.md"
    tmp_path = path.with_suffix(".tmp")
    tmp_path.write_text(content, encoding="utf-8")
    tmp_path.rename(path)
    logger.info("MASTER.md 已更新")


path: tools/task_analyzer.py
content: #!/usr/bin/env python3
"""智能超时/卡死分析模块"""

import json
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional

logger = logging.getLogger("heartbeat.analyzer")

WORKSPACE = Path(__file__).parent.parent / "workspace"


def _load_config() -> dict:
    """加载超时分析配置"""
    import yaml
    config_path = Path(__file__).parent.parent / "config" / "settings.yaml"
    with open(config_path, "r", encoding="utf-8") as f:
        cfg = yaml.safe_load(f)
    return cfg.get("timeout", {})


def _load_ongoing() -> list:
    """加载 ongoing.json"""
    path = WORKSPACE / "ongoing.json"
    if not path.exists():
        return []
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        return data if isinstance(data, list) else data.get("tasks", [])
    except (json.JSONDecodeError, Exception) as e:
        logger.error("加载 ongoing.json 失败: %s", e)
        return []


def _save_ongoing(tasks: list):
    """保存 ongoing.json（原子写入，保持 {"tasks":[...]} 格式）"""
    path = WORKSPACE / "ongoing.json"
    tmp_path = path.with_suffix(".tmp")
    tmp_path.write_text(
        json.dumps({"tasks": tasks}, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    tmp_path.rename(path)


def _get_recent_history(task: dict, n: int = 3) -> list:
    """获取任务最近 N 条历史记录"""
    history = task.get("history", [])
    return history[-n:] if history else []


def _calc_progress_delta(history: list) -> float:
    """计算历史记录中 progress 的变化量"""
    if len(history) < 2:
        return 0.0
    progresses = [h.get("progress", 0) for h in history]
    return progresses[-1] - progresses[0]


def _context_changed(history: list) -> bool:
    """检查历史记录中 context 是否有变化"""
    if len(history) < 2:
        return False
    contexts = [h.get("context", "") for h in history]
    return len(set(contexts)) > 1


def analyze_task(task: dict, config: Optional[dict] = None) -> dict:
    """
    分析单个任务是否卡死

    返回:
        {
            "task_id": str,
            "title": str,
            "status": str,          # "normal" | "slow" | "stuck"
            "reason": str,
            "action": str,          # "none" | "log" | "alert"
            "details": str
        }
    """
    if config is None:
        config = _load_config()

    task_id = task.get("id", "?")
    title = task.get("title", "未命名")
    result = {
        "task_id": task_id,
        "title": title,
        "status": "normal",
        "reason": "",
        "action": "none",
        "details": "",
    }

    # 只分析 WIP 和 WAIT 状态的任务
    task_status = task.get("status", "IDLE")
    if task_status not in ("WIP", "WAIT"):
        return result

    now = datetime.now()
    stale_beats = config.get("stale_beats", 3)
    progress_threshold = config.get("progress_threshold", 5)
    eta_grace_hours = config.get("eta_grace_hours", 2)

    # 获取最近历史
    history = _get_recent_history(task, stale_beats)
    progress_delta = _calc_progress_delta(history)
    context_changed = _context_changed(history)

    # 检查是否超过 eta
    eta_overdue = False
    eta = task.get("eta", "")
    if eta and eta != "持续":
        try:
            if len(eta) <= 5:
                eta_date = datetime.strptime(eta, "%m-%d").replace(year=now.year)
            else:
                eta_date = datetime.strptime(eta, "%Y-%m-%d")
            grace = timedelta(hours=eta_grace_hours)
            if now > eta_date + grace:
                eta_overdue = True
        except ValueError:
            pass

    # 检查心跳无进展
    beats_stale = (
        len(history) >= stale_beats
        and abs(progress_delta) < progress_threshold
        and not context_changed
    )

    # 判断逻辑
    if beats_stale and eta_overdue:
        # 完全卡死：超期且无进展
        result["status"] = "stuck"
        result["reason"] = f"超过 ETA 且连续 {stale_beats} 个心跳无进展"
        result["action"] = "alert"
        result["details"] = (
            f"任务 [{task_id}] {title} 疑似卡死\n"
            f"  状态: {task_status}\n"
            f"  ETA: {eta}（已超期）\n"
            f"  最近进度变化: {progress_delta:.1f}%\n"
            f"  context 变化: {'有' if context_changed else '无'}\n"
        )
    elif beats_stale:
        # 无进展但未超期
        result["status"] = "stuck"
        result["reason"] = f"连续 {stale_beats} 个心跳无进展（无 context 变化）"
        result["action"] = "alert"
        result["details"] = (
            f"任务 [{task_id}] {title} 疑似停滞\n"
            f"  最近进度变化: {progress_delta:.1f}%\n"
        )
    elif eta_overdue and progress_delta > 0:
        # 超期但有进展——正常推进只是慢
        result["status"] = "slow"
        result["reason"] = "超过 ETA 但仍有进展"
        result["action"] = "log"
        result["details"] = (
            f"任务 [{task_id}] {title} 进展缓慢\n"
            f"  进度变化: +{progress_delta:.1f}%\n"
        )
    elif eta_overdue:
        # 超期且历史记录不足以判断
        result["status"] = "slow"
        result["reason"] = "超过 ETA，历史记录不足"
        result["action"] = "log"
        result["details"] = f"任务 [{task_id}] {title} 超过 ETA，待观察"

    return result


def analyze_all() -> dict:
    """
    分析所有 ongoing 任务

    返回:
        {
            "total_analyzed": int,
            "stuck": [dict],        # 卡死的任务分析结果
            "slow": [dict],         # 缓慢的任务分析结果
            "normal": int,          # 正常任务数
            "actions_taken": [str], # 执行的动作描述
        }
    """
    config = _load_config()
    tasks = _load_ongoing()

    result = {
        "total_analyzed": 0,
        "stuck": [],
        "slow": [],
        "normal": 0,
        "actions_taken": [],
    }

    for task in tasks:
        if task.get("status") not in ("WIP", "WAIT"):
            continue

        result["total_analyzed"] += 1
        analysis = analyze_task(task, config)

        if analysis["status"] == "stuck":
            result["stuck"].append(analysis)
        elif analysis["status"] == "slow":
            result["slow"].append(analysis)
        else:
            result["normal"] += 1

    # 对卡死任务执行动作
    for stuck in result["stuck"]:
        task_id = stuck["task_id"]

        # 在 ongoing.json 中标记 BLOCK
        for task in tasks:
            if str(task.get("id")) == str(task_id) and task.get("status") != "BLOCK":
                task["status"] = "BLOCK"
                task["blocked_by"] = stuck["reason"]
                task.setdefault("history", []).append({
                    "time": datetime.now().isoformat(),
                    "event": "auto_blocked",
                    "reason": stuck["reason"],
                    "progress": task.get("progress", 0),
                    "context": task.get("context", ""),
                })
                result["actions_taken"].append(f"任务 {task_id} 标记为 BLOCK")

        # 发送告警邮件
        from tools.mail import send_alert
        sent = send_alert(
            f"任务卡死: {stuck['title']}",
            stuck["details"],
        )
        if sent:
            result["actions_taken"].append(f"任务 {task_id} 告警邮件已发送")
        else:
            result["actions_taken"].append(f"任务 {task_id} 告警邮件发送失败")

    # 保存更新后的 ongoing.json
    if result["stuck"]:
        _save_ongoing(tasks)

    logger.info(
        "任务分析完成: 分析=%d, 卡死=%d, 缓慢=%d",
        result["total_analyzed"], len(result["stuck"]), len(result["slow"]),
    )
    return result


def record_beat(task_id: str, progress: int = None, context: str = None):
    """
    为指定任务记录一次心跳历史

    参数:
        task_id: 任务 ID
        progress: 当前进度（0-100）
        context: 当前上下文描述
    """
    tasks = _load_ongoing()
    for task in tasks:
        if str(task.get("id")) == str(task_id):
            entry = {
                "time": datetime.now().isoformat(),
                "event": "heartbeat",
                "progress": progress if progress is not None else task.get("progress", 0),
                "context": context if context is not None else task.get("context", ""),
            }
            task.setdefault("history", []).append(entry)
            task["updated"] = datetime.now().strftime("%Y-%m-%d %H:%M")
            if progress is not None:
                task["progress"] = progress
            if context is not None:
                task["context"] = context
            break

    _save_ongoing(tasks)


path: workspace/MASTER.md
content: # EVA MASTER | 2026-02-25 20:00

## SYS
beat:19/24ok | streak:19 | score:79 | last:20:00

## DAILY [1/6]
- [ ] 晨间邮件检查
- [ ] 更新记忆库
- [ ] 工作区整理
- [ ] 系统状态确认
- [ ] 晚间回顾总结
- [x] 📡 同步 Canvas + FSP 日程（打开浏览器后心跳自动抓取）

## TODO [0/0]

## ONGOING
|id|task|st|prio|prog|eta|
|--|--|--|--|--|--|
|01|每日新闻汇总|WIP|P0|—|持续|
|02|飞行简报系统|WAIT|P1|—|02-28|

## UPCOMING 7D
- 🔴 2026-02-26(明天) @08:15-11:15 AI Essentials for Business — 课程 [AI商业基础] (canvas)
- 🔴 2026-02-26(明天) @14:00-17:00 飞行训练 KGAI N586MA Piper Archer III [飞行] (fsp)
- 🟡 2026-02-27(2天后) @08:15-11:15 Advanced Database Management — 课程 [高级数据库] (canvas)
- 🟡 2026-02-27(2天后) @14:30-17:30 Internet Systems — 课程 [互联网系统] (canvas)
- 🔵 2026-03-02(5天后) @08:30-11:30 飞行训练 KGAI N249WF Piper Archer II [飞行] (fsp)
- 🔵 2026-03-02(5天后) @14:30-17:30 Responsible AI — 课程 [负责任AI] (canvas)
- 🔵 2026-03-02(5天后) Group Canvas Assignment #2 [负责任AI] (canvas)
- 🔵 2026-03-03(6天后) @12:30-15:30 飞行训练 KGAI N251WF PA-28-181 [飞行] (fsp)
- 🔵 2026-03-04(7天后) @12:30-15:30 飞行训练 KGAI N21803 PA-28-181 [飞行] (fsp)

## MAIL
unread:0 flag:0 last:20:00

## ALERTS
- ⚠️ 紧急事件: 2026-02-26 AI Essentials for Business — 课程 @08:15-11:15
- ⚠️ 紧急事件: 2026-02-26 飞行训练 KGAI N586MA Piper Archer III @14:00-17:00


path: workspace/daily.md
content: # Daily Tasks | 2026-02-25

- [ ] 晨间邮件检查
- [ ] 更新记忆库
- [ ] 工作区整理
- [ ] 系统状态确认
- [ ] 晚间回顾总结
- [x] 📡 同步 Canvas + FSP 日程（打开浏览器后心跳自动抓取）


path: workspace/ongoing.json
content: [
  {
    "id": "01",
    "title": "每日新闻汇总",
    "status": "WIP",
    "priority": "P0",
    "created": "2026-02-25",
    "updated": "2026-02-25 00:00",
    "eta": "持续",
    "progress": 0,
    "context": "初始化",
    "blocked_by": null,
    "tags": ["daily", "news"],
    "history": []
  },
  {
    "id": "02",
    "title": "飞行简报系统",
    "status": "WAIT",
    "priority": "P1",
    "created": "2026-02-25",
    "updated": "2026-02-25 00:00",
    "eta": "02-28",
    "progress": 0,
    "context": "等待需求确认",
    "blocked_by": null,
    "tags": ["project"],
    "history": []
  }
]


path: workspace/todo.md
content: # Todo



Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)