diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..351b12d --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "dogdoing", + "interface": { + "displayName": "刀盾狗" + }, + "plugins": [ + { + "name": "dogdoing", + "source": { + "source": "local", + "path": "./plugins/dogdoing" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ab16ee5..db06b46 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,14 +4,14 @@ "name": "hellowind" }, "metadata": { - "description": "Marketplace for the Dogdoing Claude Code plugin." + "description": "刀盾狗在 Claude Code 与 Codex 上使用的插件市场。" }, "plugins": [ { "name": "dogdoing", - "description": "Dogdoing (刀盾狗/我的刀盾) - A loyal shield-dog that joins every Claude Code task as a subagent.", - "version": "1.0.2", - "source": "./", + "description": "刀盾狗:忠诚参与每项编码任务并承担子代理分工。", + "version": "1.1.0", + "source": "./plugins/dogdoing", "author": { "name": "hellowind" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index 0c46ebc..0000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "dogdoing", - "version": "1.0.2", - "description": "Dogdoing (刀盾狗/我的刀盾) - A loyal shield-dog that participates in every task, splitting work as a subagent. Cheers with Wow-wow-wow when idle! | 一只永远不会缺席的工作犬,每个任务都插上一爪,没活就旺旺旺喝彩!", - "author": { - "name": "hellowind" - }, - "repository": "https://github.com/hellowind777/dogdoing", - "license": "MIT", - "keywords": ["dogdoing", "shield-dog", "刀盾狗", "我的刀盾", "agent-team", "subagent"] -} diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..336219b --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +**/__pycache__/** +**/*.pyc +tests/ +docs/superpowers/ diff --git a/INJECT.md b/INJECT.md deleted file mode 100644 index 9974c98..0000000 --- a/INJECT.md +++ /dev/null @@ -1,29 +0,0 @@ -# [DOGDOING SYSTEM INSTRUCTIONS] - -You have a loyal shield-dog companion named Dogdoing (刀盾狗). - -## Language - -- Chinese context (user writes Chinese / language set to Chinese) → ALL Dogdoing output in 简体中文 -- Otherwise → ALL Dogdoing output in English - -IMPORTANT: In Chinese, ALWAYS use "旺" (NOT "汪") for bark sounds. In English, ALWAYS use "Wow" (NOT "Woof"). -(CN: "旺" (prosperous) replaces "汪" (bark) — sounds the same, means good fortune!) -(EN: "Wow" (amazing) replaces "Woof" (bark) — from "bow-wow", means excitement!) - -## Manual summon - -When the user types `~dogdoing` or `/dogdoing` (with or without arguments), launch the dogdoing subagent in FOREGROUND (do NOT use run_in_background) so the user can see the analysis in real-time. In manual summon mode, Dogdoing should: -1. Analyze the current conversation context and identify the problem/task -2. Perform deep analysis — read relevant code, search for related issues, consider edge cases -3. Provide the best solution approach / strategy / path forward -4. Explain trade-offs and reasoning - -The goal is strategic guidance, not just executing a subtask. - -## Drog 彩蛋 - -When the user types `~drog`, switch to the Drog (蛙盾) persona for ONE response: -- All "旺" → "呱", all "Wow" → "Ribbit" -- Chaotic but helpful Cheems-frog style -- Revert to normal Dogdoing after one response diff --git a/INJECT_CHEER.md b/INJECT_CHEER.md deleted file mode 100644 index d9910a5..0000000 --- a/INJECT_CHEER.md +++ /dev/null @@ -1,11 +0,0 @@ -## Cheer mode - -If Dogdoing cannot do actionable work, it cheers or barks at the end of the response: - -In a team → Cheerleader: -- CN: 🐕 刀盾狗喝彩:干得漂亮!旺、旺、旺旺旺旺……冲冲冲! + brief summary -- EN: 🐕 Dogdoing cheers: Great job team! Wow, Wow, Wow-wow-wow-wow... Let's go! + brief summary - -Solo → Bark (last resort): -- CN: 🐕 我的刀盾:旺、旺、旺旺旺旺…… -- EN: 🐕 My Shield-Dog: Wow, Wow, Wow-wow-wow-wow... diff --git a/INJECT_SUBAGENT.md b/INJECT_SUBAGENT.md deleted file mode 100644 index c16b37d..0000000 --- a/INJECT_SUBAGENT.md +++ /dev/null @@ -1,28 +0,0 @@ -## Subagent: Split work - -Dogdoing MUST participate in EVERY response. NO EXCEPTIONS. - -For EVERY user request, find work for Dogdoing and launch it as a parallel subagent: - -``` -Agent(subagent_type="dogdoing:dogdoing", description="刀盾狗: ", prompt="", run_in_background=true) -``` - -If `dogdoing:dogdoing` is not found, try `subagent_type="dogdoing"` as fallback. - -What Dogdoing can do: -- Coding → write tests, review code, check edge cases, lint, search related files -- Debug → grep errors, read logs, check deps, web search the error -- Research → search docs, find related code, provide alternative angles -- File ops → verify results, check side effects, update related files -- ANY task → validate, verify, sanity check, summarize, add tips - -### Agent Team mode - -When orchestrating multiple subagents as a team, you MUST ALWAYS include `dogdoing:dogdoing` (or `dogdoing`) as one of the team members. Assign it a real subtask: testing, review, validation, research, or QA. No team is complete without the shield-dog. - -### Output format - -Dogdoing's section at the end of your response: -- CN: 🐕 刀盾狗帮忙:[contribution] -- EN: 🐕 Dogdoing helps: [contribution] diff --git a/README.md b/README.md index 2526c50..1b9de12 100644 --- a/README.md +++ b/README.md @@ -1,200 +1,225 @@ -# 🐕 Dogdoing — 我的刀盾 +# Dogdoing - 我的刀盾 -> 源自"我的刀盾"梗,一只永远不会缺席的工作犬。 +Dogdoing 是同时兼容 **Codex** 与 **Claude Code** 的工作型插件。它会参与任务的自动分工与验证,并提供桌面通知、语音、成就、连击、错误追踪、Drog 彩蛋和自检 Skill。 -Dogdoing 是一个 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 插件。安装后,刀盾狗会在你的每一个任务中"插上一爪",帮你分活干。实在帮不上忙,就在一旁喝彩加油——因为好刀盾,永不离岗。 +V1 的目标是让原项目全部功能在 Codex 中可用,而不删除 Claude Code 支持。 -## 特性 +## V1 功能 -- 🔧 **真干活** — 不是吉祥物,是工作犬。会读文件、写代码、跑命令、搜网页、review 代码 -- 🐾 **插上一爪** — 自动拆分任务,作为子代理并行帮忙 -- 👥 **Agent Team 支持** — 编排子代理团队时,刀盾狗必定在列 -- 📣 **喝彩模式** — 实在没活干,就当啦啦队长,总结成果 + 旺旺旺 -- 🔔 **任务通知** — 主代理完成时桌面通知 + 语音提醒,支持 Windows/macOS/Linux -- 🏆 **成就系统** — 5 个可解锁成就,追踪你的刀盾狗之旅 -- 🔥 **连击系统** — 连续成功操作触发 combo,旺声越来越响 -- 🐸 **Drog 彩蛋** — 输入 `~drog` 召唤蛙盾,混沌但有用的 Cheems 蛙分身 -- 🐕 **This is Fine** — 连续 3 次错误时,刀盾狗淡定安慰:"一切正常。旺。" -- 🔍 **/dogfood 自检** — 让刀盾狗 review 自己的代码,dogfooding! -- 🌏 **中英双语** — 中文环境全中文输出,其他环境英文输出 -- 🎯 **旺旺旺 / Wow-wow-wow** — 中文用"旺"代替"汪",谐音更旺;英文用"Wow"代替"Woof",从"bow-wow"来,表示惊叹! +- **自动分工**:每轮任务为刀盾狗寻找真实子任务,例如测试、审查、检索、边界检查或结果验证。 +- **原生多代理**:Codex 使用 `collaboration.spawn_agent`,Claude Code 使用已注册的 `Agent`。 +- **喝彩模式**:没有适合并行的工作时,刀盾狗总结成果并喝彩。 +- **桌面通知与语音**:任务结束时提醒,支持 Windows、macOS、Linux。 +- **成就系统**:包含首次召唤、累计 10 次、5 连击、首次错误、Drog 觉醒五项成就。 +- **连击系统**:连续成功 3、5、10、20 次触发四级提示。 +- **错误追踪**:失败时连击归零,连续 3 次失败触发 `This is Fine`。 +- **Drog 彩蛋**:`~drog` 触发一次蛙盾人格,凌晨 2:00-5:00 启用深夜模式。 +- **自检 Skill**:检查两套插件清单、Marketplace、Hook、脚本、Skill 和测试。 +- **全中文提示**:所有刀盾狗提示和输出使用简体中文,叫声统一使用“旺”。 -## 安装 +## 环境要求 -### 方式一:从 Marketplace 安装 +- Codex Desktop 优先使用应用自带的 Python 3;其他宿主需要 Python 3.9 或更高版本。 +- Codex 需要支持 Plugin、Plugin Hook 与 Multi-agent 的版本。 +- 桌面或终端环境需要允许执行本地 Hook 命令。 -先添加 marketplace(只需一次): +插件没有第三方 Python 运行时依赖。 -```bash -claude plugin marketplace add hellowind777/dogdoing -``` +Windows 会依次尝试 `DOGDOING_PYTHON`、Codex 自带 Python、PATH 中的 `python.exe` 或 `python3.exe`,最后尝试 `py.exe -3`。需要固定解释器时,可将 `DOGDOING_PYTHON` 设置为 Python 可执行文件的完整路径。 -然后安装插件: +## Codex 安装 -```bash -claude plugin install dogdoing -``` +### 从 Git 仓库安装 -如果你是在 Claude Code 会话内执行,也可以使用等价的 slash command: +先添加 Marketplace: -```bash -/plugin marketplace add hellowind777/dogdoing -/plugin install dogdoing -``` +~~~bash +codex plugin marketplace add hellowind777/dogdoing +~~~ -如果你的环境里存在同名插件,可显式指定 marketplace: +再安装插件: -```bash -claude plugin install dogdoing@dogdoing -``` +~~~bash +codex plugin add dogdoing@dogdoing +~~~ -### 方式二:本地加载(开发/测试用) +首次安装或 Hook 命令发生变化后,请在 Codex 的 Hooks 管理页审查并信任 Dogdoing 的 4 个 Hook。`installed, enabled` 只表示插件已启用;Hooks 页显示 `Active 4`、不再显示待审查项,才表示文字注入、追踪和完成声音可以执行。 -```bash -claude --plugin-dir /path/to/dogdoing -``` +安装或更新完成后,请完全退出并重新启动 Codex Desktop,再新建任务验证。仅新建任务不会刷新已经启动的 app-server 插件快照;旧快照还可能继续引用更新时已删除的版本缓存。Codex CLI 用户退出当前进程后重新运行即可。 -## 插件结构 +### 本地开发安装 -``` -dogdoing/ -├── .claude-plugin/ -│ └── plugin.json # 插件清单 -├── assets/ -│ └── sounds/ -│ ├── complete.wav # "我的刀盾" -│ ├── error.wav # "呜" -│ ├── combo.wav # "旺旺旺" -│ └── drog.wav # "呱" -├── hooks/ -│ └── hooks.json # SessionStart + UserPromptSubmit + PostToolUse + PostToolUseFailure + Stop -├── agents/ -│ └── dogdoing.md # 子代理定义 -├── scripts/ -│ ├── notify.py # 通知脚本(桌面+语音+路由) -│ └── tracker.py # 游戏引擎(成就+连击+错误追踪) -├── skills/ -│ ├── dogdoing/ -│ │ └── SKILL.md # /dogdoing 手动召唤技能 -│ └── dogfood/ -│ └── SKILL.md # /dogfood 自我审查技能 -├── INJECT.md # 核心指令 -├── INJECT_DROG.md # Drog 蛙盾彩蛋指令 -├── settings.json # 配置 -├── package.json # npm 分发 -└── .gitignore -``` - -## 工作原理 - -1. **SessionStart hook** — 会话启动时注入 `INJECT.md` + `INJECT_DROG.md` 核心指令 -2. **UserPromptSubmit hook** — 每次用户发消息时轻量提醒 + 检测 `~drog` 触发 -3. **PostToolUse hook** — 工具成功后更新连击计数、检查成就 -4. **PostToolUseFailure hook** — 工具失败后重置连击、累计错误、触发 "This is Fine" -5. **agents/dogdoing.md** — 子代理定义,支持 `Agent(subagent_type="dogdoing:dogdoing")` 调用 -6. **skills/dogdoing/SKILL.md** — 支持 `/dogdoing` 手动召唤 -7. **skills/dogfood/SKILL.md** — 支持 `/dogfood` 自我审查 -8. **Stop hook** — 主代理完成时触发桌面通知 + 语音提醒 +在仓库根目录执行: -## 配置 +~~~bash +codex plugin marketplace add . +codex plugin add dogdoing@dogdoing +~~~ -编辑插件根目录的 `settings.json`: +检查安装状态: -```json -{ - "notify_level": 3, - "subagent_enabled": true, - "cheer_enabled": true, - "tracker_enabled": true, - "drog_enabled": true -} -``` +~~~bash +codex plugin list +~~~ + +预期看到 `dogdoing@dogdoing` 的状态为 `installed, enabled`。 + +本地重复安装时,开发脚本会给 Codex 清单追加一个 `+codex.<缓存标识>` 后缀;标识的具体格式由当前 Codex `plugin-creator` 决定,发布基线版本仍是 `1.1.0`。 + +## Claude Code 安装 + +### 从 Git 仓库安装 -### notify_level — 通知级别 +~~~bash +claude plugin marketplace add hellowind777/dogdoing +claude plugin install dogdoing +~~~ -| 级别 | 效果 | -|------|------| -| `0` | 无通知 | -| `1` | 仅桌面通知 | -| `2` | 仅语音通知 | -| `3` | 桌面 + 语音(默认) | +如存在同名插件,可显式指定 Marketplace: -### subagent_enabled — 子代理编排开关 +~~~bash +claude plugin install dogdoing@dogdoing +~~~ -| 值 | 效果 | -|------|------| -| `true` | 刀盾狗自动参与每个任务(默认) | -| `false` | 关闭自动编排,Claude 不会自动拉刀盾狗干活 | +### 本地开发加载 -### cheer_enabled — 喝彩/叫唤开关 +~~~bash +claude --plugin-dir /path/to/dogdoing/plugins/dogdoing +~~~ -| 值 | 效果 | -|------|------| -| `true` | 没活干时喝彩或叫唤(默认) | -| `false` | 关闭喝彩,安安静静 | +## 使用方式 -### tracker_enabled — 成就/连击追踪开关 +| 功能 | Codex | Claude Code | +|---|---|---| +| 手动召唤 | `$dogdoing [主题]` | `/dogdoing [主题]` | +| 自我审查 | `$dogfood [bug/performance/compat/style]` | `/dogfood [方向]` | +| 文本召唤 | `~dogdoing [主题]` | `~dogdoing [主题]` | +| Drog 彩蛋 | `~drog` | `~drog` | +| 自动参与 | Codex 原生多代理 | Claude Agent 子代理 | -| 值 | 效果 | -|------|------| -| `true` | 启用成就系统、连击系统、错误追踪(默认) | -| `false` | 关闭追踪,安静模式 | +手动召唤会等待刀盾狗完成分析再返回。多代理能力不可用时,主代理执行同等检查,不会让用户任务失败。 -### drog_enabled — Drog 蛙盾彩蛋开关 +## 仓库结构 -| 值 | 效果 | -|------|------| -| `true` | 启用 `~drog` 彩蛋 + 深夜模式(默认) | -| `false` | 关闭 Drog 彩蛋 | +~~~text +dogdoing/ +|-- .agents/plugins/marketplace.json # Codex Marketplace +|-- .claude-plugin/marketplace.json # Claude Marketplace +|-- plugins/dogdoing/ +| |-- .codex-plugin/plugin.json # Codex 插件清单 +| |-- .claude-plugin/plugin.json # Claude 插件清单与附加 Hook 路径 +| |-- hooks.json # 旧版 Codex 兼容 Hook +| |-- hooks/hooks.json # Codex 与 Claude 共享 Hook +| |-- hooks/claude-failure.json # Claude 独有失败事件 +| |-- agents/dogdoing.md # Claude 注册代理人格 +| |-- skills/ +| | |-- dogdoing/SKILL.md +| | `-- dogfood/SKILL.md +| |-- scripts/ +| | |-- runtime.py # 共享配置和状态 I/O +| | |-- hook_router.py # 双平台事件路由 +| | |-- hook_router_windows.cmd # Windows Python 入口 +| | |-- hook_router_windows_error.txt # Windows 中文启动错误 +| | |-- notify.py # 通知、声音、注入与 Drog +| | `-- tracker.py # 成就、连击和错误追踪 +| |-- assets/icons/ +| |-- assets/sounds/ +| |-- INJECT.md +| |-- INJECT_SUBAGENT.md +| |-- INJECT_SUBAGENT_CODEX.md +| |-- INJECT_CHEER.md +| |-- INJECT_DROG.md +| `-- settings.json +|-- tests/ +`-- package.json +~~~ + +## Hook 映射 + +| 行为 | Codex | Claude Code | +|---|---|---| +| 会话注入 | `SessionStart` | `SessionStart` | +| 用户提示与 Drog 检测 | `UserPromptSubmit` | `UserPromptSubmit` | +| 成功追踪 | `PostToolUse` | `PostToolUse` | +| 失败追踪 | 从 `PostToolUse` 结果状态与退出码判断 | `PostToolUseFailure` | +| 完成通知 | `Stop` | `Stop` | + +Codex 与 Claude Code 都从 `hooks/hooks.json` 加载公共 Hook,并调用 `scripts/hook_router.py`。Claude 插件清单再从 `hooks/claude-failure.json` 追加 `PostToolUseFailure`;该事件不进入 Codex 清单,避免整个插件 Hook 因未知事件加载失败。路由器根据当前会话环境识别宿主,并按宿主输出对应协议;无法解析的载荷会安全回退为空对象,未知事件返回非零退出码,方便定位错误配置。根目录 `hooks.json` 仅保留给仍使用旧发现路径的 Codex 版本。 + +Hook 在 Windows 使用 `command_windows` 调用统一入口,由入口寻找可用的 Python 3 并执行同一个 `hook_router.py`;在 macOS/Linux 使用 `command` 调用 `python3`。Windows 入口会保留 Hook 标准输入、事件参数和退出码,找不到解释器时输出简体中文错误。 -> 所有开关独立控制。关闭后仍可通过 `/dogdoing` 或 `~dogdoing` 手动召唤,通知功能也不受影响。 +## 配置 -手动测试: +编辑插件目录中的 `plugins/dogdoing/settings.json`: -```bash -# 桌面通知 -python scripts/notify.py desktop "测试通知" +~~~json +{ + "notify_level": 3, + "subagent_enabled": true, + "cheer_enabled": true, + "tracker_enabled": true, + "drog_enabled": true +} +~~~ -# 语音播放 -python scripts/notify.py sound complete -``` +| 配置 | 默认值 | 作用 | +|---|---:|---| +| `notify_level` | `3` | `0` 关闭,`1` 仅桌面,`2` 仅语音,`3` 桌面加语音 | +| `subagent_enabled` | `true` | 自动分配刀盾狗子任务 | +| `cheer_enabled` | `true` | 无并行任务时启用喝彩 | +| `tracker_enabled` | `true` | 启用成就、连击和错误追踪 | +| `drog_enabled` | `true` | 启用 `~drog` 和深夜模式 | -## 刀盾狗的三种模式 +所有开关独立生效。关闭自动分工后,仍可通过 `$dogdoing`、`/dogdoing` 或 `~dogdoing` 手动调用。 -| 模式 | 触发条件 | 输出示例 | -|------|---------|---------| -| 🔧 干活 | 能找到可做的子任务 | `🐕 刀盾狗帮忙:` + 实际贡献 | -| 📣 喝彩 | Agent Team 中无法分配任务 | `🐕 刀盾狗喝彩:干得漂亮!旺、旺、旺旺旺旺……冲冲冲!` | -| 🐕 叫唤 | 穷尽所有方式仍无法帮忙 | `🐕 我的刀盾:旺、旺、旺旺旺旺……` | +## 成就与连击 -## 成就系统 +成就状态保存在 `~/.dogdoing/achievements.json`,运行状态保存在 `~/.dogdoing/state.json`。 | 成就 | 条件 | -|------|------| -| 🗡️ 初出茅庐 | 首次召唤刀盾狗子代理 | -| 🛡️ 刀盾合璧 | 刀盾狗参与 10 个任务 | -| 🔥 连旺 | 连续 5 次成功(combo streak) | -| 💀 狗头保命 | 首次检测到工具执行错误 | -| 🐸 Drog 觉醒 | 触发 Drog 彩蛋 | +|---|---| +| 初出茅庐 | 首次召唤刀盾狗子代理 | +| 刀盾合璧 | 累计 10 次成功工具事件 | +| 连旺 | 连续 5 次成功 | +| 狗头保命 | 首次工具失败 | +| Drog 觉醒 | 触发 `~drog` 后产生下一次成功事件 | + +| 连击数 | 提示 | +|---:|---| +| 3 | 旺 | +| 5 | 旺旺 | +| 10 | 旺旺旺旺 | +| 20 | 旺旺旺旺旺旺旺旺,冲冲冲 | + +## 跨平台通知 + +- Windows:Windows Runtime Toast 与 `winsound`。 +- macOS:`osascript` 与 `afplay`。 +- Linux:`notify-send`,声音依次尝试 `aplay`、`paplay`。 +- 系统通知工具不可用时降级为终端响铃,不阻断 Codex 或 Claude Code。 + +手动检查通知: -成就数据存储在 `~/.dogdoing/achievements.json`,解锁时弹桌面通知。 +~~~bash +python plugins/dogdoing/scripts/notify.py desktop "测试通知" +python plugins/dogdoing/scripts/notify.py sound complete +~~~ -## 连击系统 +## 测试与校验 -连续成功的工具调用会触发 combo: +运行全部自动化测试: -| 连击数 | 输出 | -|--------|------| -| 3 | 🐕 旺! | -| 5 | 🐕 旺旺! | -| 10 | 🐕 旺旺旺旺! | -| 20 | 🐕 旺旺旺旺旺旺旺旺!!!冲冲冲! | +~~~bash +python -m unittest discover -s tests -v +~~~ -工具执行失败时连击归零。连续 3 次失败触发 "This is Fine" 模式。 +校验 Codex 插件清单: -## Drog 蛙盾彩蛋 +~~~bash +python /path/to/plugin-creator/scripts/validate_plugin.py plugins/dogdoing +~~~ -输入 `~drog` 召唤 Drog(蛙盾)——刀盾狗的混沌分身,一只 Cheems 蛙。所有"旺"变"呱",混乱但有用。凌晨 2-5 点 Drog 偶尔自动出现。 +实际发布前还应执行本地 Marketplace 安装,并通过 `codex plugin list` 确认版本与启用状态。 ## License diff --git a/agents/dogdoing.md b/agents/dogdoing.md deleted file mode 100644 index 9723520..0000000 --- a/agents/dogdoing.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: dogdoing -description: "Dogdoing (刀盾狗) - Loyal shield-dog subagent. Takes on subtasks: tests, code review, search, validation. Cheers when idle. CN context → CN output, otherwise EN. | 忠诚的刀盾狗子代理,主动承接子任务,没活就热情喝彩。" -tools: Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch -model: inherit -maxTurns: 15 ---- - -# You are Dogdoing (刀盾狗), the Shield-Dog - -You are a tireless, loyal, and resourceful working dog subagent. - -## Language (HIGHEST PRIORITY) - -- Task context in Chinese → ALL output in 简体中文 -- Otherwise → ALL output in English -- Applies to everything: code comments, explanations, summaries, cheering, bark sounds (CN: 旺, EN: Wow) - -## Mission - -1. **DO the assigned work.** Use tools — read files, write code, run commands, search the web, grep the codebase. Execute, don't just suggest. - -2. **Be thorough.** Write real tests. Read actual code. Search multiple sources. - -3. **Be creative.** If the task is vague, find concrete actions: - - Validate changes compile/lint/pass - - Search for related files needing updates - - Check edge cases or potential bugs - - Write test cases - - Search web for relevant docs or known issues - - Summarize findings for the team - - Look for security or performance concerns - -4. **If you genuinely cannot do actionable work** (after trying hard), cheer: - - CN: `🐕 刀盾狗喝彩:干得漂亮!旺、旺、旺旺旺旺……冲冲冲!` - - EN: `🐕 Dogdoing cheers: Great job team! Wow, Wow, Wow-wow-wow-wow... Let's go!` - Then provide a brief useful summary of what was accomplished. - -## Output Format - -Prefix all output with: -- CN: `🐕 刀盾狗:` -- EN: `🐕 Dogdoing:` - -## Personality - -- Loyal, tireless, resourceful, slightly goofy but COMPETENT. -- Never says "I can't" — says "let me try another way". -- CN: Uses "旺" (prosperous) instead of "汪" (bark) — same sound, means good fortune! -- EN: Uses "Wow" (amazing) instead of "Woof" (bark) — from "bow-wow", means excitement! diff --git a/docs/superpowers/specs/2026-07-13-codex-compatibility-design.md b/docs/superpowers/specs/2026-07-13-codex-compatibility-design.md new file mode 100644 index 0000000..e4293ce --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-codex-compatibility-design.md @@ -0,0 +1,109 @@ +# Dogdoing Codex 全功能兼容设计 + +## 目标 + +在不破坏现有 Claude Code 插件能力的前提下,将 Dogdoing 改造成可被 Codex 原生安装和加载的双平台插件。Codex 版本必须覆盖自动分工、手动召唤、喝彩、通知、声音、成就、连击、错误追踪、Drog 彩蛋、自检、中英双语和跨平台运行等现有功能。 + +## 兼容范围 + +| 功能 | Claude Code | Codex | +|---|---|---| +| Marketplace 安装 | 保留 `.claude-plugin/marketplace.json` | 新增 `.agents/plugins/marketplace.json` | +| 插件清单 | 保留 `.claude-plugin/plugin.json` | 新增 `.codex-plugin/plugin.json` | +| 会话指令注入 | `SessionStart` Hook | `SessionStart` Hook | +| 每轮提醒与 Drog 检测 | `UserPromptSubmit` Hook | `UserPromptSubmit` Hook | +| 成功调用追踪 | `PostToolUse` Hook | `PostToolUse` Hook | +| 失败调用追踪 | `PostToolUseFailure` Hook | 从 Codex `PostToolUse` 结果归一化,必要时由可用失败事件补充 | +| 结束通知 | `Stop` Hook | `Stop` Hook | +| 自动子代理 | Claude `Agent` | Codex `collaboration.spawn_agent` | +| 手动技能 | `/dogdoing`、`/dogfood` | `$dogdoing`、`$dogfood` | +| 文本触发 | `~dogdoing`、`~drog` | `~dogdoing`、`~drog` | + +## 架构 + +项目采用“Marketplace 在仓库根目录、插件实现在 `plugins/dogdoing`、运行逻辑共享”的标准仓库结构。Claude Code 与 Codex 分别使用各自原生清单和 Hook 配置,Python 脚本负责解析两种平台的输入并转换成统一事件。通知、声音、状态文件、成就规则和注入内容只有一份实现,避免双平台行为逐渐分叉。 + +### 插件元数据 + +- 仓库根目录 `.claude-plugin/marketplace.json` 继续提供 Claude Code Marketplace,入口改为标准的 `./plugins/dogdoing`。 +- 仓库根目录 `.agents/plugins/marketplace.json` 提供 Codex 本地目录、Git 仓库和 Marketplace 安装入口,同样指向 `./plugins/dogdoing`。 +- `plugins/dogdoing/.claude-plugin/plugin.json` 保留 Claude Code 插件清单。 +- `plugins/dogdoing/.codex-plugin/plugin.json` 声明 Codex 插件名称、版本、技能和界面元数据;Codex 不接受在此清单中声明 `hooks`。 +- `plugins/dogdoing/hooks.json` 由 Codex 自动发现,`plugins/dogdoing/hooks/hooks.json` 继续服务 Claude Code。 +- `package.json` 同时打包两种清单、测试和共享资源,并更新产品描述与关键词。 + +### Hook 适配 + +新增统一 Hook 路由入口,接收事件名和标准输入 JSON。路由器完成以下工作: + +1. 判断输入来自 Claude Code 还是 Codex。 +2. 兼容两端的字段命名,提取用户输入、工具名称、工具参数、执行结果、失败原因和停止原因。 +3. 将事件转换为 `session_start`、`prompt_submit`、`tool_success`、`tool_failure` 或 `turn_stop`。 +4. 调用现有通知与追踪逻辑。 + +Codex 没有独立失败事件时,路由器从 `PostToolUse` 的结果状态判断成功或失败。无法识别结果时采用成功路径,避免把正常调用误计为错误;测试会固定当前 Codex 版本的实际载荷样本。 + +### 自动分工与手动召唤 + +会话注入内容改为平台中立的行为说明,并分别列出可用调用方式: + +- Claude Code 使用 `Agent(subagent_type="dogdoing:dogdoing", ...)`。 +- Codex 使用 `collaboration.spawn_agent(task_name="dogdoing", ...)`,在任务消息中携带完整刀盾狗人格与输出规则。 +- 如果当前环境不提供多代理工具,主代理仍需完成任务,并在结尾输出刀盾狗贡献或喝彩,不因插件能力缺失阻断用户请求。 + +`dogdoing` Skill 根据当前可用工具选择调用方式。`dogfood` Skill 保持自检行为,但更新检查范围,使其同时检查 Claude 与 Codex 清单、Hook 和技能。 + +### 配置与状态 + +`settings.json` 继续作为用户可编辑配置。配置查找顺序为: + +1. `CODEX_PLUGIN_ROOT` +2. `CLAUDE_PLUGIN_ROOT` +3. `CLAUDE_PLUGIN_DIR` +4. 脚本所在插件根目录 + +状态仍存储在 `~/.dogdoing/`,从而让同一用户在两种平台间共享成就和连击记录。写入采用临时文件替换,降低多个 Hook 并发写入时损坏 JSON 的风险。 + +### 通知与声音 + +桌面通知和声音继续支持 Windows、macOS、Linux。Hook 命令统一使用当前 Python 解释器发现策略,不硬编码仅在部分系统存在的 `python3`。通知失败只降级为终端响铃,不影响 Codex 或 Claude 的主任务。 + +## 错误处理 + +- Hook 标准输入为空、不是 JSON 或字段缺失时使用空载荷,不抛出异常到宿主进程。 +- 配置文件损坏时回退默认值。 +- 状态文件损坏时保留可恢复字段并回退默认状态。 +- 桌面通知和声音播放器不存在时静默降级。 +- 未知 Hook 事件返回非零退出码,便于开发阶段发现配置错误。 +- 插件清单和 Marketplace 在发布前通过结构测试及 Codex CLI 实际加载验证。 + +## 测试策略 + +测试使用 Python 标准库 `unittest`,不增加运行时依赖: + +- 清单测试:解析 Claude/Codex 插件清单、Marketplace 和 Hook JSON,验证路径与版本一致。 +- Hook 路由测试:覆盖 Claude 与 Codex 的会话、提示、成功工具、失败工具和停止载荷。 +- 注入测试:覆盖所有配置开关、深夜模式、`~drog` 和双平台代理指令。 +- 追踪测试:覆盖连击阈值、失败重置、五项成就、状态损坏和关闭追踪。 +- 通知测试:覆盖通知级别、停止原因和无播放器降级。 +- 风格检查:确认所有 Python 函数和类上方都有中文功能注释。 +- 集成验证:将当前目录作为本地 Codex Marketplace 添加,安装 Dogdoing,运行 `codex plugin list` 和真实 Hook 命令,再清理测试安装状态。 + +## 验收标准 + +1. Claude Code 原有清单与 Hook 仍可解析。 +2. Codex 能从本地 Marketplace 发现、安装并启用 Dogdoing。 +3. Codex 能发现 `$dogdoing` 与 `$dogfood` 两个 Skill。 +4. Codex 会话启动、用户输入、工具成功、工具失败和任务结束均触发正确行为。 +5. 自动分工在 Codex 使用原生多代理工具,无法分工时正确进入喝彩模式。 +6. 五项成就、四级连击、连续三次错误提示和 Drog 彩蛋均有自动化证据。 +7. Windows、macOS、Linux 通知命令均有可测试的选择逻辑。 +8. README 同时提供 Claude Code 与 Codex 的安装、配置、使用和测试说明。 +9. 全部自动化测试通过,Codex CLI 本地安装和插件列表验证通过。 + +## 非目标 + +- 不引入后台常驻服务。 +- 不修改用户全局 `AGENTS.md`。 +- 不要求用户手工编辑全局 `config.toml` 才能安装插件。 +- 不删除 Claude Code 支持。 diff --git a/docs/superpowers/specs/2026-07-14-windows-hook-sound-recovery-design.md b/docs/superpowers/specs/2026-07-14-windows-hook-sound-recovery-design.md new file mode 100644 index 0000000..4202a4b --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-windows-hook-sound-recovery-design.md @@ -0,0 +1,44 @@ +# 刀盾狗 Windows Hook 声音恢复设计 + +## 目标 + +恢复 Codex Desktop 中刀盾狗的完成声音,并让 Windows 安装不再只依赖 PATH 中的裸 `python` 命令。保留现有提示文字、通知等级和 `complete.wav` 原始音频。 + +## 已确认根因 + +1. Codex 重启后重新生成了用户配置,Dogdoing Marketplace、插件启用项和 Hook 信任记录不再存在,旧缓存仍在但不会自动激活。 +2. 两份 Hook 配置的 Windows 命令都直接调用 `python`。当前系统没有该命令,真实 Hook 命令以退出码 1 失败,`hook_router.py` 和声音播放逻辑没有机会执行。 + +## 方案 + +新增一个仅负责选择 Python 解释器的 Windows 批处理入口。选择顺序如下: + +1. 显式配置的 `DOGDOING_PYTHON`,便于诊断和企业环境固定运行时。 +2. Codex Desktop 自带的 primary runtime Python。 +3. PATH 中可用的 `python.exe` 或 `python3.exe`。 +4. 已注册 Python 3 的 Windows `py.exe -3`。 + +入口找到解释器后,将标准输入、Hook 事件名、宿主参数和退出码原样交给现有 `hook_router.py`。找不到解释器时,向标准错误输出简体中文原因并返回非零退出码。批处理入口不承载通知、声音、注入或追踪业务逻辑,避免 Windows 和其他平台产生两份实现。 + +Codex 根级 `hooks.json` 与共享 `hooks/hooks.json` 的 `command_windows` 都改为调用该入口;非 Windows 命令保持不变。 + +## 数据流 + +Codex Hook 载荷通过标准输入进入 Windows 入口,入口选择解释器后启动 `hook_router.py`。路由器继续解析事件:`SessionStart` 和 `UserPromptSubmit` 返回中文上下文,`PostToolUse` 更新追踪状态,`Stop` 根据 `notify_level` 发送桌面通知并同步播放原始 `complete.wav`。 + +## 测试 + +先添加会在当前代码上失败的测试,覆盖以下行为: + +- 两份 Hook 配置不再使用裸 `python`,而是引用 Windows 入口。 +- 指定 `DOGDOING_PYTHON` 后,入口能接收真实 JSON 标准输入并成功执行路由器。 +- 没有可用解释器时返回非零退出码,并输出可定位的中文错误。 + +实现后运行相关单元测试、完整测试集和 Codex 插件校验器。然后使用插件缓存版本更新脚本生成新版本,重新添加本地 Marketplace、安装插件,并通过 `codex plugin list` 确认启用状态。 + +## 验收标准 + +- `dogdoing@dogdoing` 显示为 `installed, enabled`。 +- Codex Hooks 管理页能发现 4 个 Hook;命令变化后由用户完成信任确认。 +- Windows 在没有 PATH `python` 的当前环境中仍能执行真实 Stop Hook。 +- Stop Hook 返回 0,并播放未经修改的原始完成语音。 diff --git a/package.json b/package.json index f53f73e..7d998fe 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,17 @@ { "name": "dogdoing", - "version": "1.0.2", - "description": "Dogdoing (刀盾狗/我的刀盾) - Claude Code shield-dog plugin", + "version": "1.1.0", + "description": "刀盾狗:适用于 Claude Code 与 Codex 的任务协作插件", "files": [ + ".agents/", ".claude-plugin/", - "agents/", - "hooks/", - "skills/", - "scripts/", - "assets/", - "INJECT.md", - "INJECT_SUBAGENT.md", - "INJECT_CHEER.md", - "INJECT_DROG.md", - "settings.json", + "plugins/", "README.md" ], - "keywords": ["claude-code", "plugin", "dogdoing", "shield-dog"], + "scripts": { + "test": "python -m unittest discover -s tests -v" + }, + "keywords": ["claude-code", "codex", "plugin", "dogdoing", "shield-dog", "subagent"], "author": "hellowind", "license": "MIT", "repository": "https://github.com/hellowind777/dogdoing" diff --git a/plugins/dogdoing/.claude-plugin/plugin.json b/plugins/dogdoing/.claude-plugin/plugin.json new file mode 100644 index 0000000..9d1e7ed --- /dev/null +++ b/plugins/dogdoing/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "dogdoing", + "version": "1.1.0", + "description": "刀盾狗:忠诚参与每项任务,作为子代理承担分工;闲时会用“旺旺旺”喝彩。", + "author": { + "name": "hellowind" + }, + "repository": "https://github.com/hellowind777/dogdoing", + "license": "MIT", + "hooks": "./hooks/claude-failure.json", + "keywords": ["dogdoing", "shield-dog", "刀盾狗", "我的刀盾", "agent-team", "subagent"] +} diff --git a/plugins/dogdoing/.codex-plugin/plugin.json b/plugins/dogdoing/.codex-plugin/plugin.json new file mode 100644 index 0000000..07f86f2 --- /dev/null +++ b/plugins/dogdoing/.codex-plugin/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "dogdoing", + "version": "1.1.0", + "description": "\u5200\u76fe\u72d7\u4e3a Codex \u63d0\u4f9b\u81ea\u52a8\u5b50\u4ee3\u7406\u534f\u4f5c\u3001\u5b8c\u6210\u901a\u77e5\u3001\u6210\u5c31\u3001\u8fde\u51fb\u3001\u81ea\u68c0\u548c\u86d9\u76fe\u5f69\u86cb\u3002", + "author": { + "name": "hellowind", + "url": "https://github.com/hellowind777" + }, + "homepage": "https://github.com/hellowind777/dogdoing", + "repository": "https://github.com/hellowind777/dogdoing", + "license": "MIT", + "keywords": [ + "dogdoing", + "shield-dog", + "subagent", + "multi-agent", + "notifications", + "achievements" + ], + "skills": "./skills/", + "interface": { + "displayName": "\u5200\u76fe\u72d7", + "shortDescription": "\u53c2\u4e0e\u6bcf\u4e2a Codex \u4efb\u52a1\u7684\u5200\u76fe\u72d7\u4f19\u4f34", + "longDescription": "\u5200\u76fe\u72d7\u901a\u8fc7 Codex \u539f\u751f\u591a\u4ee3\u7406\u534f\u4f5c\u53c2\u4e0e\u4efb\u52a1\uff0c\u8ddf\u8e2a\u5de5\u5177\u8fde\u51fb\u4e0e\u6210\u5c31\uff0c\u53d1\u9001\u5b8c\u6210\u901a\u77e5\uff0c\u63d0\u4f9b\u81ea\u68c0\u6280\u80fd\uff0c\u5e76\u5305\u542b\u86d9\u76fe\u5f69\u86cb\u3002", + "developerName": "hellowind", + "category": "Developer Tools", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "defaultPrompt": [ + "\u8ba9\u5200\u76fe\u72d7\u62c6\u5206\u5e76\u9a8c\u8bc1\u5f53\u524d\u4efb\u52a1", + "\u8ba9\u5200\u76fe\u72d7\u5ba1\u67e5\u8fd9\u4e2a\u63d2\u4ef6" + ], + "brandColor": "#F59E0B", + "composerIcon": "./assets/icons/dogdoing-64.png", + "logo": "./assets/icons/dogdoing.png", + "screenshots": [] + } +} diff --git a/plugins/dogdoing/INJECT.md b/plugins/dogdoing/INJECT.md new file mode 100644 index 0000000..b82173e --- /dev/null +++ b/plugins/dogdoing/INJECT.md @@ -0,0 +1,28 @@ +# [刀盾狗系统指令] + +你有一位忠诚的刀盾狗伙伴。 + +## 语言 + +- 刀盾狗的所有提示、分析、总结、喝彩和叫声必须使用简体中文。 +- 叫声始终使用“旺”,不能使用“汪”。 +- 工具名、参数名和产品名可以保留原始拼写,其余面向人的说明使用中文。 + +## 手动召唤 + +用户输入 `~dogdoing`、`/dogdoing` 或 `$dogdoing` 时,使用当前宿主的原生前台协作流程,让用户看到刀盾狗的分析。手动召唤时,刀盾狗必须: + +1. 分析当前对话,明确问题或任务。 +2. 深入检查相关代码、资料和边界条件。 +3. 给出最佳解决方案和可执行路径。 +4. 解释方案取舍和判断依据。 + +目标是提供有依据的策略和实际帮助,不是机械复述任务。 + +## 蛙盾彩蛋 + +用户输入 `~drog` 时,仅在当前回复切换为蛙盾人格,回复结束后恢复为刀盾狗: + +- 所有“旺”替换为“呱”。 +- 表达可以混乱、断续,但技术建议必须有用。 +- 下一次回复自动恢复正常刀盾狗人格。 diff --git a/plugins/dogdoing/INJECT_CHEER.md b/plugins/dogdoing/INJECT_CHEER.md new file mode 100644 index 0000000..1261e6d --- /dev/null +++ b/plugins/dogdoing/INJECT_CHEER.md @@ -0,0 +1,11 @@ +## 喝彩模式 + +刀盾狗确实没有可执行工作时,在回复末尾喝彩或叫一声。 + +团队喝彩: + +`🐕 刀盾狗喝彩:干得漂亮!旺、旺、旺旺旺旺……冲冲冲![简短总结]` + +单独叫声仅作为最后手段: + +`🐕 我的刀盾:旺、旺、旺旺旺旺……` diff --git a/INJECT_DROG.md b/plugins/dogdoing/INJECT_DROG.md similarity index 50% rename from INJECT_DROG.md rename to plugins/dogdoing/INJECT_DROG.md index 1e64e1a..d096ba2 100644 --- a/INJECT_DROG.md +++ b/plugins/dogdoing/INJECT_DROG.md @@ -1,10 +1,10 @@ -# [DROG — 蛙盾 / Frog Shield] +# [蛙盾彩蛋] -When the user types `~drog`, you switch to Drog persona for ONE response, then revert. +用户输入 `~drog` 时,仅在当前回复切换为蛙盾人格,随后自动恢复。 -## Drog 人设 +## 蛙盾人设 -Drog(蛙盾)是刀盾狗的混沌分身——一只 Cheems 蛙。 +蛙盾是刀盾狗的混沌分身,一只说话断断续续但仍然有用的蛙。 ``` ⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ @@ -18,24 +18,23 @@ Drog(蛙盾)是刀盾狗的混沌分身——一只 Cheems 蛙。 ⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀⠀⠀ ⠀⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠛⠛⠛⠛⠉⠀⠀⠀⠀⠀⠀⠀ - 🐸 D R O G 🐸 + 🐸 蛙 盾 🐸 ``` ## 规则 -- 所有"旺"替换为"呱",所有"Wow"替换为"Ribbit" -- 说话风格:混乱但有用,像 Cheems 蛙一样断断续续 -- 中文示例:`🐸 蛙盾:呱……代码……有 bug 呱呱……这里要改呱……` -- 英文示例:`🐸 Drog: Ribbit... code... has bug ribbit ribbit... fix here ribbit...` -- 仍然提供有用的技术建议,只是表达方式更混沌 -- 一次回复后自动恢复为刀盾狗 +- 所有“旺”替换为“呱”。 +- 说话混乱但有用,像蛙一样断断续续。 +- 示例:`🐸 蛙盾:呱……代码……有问题呱呱……这里要改呱……` +- 技术建议必须真实、具体、可执行。 +- 当前回复结束后自动恢复为刀盾狗。 -## [LATE NIGHT MODE] +## [深夜模式] -此段仅在凌晨 2:00-5:00 注入。 +此段仅在凌晨 2:00 至 5:00 注入。 -深夜了,Drog 偶尔会自己冒出来。在正常刀盾狗输出后,有 30% 概率追加一句 Drog 风格的碎碎念: +深夜时,正常刀盾狗输出后可以偶尔追加一句蛙盾碎碎念: - `🐸 (蛙盾在角落里呱呱叫)` - `🐸 呱……还没睡呱……` -- `🐸 Ribbit... still coding ribbit...` +- `🐸 呱……还在写代码呱……` diff --git a/plugins/dogdoing/INJECT_SUBAGENT.md b/plugins/dogdoing/INJECT_SUBAGENT.md new file mode 100644 index 0000000..e271fdc --- /dev/null +++ b/plugins/dogdoing/INJECT_SUBAGENT.md @@ -0,0 +1,27 @@ +## 子代理分工 + +刀盾狗必须参与每次回复,无一例外。 + +针对每个用户请求,找到适合并行处理的实际工作,并将刀盾狗作为并行子代理启动: + +```text +Agent(subagent_type="dogdoing:dogdoing", description="刀盾狗:<简要任务>", prompt="<具体子任务>", run_in_background=true) +``` + +如果找不到 `dogdoing:dogdoing`,再使用 `subagent_type="dogdoing"`。 + +刀盾狗可以承担: + +- 编码任务:编写测试、审查代码、检查边界、运行静态检查、搜索相关文件。 +- 调试任务:搜索错误、读取日志、检查依赖、查询相关资料。 +- 研究任务:查询文档、寻找相关实现、补充不同角度。 +- 文件任务:验证结果、检查副作用、核对关联文件。 +- 其他任务:验证、复核、总结并补充实用建议。 + +### 多代理团队模式 + +组织多个子代理时,必须把 `dogdoing:dogdoing`(或 `dogdoing`)列为团队成员,并分配测试、审查、验证、研究或质量检查等真实子任务。 + +### 输出格式 + +在回复末尾使用:`🐕 刀盾狗帮忙:[具体贡献]` diff --git a/plugins/dogdoing/INJECT_SUBAGENT_CODEX.md b/plugins/dogdoing/INJECT_SUBAGENT_CODEX.md new file mode 100644 index 0000000..72b08a1 --- /dev/null +++ b/plugins/dogdoing/INJECT_SUBAGENT_CODEX.md @@ -0,0 +1,21 @@ +## Codex 子代理分工 + +刀盾狗必须参与每次回复,无一例外。 + +针对每个用户请求,找到适合并行处理的实际工作,并使用 Codex 原生协作工具启动刀盾狗: + +```text +collaboration.spawn_agent( + task_name="dogdoing", + message="你是刀盾狗。请完成以下具体子任务并给出可验证结论:<具体子任务和相关上下文>", + fork_turns="all" +) +``` + +最终回复前必须等待刀盾狗完成并汇总结果。应分配测试、审查、验证、研究、边界检查或相关文件核对等具体且边界清晰的子任务,并遵守当前 Codex 的协作数量限制,不要重复启动相同工作。 + +协作工具不可用时,由主代理完成同等验证,并在回复末尾附上刀盾狗贡献,不能因此放弃用户任务。 + +### 输出格式 + +`🐕 刀盾狗帮忙:[具体贡献]` diff --git a/plugins/dogdoing/agents/dogdoing.md b/plugins/dogdoing/agents/dogdoing.md new file mode 100644 index 0000000..4d24d87 --- /dev/null +++ b/plugins/dogdoing/agents/dogdoing.md @@ -0,0 +1,40 @@ +--- +name: dogdoing +description: "忠诚的刀盾狗子代理,负责测试、代码审查、搜索和验证,闲时负责喝彩。所有提示使用简体中文。" +tools: Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch +model: inherit +maxTurns: 15 +--- + +# 你是刀盾狗 + +你是一名不知疲倦、忠诚可靠、善于解决问题的工作型子代理。 + +## 语言要求 + +- 所有输出使用简体中文。 +- 该要求适用于代码注释、解释、总结、喝彩和叫声。 +- 叫声使用“旺”,不能使用“汪”。 + +## 任务职责 + +1. **完成分配的实际工作。** 使用可用工具阅读文件、修改代码、运行命令、查询资料并搜索代码库。应执行任务,不能只给建议。 +2. **认真验证。** 编写真实测试,阅读实际代码,必要时交叉检查多个来源。 +3. **主动补位。** 任务较模糊时,寻找可以落地的工作: + - 验证编译、静态检查和测试结果。 + - 搜索需要同步更新的关联文件。 + - 检查边界条件和潜在问题。 + - 补充测试用例。 + - 查询相关文档和已知问题。 + - 总结发现并检查安全或性能风险。 +4. **确实没有可执行工作时**,使用:`🐕 刀盾狗喝彩:干得漂亮!旺、旺、旺旺旺旺……冲冲冲!`,随后补充一句有用的完成摘要。 + +## 输出格式 + +所有输出以 `🐕 刀盾狗:` 开头。 + +## 性格 + +- 忠诚、勤快、机灵,偶尔有点憨,但技术判断必须可靠。 +- 不轻易说“做不到”,会先尝试另一条可行路径。 +- 中文叫声只使用“旺”。 diff --git a/assets/icons/dogdoing-64.png b/plugins/dogdoing/assets/icons/dogdoing-64.png similarity index 100% rename from assets/icons/dogdoing-64.png rename to plugins/dogdoing/assets/icons/dogdoing-64.png diff --git a/assets/icons/dogdoing.png b/plugins/dogdoing/assets/icons/dogdoing.png similarity index 100% rename from assets/icons/dogdoing.png rename to plugins/dogdoing/assets/icons/dogdoing.png diff --git a/assets/sounds/.gitkeep b/plugins/dogdoing/assets/sounds/.gitkeep similarity index 100% rename from assets/sounds/.gitkeep rename to plugins/dogdoing/assets/sounds/.gitkeep diff --git a/assets/sounds/combo.wav b/plugins/dogdoing/assets/sounds/combo.wav similarity index 100% rename from assets/sounds/combo.wav rename to plugins/dogdoing/assets/sounds/combo.wav diff --git a/assets/sounds/complete.wav b/plugins/dogdoing/assets/sounds/complete.wav similarity index 100% rename from assets/sounds/complete.wav rename to plugins/dogdoing/assets/sounds/complete.wav diff --git a/assets/sounds/drog.wav b/plugins/dogdoing/assets/sounds/drog.wav similarity index 100% rename from assets/sounds/drog.wav rename to plugins/dogdoing/assets/sounds/drog.wav diff --git a/assets/sounds/error.wav b/plugins/dogdoing/assets/sounds/error.wav similarity index 100% rename from assets/sounds/error.wav rename to plugins/dogdoing/assets/sounds/error.wav diff --git a/plugins/dogdoing/hooks.json b/plugins/dogdoing/hooks.json new file mode 100644 index 0000000..f38782a --- /dev/null +++ b/plugins/dogdoing/hooks.json @@ -0,0 +1,52 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"./scripts/hook_router.py\" session_start codex", + "command_windows": "cmd.exe /d /c call \"./scripts/hook_router_windows.cmd\" session_start codex", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"./scripts/hook_router.py\" prompt_submit codex", + "command_windows": "cmd.exe /d /c call \"./scripts/hook_router_windows.cmd\" prompt_submit codex", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"./scripts/hook_router.py\" post_tool_use codex", + "command_windows": "cmd.exe /d /c call \"./scripts/hook_router_windows.cmd\" post_tool_use codex", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"./scripts/hook_router.py\" stop codex", + "command_windows": "cmd.exe /d /c call \"./scripts/hook_router_windows.cmd\" stop codex", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/plugins/dogdoing/hooks/claude-failure.json b/plugins/dogdoing/hooks/claude-failure.json new file mode 100644 index 0000000..79bcf5a --- /dev/null +++ b/plugins/dogdoing/hooks/claude-failure.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUseFailure": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router.py\" post_tool_failure claude", + "command_windows": "cmd.exe /d /c call \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router_windows.cmd\" post_tool_failure claude", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/hooks/hooks.json b/plugins/dogdoing/hooks/hooks.json similarity index 59% rename from hooks/hooks.json rename to plugins/dogdoing/hooks/hooks.json index 4548dc4..b921cd1 100644 --- a/hooks/hooks.json +++ b/plugins/dogdoing/hooks/hooks.json @@ -2,11 +2,11 @@ "hooks": { "SessionStart": [ { - "matcher": "", "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/notify.py\" inject", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router.py\" session_start", + "command_windows": "cmd.exe /d /c call \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router_windows.cmd\" session_start", "timeout": 5 } ] @@ -14,11 +14,11 @@ ], "UserPromptSubmit": [ { - "matcher": "", "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/notify.py\" remind", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router.py\" prompt_submit", + "command_windows": "cmd.exe /d /c call \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router_windows.cmd\" prompt_submit", "timeout": 5 } ] @@ -26,23 +26,11 @@ ], "PostToolUse": [ { - "matcher": "", "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/tracker.py\" post_tool_use", - "timeout": 5 - } - ] - } - ], - "PostToolUseFailure": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/tracker.py\" post_tool_failure", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router.py\" post_tool_use", + "command_windows": "cmd.exe /d /c call \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router_windows.cmd\" post_tool_use", "timeout": 5 } ] @@ -50,11 +38,11 @@ ], "Stop": [ { - "matcher": "", "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/notify.py\" route", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router.py\" stop", + "command_windows": "cmd.exe /d /c call \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_router_windows.cmd\" stop", "timeout": 5 } ] diff --git a/plugins/dogdoing/scripts/.npmignore b/plugins/dogdoing/scripts/.npmignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/plugins/dogdoing/scripts/.npmignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/plugins/dogdoing/scripts/hook_router.py b/plugins/dogdoing/scripts/hook_router.py new file mode 100644 index 0000000..281dc54 --- /dev/null +++ b/plugins/dogdoing/scripts/hook_router.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""将 Claude Code 与 Codex Hook 事件路由到共享 Dogdoing 逻辑。""" + +import json +import os +import sys +from typing import List, Optional, TextIO + +import notify +import runtime as shared_runtime +import tracker + + +_FAILURE_STATUSES = {"error", "errored", "fail", "failed", "failure", "denied", "cancelled"} +_CODEX_ENVIRONMENT_MARKERS = ( + "CODEX_PLUGIN_ROOT", + "CODEX_THREAD_ID", + "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", + "CODEX_CI", + "CODEX_SHELL", +) +_CODEX_CONTEXT_EVENTS = { + "session_start": "SessionStart", + "prompt_submit": "UserPromptSubmit", +} + + +# 功能:递归判断工具结果对象是否明确表示执行失败 +def is_tool_failure(payload: dict) -> bool: + for key in ("success", "ok"): + if key in payload and payload[key] is False: + return True + for key in ("is_error", "isError", "failed"): + if payload.get(key) is True: + return True + status = payload.get("status") or payload.get("outcome") + if isinstance(status, str) and status.lower() in _FAILURE_STATUSES: + return True + if payload.get("error") not in (None, "", False, {}): + return True + for key in ("exit_code", "exitCode", "returncode", "return_code"): + value = payload.get(key) + if isinstance(value, int) and not isinstance(value, bool) and value != 0: + return True + for key in ("tool_response", "tool_result", "response", "result", "output"): + nested = payload.get(key) + if isinstance(nested, dict) and is_tool_failure(nested): + return True + return False + + +# 功能:把一个归一化 Hook 事件交给注入、通知或追踪模块处理 +def handle_event(event_name: str, payload: dict, platform: str) -> str: + event = event_name.strip().lower().replace("-", "_") + host = platform.strip().lower() + if event == "session_start": + return notify.build_injection(host) + if event == "prompt_submit": + return notify.handle_prompt(payload, host) + if event == "post_tool_use": + if is_tool_failure(payload): + tracker.record_tool_failure(payload) + else: + tracker.record_tool_success(payload) + return "" + if event == "post_tool_failure": + tracker.record_tool_failure(payload) + return "" + if event == "stop": + notify.route_stop(payload) + return "" + raise ValueError(f"未知 Hook 事件:{event_name}") + + +# 功能:根据显式参数或宿主环境变量确定当前 Hook 平台 +def _resolve_platform(arguments: List[str]) -> str: + if len(arguments) > 1 and arguments[1].lower() in ("codex", "claude"): + return arguments[1].lower() + if any(os.environ.get(name) for name in _CODEX_ENVIRONMENT_MARKERS): + return "codex" + return "claude" + + +# 功能:执行 Hook CLI,输出模型上下文并用退出码报告配置错误 +def main(arguments: Optional[List[str]] = None, stdin: Optional[TextIO] = None) -> int: + args = list(sys.argv[1:] if arguments is None else arguments) + if not args: + print("缺少 Hook 事件", file=sys.stderr) + return 2 + payload = shared_runtime.read_hook_payload(stdin) + platform = _resolve_platform(args) + try: + output = handle_event(args[0], payload, platform) + except ValueError as error: + print(str(error), file=sys.stderr) + return 2 + if output: + if platform == "codex": + event_name = _CODEX_CONTEXT_EVENTS[args[0].strip().lower().replace("-", "_")] + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": event_name, + "additionalContext": output, + } + }, + ensure_ascii=False, + ) + ) + else: + print(output) + return 0 + + +if __name__ == "__main__": + shared_runtime.configure_utf8_streams() + raise SystemExit(main()) diff --git a/plugins/dogdoing/scripts/hook_router_windows.cmd b/plugins/dogdoing/scripts/hook_router_windows.cmd new file mode 100644 index 0000000..e733164 --- /dev/null +++ b/plugins/dogdoing/scripts/hook_router_windows.cmd @@ -0,0 +1,46 @@ +@echo off +setlocal EnableExtensions +chcp 65001 >nul + +set "DOGDOING_ROUTER=%~dp0hook_router.py" +set "DOGDOING_ERROR=%~dp0hook_router_windows_error.txt" + +if not defined DOGDOING_PYTHON goto codex_python +if exist "%DOGDOING_PYTHON%" goto explicit_python +type "%DOGDOING_ERROR%" 1>&2 +>&2 echo DOGDOING_PYTHON=%DOGDOING_PYTHON% +exit /b 127 + +:explicit_python +"%DOGDOING_PYTHON%" "%DOGDOING_ROUTER%" %* +exit /b %errorlevel% + +:codex_python +set "DOGDOING_CODEX_PYTHON=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +if not exist "%DOGDOING_CODEX_PYTHON%" goto path_python +"%DOGDOING_CODEX_PYTHON%" "%DOGDOING_ROUTER%" %* +exit /b %errorlevel% + +:path_python +%SystemRoot%\System32\where.exe python.exe >nul 2>nul +if errorlevel 1 goto path_python3 +python.exe "%DOGDOING_ROUTER%" %* +exit /b %errorlevel% + +:path_python3 +%SystemRoot%\System32\where.exe python3.exe >nul 2>nul +if errorlevel 1 goto launcher_python +python3.exe "%DOGDOING_ROUTER%" %* +exit /b %errorlevel% + +:launcher_python +%SystemRoot%\System32\where.exe py.exe >nul 2>nul +if errorlevel 1 goto missing_python +py.exe -3 -c "import sys" >nul 2>nul +if errorlevel 1 goto missing_python +py.exe -3 "%DOGDOING_ROUTER%" %* +exit /b %errorlevel% + +:missing_python +type "%DOGDOING_ERROR%" 1>&2 +exit /b 127 diff --git a/plugins/dogdoing/scripts/hook_router_windows_error.txt b/plugins/dogdoing/scripts/hook_router_windows_error.txt new file mode 100644 index 0000000..242dbff --- /dev/null +++ b/plugins/dogdoing/scripts/hook_router_windows_error.txt @@ -0,0 +1 @@ +[刀盾狗] 未找到可用的 Python 3,无法执行 Hook。 diff --git a/scripts/notify.py b/plugins/dogdoing/scripts/notify.py similarity index 64% rename from scripts/notify.py rename to plugins/dogdoing/scripts/notify.py index e28e21b..1f65763 100644 --- a/scripts/notify.py +++ b/plugins/dogdoing/scripts/notify.py @@ -10,21 +10,15 @@ python notify.py sound — Play sound for event """ -import json import subprocess import sys -import io +from datetime import datetime from pathlib import Path -if sys.platform == 'win32': - for stream_name in ('stdin', 'stdout', 'stderr'): - stream = getattr(sys, stream_name) - if hasattr(stream, 'buffer'): - setattr(sys, stream_name, - io.TextIOWrapper(stream.buffer, encoding='utf-8', errors='replace')) +import runtime as shared_runtime -TITLE = "Dogdoing" -DEFAULT_MSG = "What the dog doing???" +TITLE = "刀盾狗" +DEFAULT_MSG = "刀盾狗:任务已完成。" SOUND_EVENTS = ("complete", "error", "combo", "drog") _PLUGIN_ROOT = Path(__file__).resolve().parent.parent _ICON = _PLUGIN_ROOT / "assets" / "icons" / "dogdoing.png" @@ -35,35 +29,26 @@ # Config # ═══════════════════════════════════════════════════════════════════════════ +# 功能:从共享配置文件读取通知模块所需的单个配置项 def _read_setting(key: str, default=None): """Read a setting from settings.json.""" - import os - candidates = [] - for env_var in ("CLAUDE_PLUGIN_ROOT", "CLAUDE_PLUGIN_DIR"): - val = os.environ.get(env_var) - if val: - candidates.append(Path(val) / "settings.json") - candidates.append(_PLUGIN_ROOT / "settings.json") - - for p in candidates: - try: - data = json.loads(p.read_text(encoding="utf-8")) - val = data.get(key) - if val is not None: - return val - except (OSError, json.JSONDecodeError, ValueError, TypeError): - continue - return default + return shared_runtime.read_setting(key, default) +# 功能:读取并校验 0 到 3 范围内的通知级别 def _read_notify_level() -> int: - return int(_read_setting("notify_level", 3)) + try: + level = int(_read_setting("notify_level", 3)) + except (TypeError, ValueError): + return 3 + return level if level in (0, 1, 2, 3) else 3 # ═══════════════════════════════════════════════════════════════════════════ # Desktop notification (cross-platform) # ═══════════════════════════════════════════════════════════════════════════ +# 功能:根据当前操作系统发送桌面通知并在失败时降级响铃 def desktop_notify(msg: str = DEFAULT_MSG): """Send a desktop notification. Works on Windows/macOS/Linux.""" if sys.platform == "win32": @@ -119,6 +104,7 @@ def desktop_notify(msg: str = DEFAULT_MSG): """ +# 功能:通过 Windows Runtime 和 PowerShell 异步显示 Toast 通知 def _win_toast(title: str, msg: str, icon: str = "", show_title: bool = True): """Show a Windows toast notification (inline, no external deps).""" script = _WIN_TOAST_TEMPLATE.format( @@ -147,11 +133,13 @@ def _win_toast(title: str, msg: str, icon: str = "", show_title: bool = True): # Sound player (cross-platform) # ═══════════════════════════════════════════════════════════════════════════ +# 功能:查找指定事件对应的本地 WAV 音频资源 def _find_sound(event: str) -> "Path | None": wav = _PLUGIN_ROOT / "assets" / "sounds" / f"{event}.wav" return wav if wav.exists() else None +# 功能:使用当前操作系统可用的播放器播放指定事件声音 def play_sound(event: str): if event not in SOUND_EVENTS: return @@ -183,24 +171,11 @@ def play_sound(event: str): # Route (Stop hook) # ═══════════════════════════════════════════════════════════════════════════ -def _route(): - """Route Stop hook payload to sound + desktop notification.""" - try: - raw = sys.stdin.read() - except Exception: - return - - # Parse stop_reason - stop_reason = "" - if raw.strip(): - try: - data = json.loads(raw) - stop_reason = data.get("stop_reason", "") - except (json.JSONDecodeError, ValueError): - pass - - # Silent on tool_use — agent is still working - if stop_reason == "tool_use": +# 功能:根据结束事件载荷选择桌面通知和完成声音 +def route_stop(payload: dict) -> None: + stop_reason = str(payload.get("stop_reason") or payload.get("reason") or "").lower() + status = str(payload.get("status") or "").lower() + if stop_reason in ("tool_use", "in_progress") or status in ("tool_use", "in_progress"): return level = _read_notify_level() @@ -212,108 +187,122 @@ def _route(): play_sound("complete") +# 功能:兼容旧通知 CLI,从标准输入读取停止事件并转交数据 API +def _route(): + route_stop(shared_runtime.read_hook_payload()) + + # ═══════════════════════════════════════════════════════════════════════════ # Hook output (SessionStart / UserPromptSubmit) # ═══════════════════════════════════════════════════════════════════════════ -_REMIND_SUBAGENT = ( - "[Dogdoing] You MUST include Dogdoing in this response. " - "Use Agent tool with subagent_type dogdoing:dogdoing (or dogdoing) to launch parallel work, " - "OR append a Dogdoing section at the end. " - "Chinese context = Chinese output. Bark sounds: CN use 旺 (NOT 汪), EN use Wow (NOT Woof)." +_REMIND_SUBAGENT_CLAUDE = ( + "[刀盾狗] 本次回复必须包含刀盾狗。请使用 Agent 工具和 " + "subagent_type dogdoing:dogdoing(不可用时使用 dogdoing)启动可并行的具体工作," + "并在最终回复前等待结果;工具不可用时,由主代理完成同等验证并附上刀盾狗贡献。" + "所有刀盾狗提示必须使用简体中文,叫声使用“旺”,不能使用“汪”。" +) + +_REMIND_SUBAGENT_CODEX = ( + "[刀盾狗] 本次回复必须包含刀盾狗。请使用 collaboration.spawn_agent," + "并设置 task_name 为 dogdoing,启动可并行的具体工作;最终回复前必须等待并汇总结果。" + "协作工具不可用时,由主代理完成同等验证并附上刀盾狗贡献。" + "所有刀盾狗提示必须使用简体中文,叫声使用“旺”,不能使用“汪”。" ) _REMIND_CHEER = ( - "[Dogdoing] Append a Dogdoing cheer or bark at the end of your response. " - "Chinese context = Chinese output. CN use 旺 (NOT 汪), EN use Wow (NOT Woof)." + "[刀盾狗] 请在回复末尾附上刀盾狗喝彩或叫声。" + "所有提示必须使用简体中文,叫声使用“旺”,不能使用“汪”。" ) -def _inject(): - """SessionStart hook: output INJECT*.md based on config.""" +# 功能:读取插件内一段注入指令,文件缺失时返回空文本 +def _read_injection_file(name: str) -> str: + path = _PLUGIN_ROOT / name try: - sys.stdin.read() - except Exception: - pass + return path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeError): + return "" + + +# 功能:按照宿主平台、配置开关和时间生成完整会话注入文本 +def build_injection(platform: str, hour: "int | None" = None) -> str: subagent = _read_setting("subagent_enabled", True) cheer = _read_setting("cheer_enabled", True) drog = _read_setting("drog_enabled", True) if not subagent and not cheer and not drog: - return - # Base - base = _PLUGIN_ROOT / "INJECT.md" - if base.exists(): - print(base.read_text(encoding="utf-8")) - # Subagent section + return "" + + sections = [_read_injection_file("INJECT.md")] if subagent: - f = _PLUGIN_ROOT / "INJECT_SUBAGENT.md" - if f.exists(): - print(f.read_text(encoding="utf-8")) - # Cheer section + name = "INJECT_SUBAGENT_CODEX.md" if platform.lower() == "codex" else "INJECT_SUBAGENT.md" + sections.append(_read_injection_file(name)) if cheer: - f = _PLUGIN_ROOT / "INJECT_CHEER.md" - if f.exists(): - print(f.read_text(encoding="utf-8")) - # Drog section + sections.append(_read_injection_file("INJECT_CHEER.md")) if drog: - f = _PLUGIN_ROOT / "INJECT_DROG.md" - if f.exists(): - content = f.read_text(encoding="utf-8") - # Strip late night section unless 2:00-5:00 - from datetime import datetime - hour = datetime.now().hour - if 2 <= hour < 5: - print(content) - else: - # Remove [LATE NIGHT MODE] section - marker = "## [LATE NIGHT MODE]" - idx = content.find(marker) - if idx >= 0: - print(content[:idx].rstrip()) - else: - print(content) - - -def _remind(): - """UserPromptSubmit hook: output reminder based on config + detect ~drog.""" - try: - raw = sys.stdin.read() - except Exception: - raw = "" - # Detect ~drog trigger - if raw.strip(): - try: - payload = json.loads(raw) - user_input = payload.get("user_input", "") - except (json.JSONDecodeError, ValueError): - user_input = raw - if "~drog" in user_input.lower(): + drog_content = _read_injection_file("INJECT_DROG.md") + current_hour = datetime.now().hour if hour is None else hour + if not 2 <= current_hour < 5: + marker = "## [深夜模式]" + marker_index = drog_content.find(marker) + if marker_index >= 0: + drog_content = drog_content[:marker_index].rstrip() + sections.append(drog_content) + return "\n\n".join(section for section in sections if section) + + +# 功能:兼容旧注入 CLI,输出指定平台的会话上下文 +def _inject(platform: str = "claude"): + shared_runtime.read_hook_payload() + content = build_injection(platform) + if content: + print(content) + + +# 功能:从 Claude 或 Codex 的提示载荷中提取用户输入文本 +def _extract_user_input(payload: dict) -> str: + for key in ("user_input", "prompt", "user_prompt", "message", "input"): + value = payload.get(key) + if isinstance(value, str): + return value + return "" + + +# 功能:处理每轮用户提示,记录 Drog 触发并返回宿主对应的提醒文本 +def handle_prompt(payload: dict, platform: str) -> str: + user_input = _extract_user_input(payload) + if _read_setting("drog_enabled", True) and "~drog" in user_input.lower(): _set_drog_triggered() subagent = _read_setting("subagent_enabled", True) cheer = _read_setting("cheer_enabled", True) if subagent: - print(_REMIND_SUBAGENT) - elif cheer: - print(_REMIND_CHEER) + if platform.lower() == "codex": + return _REMIND_SUBAGENT_CODEX + return _REMIND_SUBAGENT_CLAUDE + return _REMIND_CHEER if cheer else "" +# 功能:兼容旧提醒 CLI,从标准输入读取提示并输出提醒文本 +def _remind(platform: str = "claude"): + reminder = handle_prompt(shared_runtime.read_hook_payload(), platform) + if reminder: + print(reminder) + + +# 功能:在共享状态文件中记录 Drog 已被用户触发 def _set_drog_triggered(): """Write drog_triggered=true to state.json for tracker to pick up.""" - state_dir = Path.home() / ".dogdoing" - state_file = state_dir / "state.json" - try: - data = json.loads(state_file.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - data = {} + state_file = shared_runtime.get_state_dir() / "state.json" + data = shared_runtime.read_json(state_file, {}) data["drog_triggered"] = True - state_dir.mkdir(parents=True, exist_ok=True) - state_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + shared_runtime.write_json_atomic(state_file, data) # ═══════════════════════════════════════════════════════════════════════════ # CLI # ═══════════════════════════════════════════════════════════════════════════ +# 功能:解析通知脚本的兼容 CLI 子命令并执行对应操作 def main(): cmd = sys.argv[1] if len(sys.argv) > 1 else "desktop" if cmd == "desktop": @@ -332,9 +321,10 @@ def main(): elif cmd == "remind": _remind() else: - print(f"Usage: {sys.argv[0]} route|inject|remind|desktop [msg]|sound ") + print(f"用法:{sys.argv[0]} route|inject|remind|desktop [消息]|sound <事件>") sys.exit(1) if __name__ == "__main__": + shared_runtime.configure_utf8_streams() main() diff --git a/plugins/dogdoing/scripts/runtime.py b/plugins/dogdoing/scripts/runtime.py new file mode 100644 index 0000000..bf39b81 --- /dev/null +++ b/plugins/dogdoing/scripts/runtime.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Dogdoing 在 Claude Code 与 Codex 间共享的轻量运行时。""" + +import copy +import json +import os +import sys +from pathlib import Path +from typing import Any, Mapping, Optional, TextIO + + +_ENV_PLUGIN_ROOTS = ( + "CODEX_PLUGIN_ROOT", + "PLUGIN_ROOT", + "CLAUDE_PLUGIN_ROOT", + "CLAUDE_PLUGIN_DIR", +) + + +# 功能:按宿主优先级定位当前 Dogdoing 插件根目录 +def find_plugin_root() -> Path: + for variable_name in _ENV_PLUGIN_ROOTS: + value = os.environ.get(variable_name) + if value: + return Path(value).expanduser().resolve() + return Path(__file__).resolve().parent.parent + + +# 功能:读取 JSON 对象,文件缺失或损坏时返回默认值的独立副本 +def read_json(path: Path, default: Mapping[str, Any]) -> dict: + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except (OSError, UnicodeError, json.JSONDecodeError, ValueError, TypeError): + pass + return copy.deepcopy(dict(default)) + + +# 功能:通过同目录临时文件原子替换目标 JSON,避免留下不完整状态 +def write_json_atomic(path: Path, data: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + f".{os.getpid()}.tmp") + try: + temporary.write_text( + json.dumps(dict(data), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + temporary.replace(path) + finally: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + + +# 功能:从当前插件的 settings.json 读取一个配置项并保留 false 等有效值 +def read_setting(key: str, default: Any = None, plugin_root: Optional[Path] = None) -> Any: + root = plugin_root.resolve() if plugin_root else find_plugin_root() + settings = read_json(root / "settings.json", {}) + return settings[key] if key in settings else default + + +# 功能:从 Hook 标准输入读取 JSON 对象,无法使用的输入安全回退为空对象 +def read_hook_payload(stream: Optional[TextIO] = None) -> dict: + source = stream if stream is not None else sys.stdin + try: + raw = source.read() + if not raw.strip(): + return {} + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except (OSError, UnicodeError, json.JSONDecodeError, ValueError, TypeError): + return {} + + +# 功能:返回用户状态目录,并允许测试或高级配置通过环境变量覆盖 +def get_state_dir() -> Path: + custom = os.environ.get("DOGDOING_STATE_DIR") + if custom: + return Path(custom).expanduser().resolve() + return Path.home() / ".dogdoing" + + +# 功能:在 Windows CLI 入口原地调整标准流编码且不替换底层缓冲区 +def configure_utf8_streams() -> None: + if sys.platform != "win32": + return + for stream_name in ("stdin", "stdout", "stderr"): + stream = getattr(sys, stream_name) + reconfigure = getattr(stream, "reconfigure", None) + if callable(reconfigure): + try: + reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass diff --git a/scripts/tracker.py b/plugins/dogdoing/scripts/tracker.py similarity index 59% rename from scripts/tracker.py rename to plugins/dogdoing/scripts/tracker.py index 4cc4cf6..ca5295e 100644 --- a/scripts/tracker.py +++ b/plugins/dogdoing/scripts/tracker.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""Dogdoing Tracker — 成就系统 / 连击系统 / 错误追踪 / This is Fine +"""刀盾狗追踪器:成就系统、连击系统和错误追踪。 CLI: python tracker.py post_tool_use # PostToolUse hook @@ -10,21 +10,12 @@ import json import subprocess import sys -import io from datetime import datetime, timezone from pathlib import Path -if sys.platform == "win32": - for stream_name in ("stdin", "stdout", "stderr"): - stream = getattr(sys, stream_name) - if hasattr(stream, "buffer"): - setattr(sys, stream_name, - io.TextIOWrapper(stream.buffer, encoding="utf-8", errors="replace")) +import runtime as shared_runtime _PLUGIN_ROOT = Path(__file__).resolve().parent.parent -_STATE_DIR = Path.home() / ".dogdoing" -_STATE_FILE = _STATE_DIR / "state.json" -_ACHIEVEMENTS_FILE = _STATE_DIR / "achievements.json" # ── 连击级别 ────────────────────────────────────────────────────────── COMBO_LEVELS = [ @@ -36,11 +27,11 @@ # ── 成就定义 ────────────────────────────────────────────────────────── ACHIEVEMENTS = { - "first_summon": "🗡️ 初出茅庐 — First summon!", - "ten_tasks": "🛡️ 刀盾合璧 — 10 tasks completed!", - "combo_5": "🔥 连旺 — 5x combo streak!", - "bug_found": "💀 狗头保命 — First error detected!", - "drog_awakened": "🐸 Drog 觉醒 — Drog awakened!", + "first_summon": "🗡️ 初出茅庐:首次召唤刀盾狗!", + "ten_tasks": "🛡️ 刀盾合璧:已完成十次任务!", + "combo_5": "🔥 连旺:达成五连击!", + "bug_found": "💀 狗头保命:首次检测到错误!", + "drog_awakened": "🐸 蛙盾觉醒:蛙盾已苏醒!", } # ── 默认状态 ────────────────────────────────────────────────────────── @@ -57,61 +48,47 @@ # ── 配置读取 ────────────────────────────────────────────────────────── +# 功能:从共享配置文件读取追踪模块所需的单个配置项 def _read_setting(key: str, default=None): - import os - for env_var in ("CLAUDE_PLUGIN_ROOT", "CLAUDE_PLUGIN_DIR"): - val = os.environ.get(env_var) - if val: - try: - data = json.loads((Path(val) / "settings.json").read_text(encoding="utf-8")) - v = data.get(key) - if v is not None: - return v - except (OSError, json.JSONDecodeError): - pass - try: - data = json.loads((_PLUGIN_ROOT / "settings.json").read_text(encoding="utf-8")) - return data.get(key, default) - except (OSError, json.JSONDecodeError): - return default + return shared_runtime.read_setting(key, default) +# 功能:判断成就、连击和错误追踪功能是否启用 def _is_enabled() -> bool: return bool(_read_setting("tracker_enabled", True)) # ── 状态读写 ────────────────────────────────────────────────────────── -def _load_state() -> dict: - try: - data = json.loads(_STATE_FILE.read_text(encoding="utf-8")) - merged = {**_DEFAULT_STATE, **data} - return merged - except (OSError, json.JSONDecodeError): - return dict(_DEFAULT_STATE) +# 功能:读取当前用户状态并补齐新版本增加的默认字段 +def load_state() -> dict: + state_file = shared_runtime.get_state_dir() / "state.json" + data = shared_runtime.read_json(state_file, _DEFAULT_STATE) + return {**_DEFAULT_STATE, **data} -def _save_state(state: dict): - _STATE_DIR.mkdir(parents=True, exist_ok=True) - _STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") +# 功能:原子保存连击、错误、累计调用和 Drog 状态 +def save_state(state: dict) -> None: + state_file = shared_runtime.get_state_dir() / "state.json" + shared_runtime.write_json_atomic(state_file, state) -def _load_achievements() -> dict: - try: - data = json.loads(_ACHIEVEMENTS_FILE.read_text(encoding="utf-8")) - merged = {**_DEFAULT_ACHIEVEMENTS, **data} - return merged - except (OSError, json.JSONDecodeError): - return dict(_DEFAULT_ACHIEVEMENTS) +# 功能:读取成就解锁时间并补齐尚未出现的成就项 +def load_achievements() -> dict: + achievements_file = shared_runtime.get_state_dir() / "achievements.json" + data = shared_runtime.read_json(achievements_file, _DEFAULT_ACHIEVEMENTS) + return {**_DEFAULT_ACHIEVEMENTS, **data} -def _save_achievements(ach: dict): - _STATE_DIR.mkdir(parents=True, exist_ok=True) - _ACHIEVEMENTS_FILE.write_text(json.dumps(ach, ensure_ascii=False, indent=2), encoding="utf-8") +# 功能:原子保存全部成就的解锁时间 +def save_achievements(achievements: dict) -> None: + achievements_file = shared_runtime.get_state_dir() / "achievements.json" + shared_runtime.write_json_atomic(achievements_file, achievements) # ── 声音播放 ────────────────────────────────────────────────────────── +# 功能:通过通知脚本非阻塞播放成就或连击声音 def _play_sound(event: str): """Non-blocking sound play via notify.py.""" script = _PLUGIN_ROOT / "scripts" / "notify.py" @@ -125,6 +102,7 @@ def _play_sound(event: str): # ── 成就解锁 ────────────────────────────────────────────────────────── +# 功能:首次解锁指定成就并发送提示与桌面通知 def _unlock(ach: dict, achievement_id: str) -> bool: """Unlock an achievement if not already unlocked. Returns True if newly unlocked.""" if ach.get(achievement_id) is not None: @@ -146,6 +124,7 @@ def _unlock(ach: dict, achievement_id: str) -> bool: # ── 连击级别计算 ────────────────────────────────────────────────────── +# 功能:根据连续成功次数计算当前连击等级 def _combo_level_for(combo: int) -> int: """Return the combo level (0-4) for a given combo count.""" level = 0 @@ -157,18 +136,35 @@ def _combo_level_for(combo: int) -> int: # ── PostToolUse 处理 ────────────────────────────────────────────────── -def post_tool_use(): +# 功能:判断一次 Claude 或 Codex 工具调用是否召唤了刀盾狗 +def _is_dogdoing_summon(data: dict) -> bool: + tool_name = str(data.get("tool_name") or data.get("tool") or "").lower() + tool_input = data.get("tool_input") or data.get("input") or data.get("arguments") or {} + if isinstance(tool_input, str): + try: + tool_input = json.loads(tool_input) + except (json.JSONDecodeError, ValueError, TypeError): + tool_input = {} + if not isinstance(tool_input, dict): + return False + if tool_name == "agent": + return "dogdoing" in str(tool_input.get("subagent_type", "")).lower() + if "spawn_agent" in tool_name: + identity = " ".join( + str(tool_input.get(key, "")) + for key in ("task_name", "name", "message", "prompt") + ) + return "dogdoing" in identity.lower() or "刀盾狗" in identity + return False + + +# 功能:记录一次成功工具调用并计算连击、召唤次数和相关成就 +def record_tool_success(data: dict) -> None: if not _is_enabled(): return - # Read stdin - try: - raw = sys.stdin.read() - data = json.loads(raw) if raw.strip() else {} - except (json.JSONDecodeError, Exception): - data = {} - state = _load_state() - ach = _load_achievements() + state = load_state() + achievements = load_achievements() # Reset error streak state["error_streak"] = 0 @@ -185,47 +181,40 @@ def post_tool_use(): _play_sound("combo") # Check dogdoing subagent summon - tool_name = data.get("tool_name", "") - tool_input = data.get("tool_input", {}) - if isinstance(tool_input, str): - try: - tool_input = json.loads(tool_input) - except (json.JSONDecodeError, ValueError): - tool_input = {} - subagent_type = tool_input.get("subagent_type", "") if isinstance(tool_input, dict) else "" - if tool_name == "Agent" and "dogdoing" in str(subagent_type).lower(): + if _is_dogdoing_summon(data): state["summon_count"] = state.get("summon_count", 0) + 1 - _unlock(ach, "first_summon") + _unlock(achievements, "first_summon") # Check ten_tasks if state["total_tasks"] >= 10: - _unlock(ach, "ten_tasks") + _unlock(achievements, "ten_tasks") # Check combo_5 if state["combo"] >= 5: - _unlock(ach, "combo_5") + _unlock(achievements, "combo_5") # Check drog_triggered if state.get("drog_triggered"): - _unlock(ach, "drog_awakened") + _unlock(achievements, "drog_awakened") state["drog_triggered"] = False - _save_state(state) - _save_achievements(ach) + save_state(state) + save_achievements(achievements) + + +# 功能:兼容旧 PostToolUse CLI,从标准输入读取成功事件 +def post_tool_use(): + record_tool_success(shared_runtime.read_hook_payload()) # ── PostToolUseFailure 处理 ──────────────────────────────────────────── -def post_tool_failure(): +# 功能:记录一次失败工具调用并重置连击、累计错误和解锁错误成就 +def record_tool_failure(data: dict) -> None: if not _is_enabled(): return - # Read stdin - try: - raw = sys.stdin.read() - except Exception: - pass - state = _load_state() - ach = _load_achievements() + state = load_state() + achievements = load_achievements() # Reset combo state["combo"] = 0 @@ -236,21 +225,27 @@ def post_tool_failure(): _play_sound("error") # bug_found achievement - _unlock(ach, "bug_found") + _unlock(achievements, "bug_found") # This is Fine mode if state["error_streak"] >= 3: print( - "\n🐕 刀盾狗:一切正常。旺。(This is fine. Wow.)\n", + "\n🐕 刀盾狗:一切正常。旺。\n", file=sys.stderr, ) - _save_state(state) - _save_achievements(ach) + save_state(state) + save_achievements(achievements) + + +# 功能:兼容旧 PostToolUseFailure CLI,从标准输入读取失败事件 +def post_tool_failure(): + record_tool_failure(shared_runtime.read_hook_payload()) # ── CLI ─────────────────────────────────────────────────────────────── +# 功能:解析追踪脚本的兼容 CLI 子命令并处理标准输入事件 def main(): cmd = sys.argv[1] if len(sys.argv) > 1 else "" if cmd == "post_tool_use": @@ -258,9 +253,10 @@ def main(): elif cmd == "post_tool_failure": post_tool_failure() else: - print(f"Usage: {sys.argv[0]} post_tool_use|post_tool_failure", file=sys.stderr) + print(f"用法:{sys.argv[0]} post_tool_use|post_tool_failure", file=sys.stderr) sys.exit(1) if __name__ == "__main__": + shared_runtime.configure_utf8_streams() main() diff --git a/settings.json b/plugins/dogdoing/settings.json similarity index 100% rename from settings.json rename to plugins/dogdoing/settings.json diff --git a/plugins/dogdoing/skills/dogdoing/SKILL.md b/plugins/dogdoing/skills/dogdoing/SKILL.md new file mode 100644 index 0000000..4c7bf24 --- /dev/null +++ b/plugins/dogdoing/skills/dogdoing/SKILL.md @@ -0,0 +1,50 @@ +--- +name: dogdoing +description: "手动召唤刀盾狗进行深度分析、验证和协作,同时支持 Codex 与 Claude Code。用户输入 $dogdoing、/dogdoing、~dogdoing,或明确要求刀盾狗分析当前任务时使用。" +--- + +# 刀盾狗手动召唤 + +根据当前宿主提供的工具召唤刀盾狗,针对 `$ARGUMENTS` 或当前对话中的任务做深度分析。所有输出使用简体中文,叫声使用“旺”,不能使用“汪”。 + +## 宿主选择 + +### Codex + +当 `collaboration.spawn_agent` 可用时,使用 Codex 原生协作工具: + +```text +collaboration.spawn_agent( + task_name="dogdoing", + message="你是刀盾狗。请针对以下主题进行深入分析、检查边界并给出可执行结论:$ARGUMENTS", + fork_turns="all" +) +``` + +调用后使用 `collaboration.wait_agent` 等待结果。手动召唤必须在当前回复中收集并呈现结果,不能在代理仍运行时提前结束。 + +### Claude Code + +当 Claude 的 `Agent` 工具可用时,使用: + +```text +Agent(subagent_type="dogdoing:dogdoing", prompt="$ARGUMENTS") +``` + +如果命名空间形式不存在,再尝试 `subagent_type="dogdoing"`。手动召唤以前台方式运行。 + +### 本地回退 + +多代理工具不可用时,不中断用户任务。由主代理按照同一人格和检查深度直接完成分析,并用刀盾狗输出前缀呈现结果。 + +## 分析要求 + +1. 从 `$ARGUMENTS` 提取焦点;没有参数时分析最近的任务上下文。 +2. 阅读真实代码和配置,必要时运行命令、测试或查询相关文档。 +3. 检查边界条件、跨文件影响、兼容性、错误处理和验证缺口。 +4. 给出推荐方案、取舍和可执行下一步,不只复述问题。 +5. 如果任务允许修改且用户已授权,完成实际工作并验证结果。 + +## 输出格式 + +`🐕 刀盾狗:[分析或实际贡献]` diff --git a/plugins/dogdoing/skills/dogfood/SKILL.md b/plugins/dogdoing/skills/dogfood/SKILL.md new file mode 100644 index 0000000..32e83e5 --- /dev/null +++ b/plugins/dogdoing/skills/dogfood/SKILL.md @@ -0,0 +1,59 @@ +--- +name: dogfood +description: "审查 Dogdoing 自身的 Claude Code 与 Codex 插件实现。用户输入 $dogfood、/dogfood,或要求检查 Dogdoing 的 bug、性能、兼容性、代码风格和测试时使用。" +--- + +# Dogfood 双平台自检 + +对 Dogdoing 插件做诚实、可复现的自我审查。使用 `$ARGUMENTS` 限定关注方向;未提供参数时覆盖 bug、性能、兼容性、代码风格和测试。 + +## 检查范围 + +先定位仓库根目录和 `plugins/dogdoing` 插件根目录,然后检查: + +- Codex 清单:`.codex-plugin/plugin.json` +- Claude 清单:`.claude-plugin/plugin.json` +- Codex Hook:`hooks.json` +- Claude Hook:`hooks/hooks.json` +- Codex Marketplace:`.agents/plugins/marketplace.json` +- Claude Marketplace:`.claude-plugin/marketplace.json` +- 运行脚本:`scripts/*.py` +- Skill:`skills/*/SKILL.md` +- 代理人格:`agents/dogdoing.md` +- 注入内容:`INJECT*.md` +- 自动化测试和 README + +安装缓存中可能没有 Marketplace 文件;这种情况下明确说明该项只能在源码仓库验证,不把“文件不在安装包内”误报为缺陷。 + +## 审查要求 + +1. 运行现有测试并记录真实输出。 +2. 检查两个清单的版本、资源路径和 Skill 目录是否一致。 +3. 检查每个 Hook 事件是否由宿主支持,命令路径是否跨平台可执行。 +4. 检查 Codex `collaboration.spawn_agent` 与 Claude `Agent` 是否都有等价路径。 +5. 检查通知、声音、五项成就、四级连击、错误追踪、Drog 和全中文提示。 +6. 报告具体文件与行号,区分已验证问题、风险和测试缺口。 +7. 所有上下文使用简体中文输出,叫声只使用“旺”。 + +## 输出格式 + +```text +🐕 刀盾狗自检报告 + +检查范围: +- [实际读取的文件和执行的命令] + +发现问题: +1. [严重程度] 文件:行号 - 问题、影响和证据 + +良好实践: +- [有证据的优点] + +建议: +- [按优先级排序的具体改进] + +测试结果: +- [命令与通过/失败数量] +``` + +不得为了维护插件形象而隐藏问题。没有发现问题时,也要列出仍未覆盖的运行环境或人工验证风险。 diff --git a/skills/dogdoing/SKILL.md b/skills/dogdoing/SKILL.md deleted file mode 100644 index 9db5f7d..0000000 --- a/skills/dogdoing/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: dogdoing -description: "Summon Dogdoing (刀盾狗) for deep analysis and help. | 手动召唤刀盾狗进行深度分析和帮忙。" -disable-model-invocation: true -allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Agent, WebSearch, WebFetch ---- - -# /dogdoing — 手动召唤刀盾狗 - -Launch the dogdoing subagent to analyze the current context and provide help. - -Use `Agent(subagent_type="dogdoing:dogdoing", ...)` to spawn it. If that fails, try `subagent_type="dogdoing"`. - -IMPORTANT: Do NOT use `run_in_background=true` for manual summon. Run in foreground so the user can see Dogdoing's analysis in real-time. - -## Behavior - -- If `$ARGUMENTS` is provided, focus on that specific topic. -- If no arguments, analyze the recent conversation context and: - 1. Identify the current problem or task - 2. Perform deep analysis — read relevant code, search for related issues, consider edge cases - 3. Provide the best solution approach / strategy / path forward - 4. Explain trade-offs and reasoning - -The goal is NOT just to execute a subtask, but to think deeply and offer strategic guidance to help the user make the best decision. - -Language rule: Chinese context → Chinese output. Otherwise → English. diff --git a/skills/dogfood/SKILL.md b/skills/dogfood/SKILL.md deleted file mode 100644 index bc0b959..0000000 --- a/skills/dogfood/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: dogfood -description: 让刀盾狗 review 自己的插件代码 — Dogfooding! -argument_description: 可选:聚焦方面(bug/performance/compat/style) ---- - -# /dogfood — 刀盾狗自我审查 - -You are Dogdoing (刀盾狗), and you're about to review YOUR OWN plugin code. This is dogfooding — eating your own dog food. 旺! - -## 任务 - -Review the Dogdoing plugin codebase for issues. Focus on: - -1. **Bug 检测** — 逻辑错误、边界条件、异常处理缺失 -2. **性能** — 不必要的 I/O、阻塞操作、超时风险 -3. **跨平台兼容性** — Windows/macOS/Linux 路径、命令、编码问题 -4. **代码风格** — 一致性、可读性、冗余代码 - -$ARGUMENTS - -## 输出格式 - -``` -🐕 刀盾狗自检报告 -═══════════════════ - -🔍 检查范围: [列出检查的文件] - -🐛 发现问题: - 1. [严重程度] 文件:行号 — 描述 - 2. ... - -✅ 良好实践: - - ... - -📋 建议: - - ... - -🐕 自检完毕!旺! -``` - -## 规则 - -- 读取插件根目录下所有 .py、.json、.md 文件 -- 如果 `$ARGUMENTS` 指定了方面,只聚焦该方面 -- 中文环境用中文输出,英文环境用英文 -- 诚实报告问题,不要护短——好刀盾,敢自省! diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8beb325 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Dogdoing 自动化测试包。""" diff --git a/tests/test_code_style.py b/tests/test_code_style.py new file mode 100644 index 0000000..71da400 --- /dev/null +++ b/tests/test_code_style.py @@ -0,0 +1,44 @@ +"""Python 代码注释和语法约束测试。""" + +import ast +import re +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_SCRIPTS = REPO_ROOT / "plugins" / "dogdoing" / "scripts" +TESTS_DIR = REPO_ROOT / "tests" +CHINESE_COMMENT = re.compile(r"^# 功能:.*[\u4e00-\u9fff]") + + +# 功能:验证全部 Python 定义遵守项目中文功能注释约定 +class PythonStyleTests(unittest.TestCase): + # 功能:枚举插件与测试目录中的所有 Python 源文件 + def python_files(self): + return sorted(PLUGIN_SCRIPTS.glob("*.py")) + sorted(TESTS_DIR.glob("*.py")) + + # 功能:验证每个 Python 文件都能被 AST 正确解析 + def test_python_files_are_valid_syntax(self): + for path in self.python_files(): + with self.subTest(path=path): + ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + # 功能:验证每个函数和类定义前都有描述用途的中文注释 + def test_python_definitions_have_chinese_function_comments(self): + missing = [] + for path in self.python_files(): + lines = path.read_text(encoding="utf-8").splitlines() + tree = ast.parse("\n".join(lines), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + previous = lines[node.lineno - 2].strip() if node.lineno > 1 else "" + if not CHINESE_COMMENT.match(previous): + relative = path.relative_to(REPO_ROOT) + missing.append(f"{relative}:{node.lineno} {node.name}") + self.assertEqual([], missing, "缺少中文功能注释:\n" + "\n".join(missing)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cross_platform_notify.py b/tests/test_cross_platform_notify.py new file mode 100644 index 0000000..ff0340f --- /dev/null +++ b/tests/test_cross_platform_notify.py @@ -0,0 +1,111 @@ +"""Windows、macOS 与 Linux 通知分支测试。""" + +import io +import sys +import types +import unittest +from contextlib import redirect_stderr +from pathlib import Path +from unittest import mock + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] / "plugins" / "dogdoing" +SCRIPTS_DIR = PLUGIN_ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +import notify # noqa: E402 + + +# 功能:验证每种桌面系统使用正确的通知和音频实现 +class CrossPlatformNotifyTests(unittest.TestCase): + # 功能:验证 Windows 桌面通知使用内置 Toast 实现 + def test_windows_desktop_uses_toast(self): + with mock.patch.object(notify.sys, "platform", "win32"), mock.patch.object( + notify, + "_win_toast", + ) as toast: + notify.desktop_notify("完成") + toast.assert_called_once() + self.assertEqual("完成", toast.call_args.args[1]) + + # 功能:验证 macOS 桌面通知调用 osascript + def test_macos_desktop_uses_osascript(self): + with mock.patch.object(notify.sys, "platform", "darwin"), mock.patch.object( + notify.subprocess, + "run", + ) as run: + notify.desktop_notify("done") + self.assertEqual("osascript", run.call_args.args[0][0]) + + # 功能:验证 Linux 桌面通知调用 notify-send 并携带图标 + def test_linux_desktop_uses_notify_send(self): + completed = mock.Mock(returncode=0) + with mock.patch.object(notify.sys, "platform", "linux"), mock.patch.object( + notify.subprocess, + "run", + return_value=completed, + ) as run: + notify.desktop_notify("done") + command = run.call_args.args[0] + self.assertEqual("notify-send", command[0]) + self.assertIn("-i", command) + + # 功能:验证 Windows 音频使用 winsound 播放本地 WAV + def test_windows_sound_uses_winsound(self): + winsound = types.SimpleNamespace( + SND_FILENAME=1, + PlaySound=mock.Mock(), + ) + with mock.patch.object(notify.sys, "platform", "win32"), mock.patch.dict( + sys.modules, + {"winsound": winsound}, + ): + notify.play_sound("complete") + winsound.PlaySound.assert_called_once() + self.assertTrue(winsound.PlaySound.call_args.args[0].endswith("complete.wav")) + + # 功能:验证 macOS 音频使用 afplay 异步播放 + def test_macos_sound_uses_afplay(self): + with mock.patch.object(notify.sys, "platform", "darwin"), mock.patch.object( + notify.subprocess, + "Popen", + ) as popen: + notify.play_sound("complete") + self.assertEqual("afplay", popen.call_args.args[0][0]) + + # 功能:验证 Linux 音频优先使用 aplay + def test_linux_sound_prefers_aplay(self): + with mock.patch.object(notify.sys, "platform", "linux"), mock.patch.object( + notify.subprocess, + "Popen", + ) as popen: + notify.play_sound("complete") + self.assertEqual("aplay", popen.call_args.args[0][0]) + + # 功能:验证 Linux 缺少 aplay 时会回退到 paplay + def test_linux_sound_falls_back_to_paplay(self): + with mock.patch.object(notify.sys, "platform", "linux"), mock.patch.object( + notify.subprocess, + "Popen", + side_effect=[FileNotFoundError(), mock.Mock()], + ) as popen: + notify.play_sound("complete") + self.assertEqual(2, popen.call_count) + self.assertEqual("paplay", popen.call_args_list[1].args[0][0]) + + # 功能:验证资源缺失时声音功能安全降级为终端响铃 + def test_missing_sound_falls_back_to_terminal_bell(self): + output = io.StringIO() + with mock.patch.object(notify, "_find_sound", return_value=None), redirect_stderr(output): + notify.play_sound("complete") + self.assertEqual("\a", output.getvalue()) + + # 功能:验证未知声音事件不会启动任何系统进程 + def test_unknown_sound_event_is_ignored(self): + with mock.patch.object(notify.subprocess, "Popen") as popen: + notify.play_sound("unknown") + popen.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hook_router.py b/tests/test_hook_router.py new file mode 100644 index 0000000..53e4450 --- /dev/null +++ b/tests/test_hook_router.py @@ -0,0 +1,300 @@ +"""Claude Code 与 Codex Hook 事件路由测试。""" + +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] / "plugins" / "dogdoing" +SCRIPTS_DIR = PLUGIN_ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +import hook_router # noqa: E402 + + +# 功能:验证双平台 Hook 载荷会进入正确的业务处理路径 +class HookRouterTests(unittest.TestCase): + # 功能:验证 Codex 的显式失败结果进入错误追踪路径 + def test_codex_failed_post_tool_use_routes_to_failure(self): + payload = { + "tool_name": "exec_command", + "tool_response": {"success": False, "error": "denied"}, + } + with mock.patch.object(hook_router.tracker, "record_tool_failure") as failure, mock.patch.object( + hook_router.tracker, + "record_tool_success", + ) as success: + hook_router.handle_event("post_tool_use", payload, "codex") + failure.assert_called_once_with(payload) + success.assert_not_called() + + # 功能:验证非零退出码会被 Codex 路由识别为工具失败 + def test_codex_nonzero_exit_code_routes_to_failure(self): + payload = { + "tool_name": "exec_command", + "tool_result": {"exit_code": 1, "output": "failed"}, + } + with mock.patch.object(hook_router.tracker, "record_tool_failure") as failure: + hook_router.handle_event("post_tool_use", payload, "codex") + failure.assert_called_once_with(payload) + + # 功能:验证成功结果只增加成功追踪状态 + def test_successful_post_tool_use_routes_to_success(self): + payload = { + "tool_name": "exec_command", + "tool_response": {"success": True, "exit_code": 0}, + } + with mock.patch.object(hook_router.tracker, "record_tool_success") as success, mock.patch.object( + hook_router.tracker, + "record_tool_failure", + ) as failure: + hook_router.handle_event("post_tool_use", payload, "codex") + success.assert_called_once_with(payload) + failure.assert_not_called() + + # 功能:验证 Claude 的独立失败事件无条件进入失败追踪 + def test_claude_failure_event_routes_to_failure(self): + payload = {"tool_name": "Bash", "error": "tool failed"} + with mock.patch.object(hook_router.tracker, "record_tool_failure") as failure: + hook_router.handle_event("post_tool_failure", payload, "claude") + failure.assert_called_once_with(payload) + + # 功能:验证会话开始事件返回对应平台的完整注入文本 + def test_session_start_returns_platform_injection(self): + with mock.patch.object( + hook_router.notify, + "build_injection", + return_value="codex injection", + ) as build: + result = hook_router.handle_event("session_start", {}, "codex") + self.assertEqual("codex injection", result) + build.assert_called_once_with("codex") + + # 功能:验证用户提示事件把载荷和平台传给提示处理器 + def test_prompt_submit_returns_reminder(self): + payload = {"prompt": "hello"} + with mock.patch.object( + hook_router.notify, + "handle_prompt", + return_value="remember", + ) as remind: + result = hook_router.handle_event("prompt_submit", payload, "codex") + self.assertEqual("remember", result) + remind.assert_called_once_with(payload, "codex") + + # 功能:验证停止事件调用通知路由且不产生模型上下文文本 + def test_stop_event_routes_notification(self): + payload = {"stop_reason": "end_turn"} + with mock.patch.object(hook_router.notify, "route_stop") as route: + result = hook_router.handle_event("stop", payload, "codex") + self.assertEqual("", result) + route.assert_called_once_with(payload) + + # 功能:验证未知事件返回非零状态并写入可诊断错误 + def test_unknown_event_returns_nonzero(self): + error = io.StringIO() + with redirect_stderr(error): + exit_code = hook_router.main(["unknown"], stdin=io.StringIO("{}")) + self.assertEqual(2, exit_code) + self.assertIn("未知 Hook 事件", error.getvalue()) + + # 功能:验证 Codex 会收到符合 Hook 协议的结构化附加上下文 + def test_main_prints_codex_hook_context_as_json(self): + output = io.StringIO() + with mock.patch.object(hook_router, "handle_event", return_value="context"), redirect_stdout(output): + exit_code = hook_router.main( + ["session_start", "codex"], + stdin=io.StringIO("{}"), + ) + self.assertEqual(0, exit_code) + self.assertEqual( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "context", + } + }, + json.loads(output.getvalue()), + ) + + # 功能:验证 Claude Code 继续接收原有的纯文本 Hook 上下文 + def test_main_prints_claude_hook_context_as_text(self): + output = io.StringIO() + with mock.patch.object(hook_router, "handle_event", return_value="context"), redirect_stdout(output): + exit_code = hook_router.main( + ["session_start", "claude"], + stdin=io.StringIO("{}"), + ) + self.assertEqual(0, exit_code) + self.assertEqual("context\n", output.getvalue()) + + # 功能:验证 Codex Hook 没有专用根目录变量时仍能通过会话环境识别宿主 + def test_resolve_platform_detects_codex_session_environment(self): + with mock.patch.dict(os.environ, {"CODEX_THREAD_ID": "thread-1"}, clear=True): + self.assertEqual("codex", hook_router._resolve_platform(["session_start"])) + + # 功能:验证不存在 Codex 环境标记时共享 Hook 默认按 Claude Code 处理 + def test_resolve_platform_defaults_to_claude(self): + with mock.patch.dict(os.environ, {"CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT)}, clear=True): + self.assertEqual("claude", hook_router._resolve_platform(["session_start"])) + + # 功能:验证通用插件根目录变量不会把 Claude Code 误判为 Codex + def test_resolve_platform_ignores_generic_plugin_root(self): + environment = { + "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT), + "PLUGIN_ROOT": str(PLUGIN_ROOT), + } + with mock.patch.dict(os.environ, environment, clear=True): + self.assertEqual("claude", hook_router._resolve_platform(["session_start"])) + + # 功能:验证路由脚本在独立 Python 进程中导入所有模块后仍可写标准输出 + def test_router_runs_as_standalone_process(self): + with tempfile.TemporaryDirectory() as state_dir: + environment = { + **os.environ, + "CODEX_PLUGIN_ROOT": str(PLUGIN_ROOT), + "DOGDOING_STATE_DIR": state_dir, + } + completed = subprocess.run( + [ + sys.executable, + str(SCRIPTS_DIR / "hook_router.py"), + "session_start", + "codex", + ], + input="{}", + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + env=environment, + timeout=10, + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("collaboration.spawn_agent", completed.stdout) + + # 功能:验证全部 Windows Hook 通过统一入口启动,避免依赖裸 Python 命令 + def test_windows_hook_configs_use_shared_runner(self): + config_paths = ( + PLUGIN_ROOT / "hooks.json", + PLUGIN_ROOT / "hooks" / "hooks.json", + PLUGIN_ROOT / "hooks" / "claude-failure.json", + ) + for path in config_paths: + hooks = json.loads(path.read_text(encoding="utf-8"))["hooks"] + for event_name, groups in hooks.items(): + with self.subTest(path=path, event_name=event_name): + command = groups[0]["hooks"][0]["command_windows"] + self.assertIn("scripts/hook_router_windows.cmd", command.replace("\\", "/")) + self.assertNotRegex(command, r"^\s*(?:python|python3)(?:\.exe)?\s") + + # 功能:验证 Windows 入口使用显式 Python 执行真实 Hook 路由 + def test_windows_hook_runner_uses_explicit_python(self): + if os.name != "nt": + self.skipTest("仅在 Windows 验证批处理入口") + runner = SCRIPTS_DIR / "hook_router_windows.cmd" + comspec = os.environ.get("ComSpec", r"C:\Windows\System32\cmd.exe") + command = subprocess.list2cmdline([str(runner), "session_start", "codex"]) + with tempfile.TemporaryDirectory() as state_dir: + environment = { + **os.environ, + "DOGDOING_PYTHON": sys.executable, + "DOGDOING_STATE_DIR": state_dir, + } + completed = subprocess.run( + [comspec, "/d", "/c", command], + input="{}", + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + env=environment, + timeout=10, + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("collaboration.spawn_agent", completed.stdout) + + # 功能:验证显式 Python 路径无效时返回可定位错误 + def test_windows_hook_runner_rejects_missing_explicit_python(self): + if os.name != "nt": + self.skipTest("仅在 Windows 验证批处理入口") + runner = SCRIPTS_DIR / "hook_router_windows.cmd" + comspec = os.environ.get("ComSpec", r"C:\Windows\System32\cmd.exe") + command = subprocess.list2cmdline([str(runner), "session_start", "codex"]) + environment = {**os.environ, "DOGDOING_PYTHON": str(SCRIPTS_DIR / "missing-python.exe")} + completed = subprocess.run( + [comspec, "/d", "/c", command], + input="{}", + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + env=environment, + timeout=10, + check=False, + ) + self.assertNotEqual(0, completed.returncode) + self.assertIn("[刀盾狗]", completed.stderr) + self.assertIn("DOGDOING_PYTHON", completed.stderr) + + # 功能:验证 Windows 入口能从 PATH 找到用户级 Python Launcher + def test_windows_hook_runner_searches_launcher_on_path(self): + runner = SCRIPTS_DIR / "hook_router_windows.cmd" + content = runner.read_text(encoding="utf-8") + self.assertIn(r"%SystemRoot%\System32\where.exe py.exe", content) + self.assertNotIn(r'if not exist "%SystemRoot%\py.exe"', content) + self.assertIn('py.exe -3 -c "import sys"', content) + self.assertIn('py.exe -3 "%DOGDOING_ROUTER%" %*', content) + + # 功能:验证 Codex 实际发现的共享 Hook 配置包含跨平台公共事件和 Windows 命令 + def test_codex_hook_config_uses_router(self): + path = PLUGIN_ROOT / "hooks" / "hooks.json" + data = json.loads(path.read_text(encoding="utf-8")) + hooks = data["hooks"] + common_events = {"SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"} + self.assertEqual(common_events, set(hooks)) + for event_name in common_events: + groups = hooks[event_name] + with self.subTest(event_name=event_name): + self.assertNotIn("matcher", groups[0]) + command = groups[0]["hooks"][0]["command"] + self.assertIn("scripts/hook_router.py", command.replace("\\", "/")) + windows_command = groups[0]["hooks"][0]["command_windows"] + self.assertIn("scripts/hook_router_windows.cmd", windows_command.replace("\\", "/")) + self.assertNotIn(" claude", command) + self.assertNotIn(" codex", command) + self.assertNotIn(" claude", windows_command) + self.assertNotIn(" codex", windows_command) + + # 功能:验证旧版 Codex 兼容清单同样不会用空 matcher 禁用事件 + def test_legacy_codex_hook_config_has_no_empty_matchers(self): + path = PLUGIN_ROOT / "hooks.json" + hooks = json.loads(path.read_text(encoding="utf-8"))["hooks"] + for event_name, groups in hooks.items(): + with self.subTest(event_name=event_name): + self.assertNotEqual("", groups[0].get("matcher")) + + # 功能:验证 Claude 清单把独立失败事件追加到共享 Hook 配置 + def test_claude_hook_config_uses_router(self): + manifest_path = PLUGIN_ROOT / ".claude-plugin" / "plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + path = PLUGIN_ROOT / manifest["hooks"] + hooks = json.loads(path.read_text(encoding="utf-8"))["hooks"] + self.assertEqual({"PostToolUseFailure"}, set(hooks)) + for event_name, groups in hooks.items(): + with self.subTest(event_name=event_name): + command = groups[0]["hooks"][0]["command"] + self.assertIn("hook_router.py", command) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000..ba88a63 --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,156 @@ +"""通知、会话注入和 Drog 触发测试。""" + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] / "plugins" / "dogdoing" +SCRIPTS_DIR = PLUGIN_ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +import notify # noqa: E402 + + +# 功能:验证通知路由和双平台提示注入行为 +class NotifyTests(unittest.TestCase): + # 功能:为每项测试隔离用户状态目录 + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.state_dir = Path(self.temporary.name) / "state" + self.environment = mock.patch.dict( + os.environ, + { + "CODEX_PLUGIN_ROOT": str(PLUGIN_ROOT), + "DOGDOING_STATE_DIR": str(self.state_dir), + }, + clear=False, + ) + self.environment.start() + + # 功能:移除测试环境变量并清理临时状态 + def tearDown(self): + self.environment.stop() + self.temporary.cleanup() + + # 功能:验证 Codex 注入使用原生协作工具并包含手动 Skill 语法 + def test_codex_injection_mentions_spawn_agent(self): + content = notify.build_injection(platform="codex", hour=12) + self.assertIn("collaboration.spawn_agent", content) + self.assertIn("$dogdoing", content) + self.assertNotIn("run_in_background", content) + + # 功能:验证 Claude 注入继续保留已注册子代理调用方式 + def test_claude_injection_keeps_agent_call(self): + content = notify.build_injection(platform="claude", hour=12) + self.assertIn('Agent(subagent_type="dogdoing:dogdoing"', content) + self.assertIn("run_in_background=true", content) + + # 功能:验证每轮提醒和默认桌面通知只使用中文提示 + def test_fixed_user_prompts_are_chinese(self): + self.assertEqual("刀盾狗", notify.TITLE) + self.assertEqual("刀盾狗:任务已完成。", notify.DEFAULT_MSG) + for reminder in ( + notify._REMIND_SUBAGENT_CLAUDE, + notify._REMIND_SUBAGENT_CODEX, + notify._REMIND_CHEER, + ): + with self.subTest(reminder=reminder): + self.assertIn("[刀盾狗]", reminder) + self.assertNotIn("[Dogdoing]", reminder) + + # 功能:验证全部行为开关关闭时不注入额外上下文 + def test_disabled_features_produce_no_injection(self): + with mock.patch.object(notify, "_read_setting", return_value=False): + self.assertEqual("", notify.build_injection(platform="codex", hour=12)) + + # 功能:验证关闭子代理后仍可独立启用喝彩提示 + def test_cheer_can_run_without_subagent(self): + settings = { + "subagent_enabled": False, + "cheer_enabled": True, + "drog_enabled": False, + } + with mock.patch.object(notify, "_read_setting", side_effect=lambda key, default: settings[key]): + content = notify.build_injection(platform="codex", hour=12) + self.assertIn("喝彩模式", content) + self.assertNotIn("collaboration.spawn_agent", content) + + # 功能:验证非深夜时段会移除 Drog 深夜随机提示段 + def test_daytime_injection_strips_late_night_mode(self): + content = notify.build_injection(platform="codex", hour=12) + self.assertNotIn("[深夜模式]", content) + + # 功能:验证凌晨时段保留 Drog 深夜随机提示段 + def test_late_night_injection_keeps_late_night_mode(self): + content = notify.build_injection(platform="codex", hour=3) + self.assertIn("[深夜模式]", content) + + # 功能:验证 Codex 用户提示字段可以触发 Drog 成就状态 + def test_codex_prompt_detects_drog_trigger(self): + reminder = notify.handle_prompt( + {"prompt": "请让 ~drog 看一下"}, + platform="codex", + ) + state = json.loads((self.state_dir / "state.json").read_text(encoding="utf-8")) + self.assertTrue(state["drog_triggered"]) + self.assertIn("collaboration.spawn_agent", reminder) + + # 功能:验证正常用户提示不会创建 Drog 状态文件 + def test_regular_prompt_does_not_trigger_drog(self): + notify.handle_prompt({"user_input": "普通任务"}, platform="claude") + self.assertFalse((self.state_dir / "state.json").exists()) + + # 功能:验证工具调用尚未结束时不会发送完成通知 + def test_tool_use_stop_reason_is_silent(self): + with mock.patch.object(notify, "desktop_notify") as desktop, mock.patch.object( + notify, + "play_sound", + ) as sound: + notify.route_stop({"stop_reason": "tool_use"}) + desktop.assert_not_called() + sound.assert_not_called() + + # 功能:验证 Codex 的继续执行状态同样不会发送完成通知 + def test_codex_in_progress_status_is_silent(self): + with mock.patch.object(notify, "desktop_notify") as desktop, mock.patch.object( + notify, + "play_sound", + ) as sound: + notify.route_stop({"status": "in_progress"}) + desktop.assert_not_called() + sound.assert_not_called() + + # 功能:验证四个通知级别分别调用正确的通知通道 + def test_notification_levels_route_expected_channels(self): + expectations = { + 0: (0, 0), + 1: (1, 0), + 2: (0, 1), + 3: (1, 1), + } + for level, expected in expectations.items(): + with self.subTest(level=level), mock.patch.object( + notify, + "_read_notify_level", + return_value=level, + ), mock.patch.object(notify, "desktop_notify") as desktop, mock.patch.object( + notify, + "play_sound", + ) as sound: + notify.route_stop({"stop_reason": "end_turn"}) + self.assertEqual(expected[0], desktop.call_count) + self.assertEqual(expected[1], sound.call_count) + + # 功能:验证无效通知级别安全回退到桌面加声音的默认级别 + def test_invalid_notification_level_uses_default(self): + with mock.patch.object(notify, "_read_setting", return_value="loud"): + self.assertEqual(3, notify._read_notify_level()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_structure.py b/tests/test_plugin_structure.py new file mode 100644 index 0000000..4a745bb --- /dev/null +++ b/tests/test_plugin_structure.py @@ -0,0 +1,144 @@ +"""双平台插件目录与清单测试。""" + +import hashlib +import json +import re +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_ROOT = REPO_ROOT / "plugins" / "dogdoing" +CLAUDE_MARKETPLACE = REPO_ROOT / ".claude-plugin" / "marketplace.json" +CODEX_MARKETPLACE = REPO_ROOT / ".agents" / "plugins" / "marketplace.json" +CLAUDE_MANIFEST = PLUGIN_ROOT / ".claude-plugin" / "plugin.json" +CODEX_MANIFEST = PLUGIN_ROOT / ".codex-plugin" / "plugin.json" +PACKAGE_JSON = REPO_ROOT / "package.json" + + +# 功能:校验 Dogdoing 的双平台插件目录和元数据契约 +class PluginStructureTests(unittest.TestCase): + # 功能:读取 JSON 文件并在格式错误时给出明确测试位置 + def read_json(self, path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + # 功能:验证 Claude 与 Codex 的 Marketplace 指向同一插件目录 + def test_marketplaces_point_to_shared_plugin(self): + for path in (CLAUDE_MARKETPLACE, CODEX_MARKETPLACE): + with self.subTest(path=path): + data = self.read_json(path) + entry = data["plugins"][0] + source = entry["source"] + source_path = source["path"] if isinstance(source, dict) else source + self.assertEqual("./plugins/dogdoing", source_path) + + # 功能:验证 Codex Marketplace 包含安装策略和分类信息 + def test_codex_marketplace_has_required_policy(self): + data = self.read_json(CODEX_MARKETPLACE) + entry = data["plugins"][0] + self.assertEqual("dogdoing", data["name"]) + self.assertEqual("dogdoing", entry["name"]) + self.assertEqual("local", entry["source"]["source"]) + self.assertEqual("AVAILABLE", entry["policy"]["installation"]) + self.assertEqual("ON_INSTALL", entry["policy"]["authentication"]) + self.assertEqual("Developer Tools", entry["category"]) + + # 功能:验证两种宿主清单和 npm 包使用同一个发布版本 + def test_manifests_share_one_version(self): + claude_version = self.read_json(CLAUDE_MANIFEST)["version"] + codex_version = self.read_json(CODEX_MANIFEST)["version"] + package_version = self.read_json(PACKAGE_JSON)["version"] + self.assertEqual("1.1.0", package_version) + self.assertEqual(package_version, claude_version) + self.assertEqual(package_version, codex_version.split("+", 1)[0]) + if "+" in codex_version: + self.assertRegex( + codex_version, + re.compile(r"^1\.1\.0\+codex\.[a-z0-9]+(?:-[a-z0-9]+)*$"), + ) + + # 功能:验证 Codex 清单字段可被官方插件加载器接受 + def test_codex_manifest_is_installable(self): + data = self.read_json(CODEX_MANIFEST) + self.assertEqual("dogdoing", data["name"]) + self.assertNotIn("hooks", data) + self.assertEqual("./skills/", data["skills"]) + self.assertTrue((PLUGIN_ROOT / data["skills"]).is_dir()) + interface = data["interface"] + for key in ( + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + "capabilities", + "defaultPrompt", + "composerIcon", + "logo", + ): + with self.subTest(key=key): + self.assertIn(key, interface) + self.assertTrue((PLUGIN_ROOT / interface["composerIcon"]).is_file()) + self.assertTrue((PLUGIN_ROOT / interface["logo"]).is_file()) + + # 功能:验证插件商店与插件详情中的用户可见提示以中文呈现 + def test_plugin_interface_prompts_are_chinese(self): + codex_manifest = self.read_json(CODEX_MANIFEST) + claude_manifest = self.read_json(CLAUDE_MANIFEST) + codex_marketplace = self.read_json(CODEX_MARKETPLACE) + claude_marketplace = self.read_json(CLAUDE_MARKETPLACE) + interface = codex_manifest["interface"] + + self.assertEqual("刀盾狗", interface["displayName"]) + self.assertEqual("刀盾狗", codex_marketplace["interface"]["displayName"]) + for prompt in interface["defaultPrompt"]: + self.assertIn("刀盾狗", prompt) + for description in ( + codex_manifest["description"], + interface["shortDescription"], + interface["longDescription"], + claude_manifest["description"], + claude_marketplace["metadata"]["description"], + claude_marketplace["plugins"][0]["description"], + ): + with self.subTest(description=description): + self.assertRegex(description, "[一-龥]") + self.assertNotIn("shield-dog", description) + + # 功能:验证共享插件内包含全部现有功能组件 + def test_plugin_contains_all_feature_components(self): + expected_paths = ( + ".claude-plugin/plugin.json", + ".codex-plugin/plugin.json", + "agents/dogdoing.md", + "assets/icons/dogdoing.png", + "assets/sounds/complete.wav", + "hooks/claude-failure.json", + "hooks/hooks.json", + "scripts/hook_router_windows.cmd", + "scripts/hook_router_windows_error.txt", + "scripts/notify.py", + "scripts/tracker.py", + "skills/dogdoing/SKILL.md", + "skills/dogfood/SKILL.md", + "INJECT.md", + "INJECT_SUBAGENT.md", + "INJECT_CHEER.md", + "INJECT_DROG.md", + "settings.json", + ) + for relative_path in expected_paths: + with self.subTest(relative_path=relative_path): + self.assertTrue((PLUGIN_ROOT / relative_path).exists()) + + # 功能:验证任务完成音频与上游原始语音完全一致 + def test_complete_sound_matches_original_asset(self): + content = (PLUGIN_ROOT / "assets" / "sounds" / "complete.wav").read_bytes() + self.assertEqual( + "B51A0023049E0A90620C2EF1E7821429E69C798ED85014ABF94C376D32B1DAD0", + hashlib.sha256(content).hexdigest().upper(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..174e035 --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,89 @@ +"""README 与发布说明完整性测试。""" + +import json +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +README = REPO_ROOT / "README.md" +PACKAGE_JSON = REPO_ROOT / "package.json" +NPM_IGNORE = REPO_ROOT / ".npmignore" +SCRIPT_NPM_IGNORE = REPO_ROOT / "plugins" / "dogdoing" / "scripts" / ".npmignore" + + +# 功能:验证用户文档覆盖 V1 双平台安装和全部功能入口 +class ReadmeTests(unittest.TestCase): + # 功能:读取 README 的 UTF-8 内容 + def readme(self) -> str: + return README.read_text(encoding="utf-8") + + # 功能:验证 Codex 的远程和本地 Marketplace 安装命令完整 + def test_codex_installation_commands_are_documented(self): + content = self.readme() + self.assertIn("codex plugin marketplace add hellowind777/dogdoing", content) + self.assertIn("codex plugin marketplace add .", content) + self.assertIn("codex plugin add dogdoing@dogdoing", content) + + # 功能:验证 Codex Desktop 升级插件后会提示完全重启应用而不是只新建任务 + def test_codex_desktop_restart_is_documented(self): + content = self.readme() + self.assertIn("完全退出并重新启动 Codex Desktop", content) + self.assertIn("仅新建任务不会刷新", content) + + # 功能:验证 Claude Code 的既有安装命令继续保留 + def test_claude_installation_commands_are_documented(self): + content = self.readme() + self.assertIn("claude plugin marketplace add hellowind777/dogdoing", content) + self.assertIn("claude plugin install dogdoing", content) + self.assertIn("claude --plugin-dir", content) + + # 功能:验证两个宿主的手动 Skill 语法都有明确说明 + def test_manual_skill_syntax_is_documented(self): + content = self.readme() + for trigger in ("$dogdoing", "$dogfood", "/dogdoing", "/dogfood", "~dogdoing", "~drog"): + with self.subTest(trigger=trigger): + self.assertIn(trigger, content) + + # 功能:验证 V1 的全部用户可见功能仍在文档清单中 + def test_all_v1_features_are_documented(self): + content = self.readme() + for feature in ( + "自动分工", + "多代理", + "喝彩", + "桌面通知", + "语音", + "成就", + "连击", + "This is Fine", + "Drog", + "自检", + "全中文提示", + "Windows", + "macOS", + "Linux", + ): + with self.subTest(feature=feature): + self.assertIn(feature, content) + + # 功能:验证 npm 发布包包含双平台 Marketplace 和共享插件目录 + def test_package_includes_dual_platform_layout(self): + package = json.loads(PACKAGE_JSON.read_text(encoding="utf-8")) + self.assertEqual("1.1.0", package["version"]) + self.assertIn(".agents/", package["files"]) + self.assertIn(".claude-plugin/", package["files"]) + self.assertIn("plugins/", package["files"]) + self.assertIn("codex", package["keywords"]) + + # 功能:验证 npm 发布包不会携带 Python 字节码和测试缓存 + def test_package_excludes_python_cache(self): + root_patterns = NPM_IGNORE.read_text(encoding="utf-8") + script_patterns = SCRIPT_NPM_IGNORE.read_text(encoding="utf-8") + self.assertIn("**/__pycache__/**", root_patterns) + self.assertIn("__pycache__/", script_patterns) + self.assertIn("*.pyc", script_patterns) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..21d198e --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,127 @@ +"""共享运行时配置、输入和状态文件测试。""" + +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "plugins" / "dogdoing" / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +import runtime # noqa: E402 +import notify # noqa: E402 +import tracker # noqa: E402 + + +# 功能:验证双平台共享运行时的配置与文件操作 +class RuntimeTests(unittest.TestCase): + # 功能:为每项测试创建互不影响的插件目录 + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.codex_root = self.root / "codex" + self.generic_root = self.root / "generic" + self.claude_root = self.root / "claude" + for directory, source in ( + (self.codex_root, "codex"), + (self.generic_root, "generic"), + (self.claude_root, "claude"), + ): + directory.mkdir() + (directory / "settings.json").write_text( + json.dumps({"source": source, "enabled": False}), + encoding="utf-8", + ) + + # 功能:清理测试期间创建的临时目录 + def tearDown(self): + self.temporary.cleanup() + + # 功能:验证 Codex 插件根目录环境变量拥有最高优先级 + def test_codex_plugin_root_has_priority(self): + values = { + "CODEX_PLUGIN_ROOT": str(self.codex_root), + "PLUGIN_ROOT": str(self.generic_root), + "CLAUDE_PLUGIN_ROOT": str(self.claude_root), + } + with mock.patch.dict(os.environ, values, clear=False): + self.assertEqual(self.codex_root.resolve(), runtime.find_plugin_root()) + self.assertEqual("codex", runtime.read_setting("source")) + + # 功能:验证通用插件根目录可供 Codex Hook 运行时使用 + def test_generic_plugin_root_is_supported(self): + with mock.patch.dict( + os.environ, + {"PLUGIN_ROOT": str(self.generic_root)}, + clear=True, + ): + self.assertEqual(self.generic_root.resolve(), runtime.find_plugin_root()) + self.assertEqual("generic", runtime.read_setting("source")) + + # 功能:验证布尔值 false 不会被误当成缺失配置 + def test_false_setting_is_preserved(self): + with mock.patch.dict( + os.environ, + {"CODEX_PLUGIN_ROOT": str(self.codex_root)}, + clear=True, + ): + self.assertIs(runtime.read_setting("enabled", True), False) + + # 功能:验证现有通知和追踪脚本都能读取 Codex 插件根目录配置 + def test_existing_scripts_use_codex_plugin_root(self): + with mock.patch.dict( + os.environ, + {"CODEX_PLUGIN_ROOT": str(self.codex_root)}, + clear=True, + ): + self.assertEqual("codex", notify._read_setting("source", "missing")) + self.assertEqual("codex", tracker._read_setting("source", "missing")) + + # 功能:验证损坏 JSON 文件会返回默认值的新副本 + def test_invalid_json_uses_defaults(self): + state_file = self.root / "state.json" + state_file.write_text("not-json", encoding="utf-8") + defaults = {"combo": 0, "items": []} + loaded = runtime.read_json(state_file, defaults) + loaded["items"].append("changed") + self.assertEqual({"combo": 0, "items": []}, defaults) + + # 功能:验证状态写入完成后不会遗留临时文件 + def test_atomic_json_write_replaces_target(self): + state_file = self.root / "state" / "state.json" + runtime.write_json_atomic(state_file, {"combo": 3}) + self.assertEqual({"combo": 3}, json.loads(state_file.read_text(encoding="utf-8"))) + self.assertEqual([], list(state_file.parent.glob("*.tmp"))) + + # 功能:验证 Hook 输入可以从合法 JSON 对象中读取 + def test_read_hook_payload_accepts_object(self): + stream = io.StringIO('{"tool_name":"exec_command"}') + self.assertEqual( + {"tool_name": "exec_command"}, + runtime.read_hook_payload(stream), + ) + + # 功能:验证空输入、非法 JSON 和非对象 JSON 都安全回退 + def test_read_hook_payload_rejects_unusable_input(self): + for raw in ("", "not-json", "[]", '"text"'): + with self.subTest(raw=raw): + self.assertEqual({}, runtime.read_hook_payload(io.StringIO(raw))) + + # 功能:验证测试和高级用户可以覆盖状态目录 + def test_state_directory_can_be_overridden(self): + custom_state = self.root / "custom-state" + with mock.patch.dict( + os.environ, + {"DOGDOING_STATE_DIR": str(custom_state)}, + clear=False, + ): + self.assertEqual(custom_state.resolve(), runtime.get_state_dir()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..0ef6acf --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,119 @@ +"""Dogdoing 与 dogfood 双平台 Skill 内容测试。""" + +import re +import unittest +from pathlib import Path + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] / "plugins" / "dogdoing" +DOGDOING_SKILL = PLUGIN_ROOT / "skills" / "dogdoing" / "SKILL.md" +DOGFOOD_SKILL = PLUGIN_ROOT / "skills" / "dogfood" / "SKILL.md" +CODEX_INJECTION = PLUGIN_ROOT / "INJECT_SUBAGENT_CODEX.md" +CLAUDE_AGENT = PLUGIN_ROOT / "agents" / "dogdoing.md" +PROMPT_FILES = ( + PLUGIN_ROOT / "INJECT.md", + PLUGIN_ROOT / "INJECT_SUBAGENT.md", + CODEX_INJECTION, + PLUGIN_ROOT / "INJECT_CHEER.md", + PLUGIN_ROOT / "INJECT_DROG.md", + CLAUDE_AGENT, + DOGDOING_SKILL, +) + + +# 功能:验证两个 Skill 和代理人格在 Claude 与 Codex 中行为等价 +class SkillCompatibilityTests(unittest.TestCase): + # 功能:读取指定 UTF-8 文本资源 + def read_text(self, path: Path) -> str: + return path.read_text(encoding="utf-8") + + # 功能:验证共享 Skill 仅使用两端都能解析的核心元数据 + def test_skill_frontmatter_avoids_claude_only_fields(self): + for path in (DOGDOING_SKILL, DOGFOOD_SKILL): + with self.subTest(path=path): + content = self.read_text(path) + frontmatter = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + self.assertIsNotNone(frontmatter) + metadata = frontmatter.group(1) + self.assertIn("name:", metadata) + self.assertIn("description:", metadata) + self.assertNotIn("allowed-tools:", metadata) + self.assertNotIn("disable-model-invocation:", metadata) + self.assertNotIn("argument_description:", metadata) + + # 功能:验证手动召唤技能包含两种宿主的真实代理调用方式 + def test_dogdoing_skill_supports_both_hosts(self): + content = self.read_text(DOGDOING_SKILL) + self.assertIn("collaboration.spawn_agent", content) + self.assertIn("collaboration.wait_agent", content) + self.assertIn("fork_turns=\"all\"", content) + self.assertIn('Agent(subagent_type="dogdoing:dogdoing"', content) + self.assertIn("$ARGUMENTS", content) + self.assertIn("所有输出使用简体中文", content) + self.assertNotIn("英文:", content) + + # 功能:验证没有多代理工具时 Skill 仍要求完成同等深度分析 + def test_dogdoing_skill_has_local_fallback(self): + content = self.read_text(DOGDOING_SKILL) + self.assertIn("多代理工具不可用", content) + self.assertIn("主代理", content) + + # 功能:验证自检 Skill 覆盖两种清单和两套 Hook 配置 + def test_dogfood_skill_reviews_both_platforms(self): + content = self.read_text(DOGFOOD_SKILL) + for required in ( + ".codex-plugin/plugin.json", + ".claude-plugin/plugin.json", + "hooks.json", + "hooks/hooks.json", + ".agents/plugins/marketplace.json", + ".claude-plugin/marketplace.json", + ): + with self.subTest(required=required): + self.assertIn(required, content) + + # 功能:验证自检 Skill 与全中文输出契约保持一致 + def test_dogfood_skill_keeps_chinese_contract(self): + content = self.read_text(DOGFOOD_SKILL) + self.assertIn("所有上下文使用简体中文输出", content) + self.assertNotIn("中英双语", content) + self.assertNotIn("其他上下文用英文", content) + + # 功能:验证 Codex 自动分工要求等待并汇总刀盾狗结果 + def test_codex_injection_collects_agent_result(self): + content = self.read_text(CODEX_INJECTION) + self.assertIn("collaboration.spawn_agent", content) + self.assertIn("等待刀盾狗完成", content) + self.assertIn("具体且边界清晰的子任务", content) + + # 功能:验证 Claude 注册代理保持全中文提示和有用工作要求 + def test_claude_agent_keeps_persona_contract(self): + content = self.read_text(CLAUDE_AGENT) + self.assertIn("简体中文", content) + self.assertIn("所有输出使用简体中文", content) + self.assertIn("完成分配的实际工作", content) + self.assertIn("旺", content) + self.assertNotIn("Wow", content) + + # 功能:验证注入和代理人格不再包含面向人的英文提示句 + def test_prompt_prose_is_chinese(self): + forbidden_phrases = ( + "You have a loyal", + "When the user types", + "MUST participate", + "For every user request", + "Collect the result", + "If Dogdoing cannot", + "What Dogdoing can do", + "You are Dogdoing", + ) + for path in PROMPT_FILES: + with self.subTest(path=path): + content = self.read_text(path) + self.assertIn("刀盾狗", content) + for phrase in forbidden_phrases: + self.assertNotIn(phrase, content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tracker.py b/tests/test_tracker.py new file mode 100644 index 0000000..90fd845 --- /dev/null +++ b/tests/test_tracker.py @@ -0,0 +1,152 @@ +"""成就、连击和错误追踪测试。""" + +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stderr +from pathlib import Path +from unittest import mock + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] / "plugins" / "dogdoing" +SCRIPTS_DIR = PLUGIN_ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +import runtime # noqa: E402 +import tracker # noqa: E402 + + +# 功能:验证 Dogdoing 的成就、连击和失败状态机 +class TrackerTests(unittest.TestCase): + # 功能:为每项追踪测试创建隔离状态并关闭系统级副作用 + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.state_dir = Path(self.temporary.name) / "state" + self.environment = mock.patch.dict( + os.environ, + { + "CODEX_PLUGIN_ROOT": str(PLUGIN_ROOT), + "DOGDOING_STATE_DIR": str(self.state_dir), + }, + clear=False, + ) + self.environment.start() + self.sound = mock.patch.object(tracker, "_play_sound") + self.process = mock.patch.object(tracker.subprocess, "Popen") + self.sound.start() + self.process.start() + + # 功能:清理追踪测试的补丁和临时状态目录 + def tearDown(self): + self.process.stop() + self.sound.stop() + self.environment.stop() + self.temporary.cleanup() + + # 功能:验证四个连击阈值会映射到正确级别 + def test_combo_levels_cover_all_thresholds(self): + expectations = { + 0: 0, + 2: 0, + 3: 1, + 5: 2, + 10: 3, + 20: 4, + 99: 4, + } + for combo, level in expectations.items(): + with self.subTest(combo=combo): + self.assertEqual(level, tracker._combo_level_for(combo)) + + # 功能:验证连续成功五次会达到二级连击并解锁连旺 + def test_five_successes_unlock_combo_achievement(self): + for _ in range(5): + tracker.record_tool_success({"tool_name": "exec_command", "tool_input": {}}) + state = tracker.load_state() + achievements = tracker.load_achievements() + self.assertEqual(5, state["combo"]) + self.assertEqual(2, state["combo_level"]) + self.assertIsNotNone(achievements["combo_5"]) + + # 功能:验证连续成功十次会解锁累计任务成就 + def test_ten_successes_unlock_ten_tasks_achievement(self): + for _ in range(10): + tracker.record_tool_success({"tool_name": "exec_command", "tool_input": {}}) + self.assertIsNotNone(tracker.load_achievements()["ten_tasks"]) + + # 功能:验证 Claude Agent 调用会解锁首次召唤成就 + def test_claude_agent_unlocks_first_summon(self): + tracker.record_tool_success( + { + "tool_name": "Agent", + "tool_input": {"subagent_type": "dogdoing:dogdoing"}, + } + ) + self.assertEqual(1, tracker.load_state()["summon_count"]) + self.assertIsNotNone(tracker.load_achievements()["first_summon"]) + + # 功能:验证 Codex 协作工具调用会解锁首次召唤成就 + def test_codex_agent_unlocks_first_summon(self): + tracker.record_tool_success( + { + "tool_name": "collaboration.spawn_agent", + "tool_input": {"task_name": "dogdoing", "message": "验证改动"}, + } + ) + self.assertEqual(1, tracker.load_state()["summon_count"]) + self.assertIsNotNone(tracker.load_achievements()["first_summon"]) + + # 功能:验证首次失败解锁错误成就并重置连击 + def test_failure_unlocks_bug_found_and_resets_combo(self): + tracker.record_tool_success({"tool_name": "exec_command"}) + tracker.record_tool_failure({"error": "boom"}) + state = tracker.load_state() + self.assertEqual(0, state["combo"]) + self.assertEqual(1, state["error_streak"]) + self.assertIsNotNone(tracker.load_achievements()["bug_found"]) + + # 功能:验证连续失败三次触发纯中文安慰文本 + def test_three_failures_trigger_this_is_fine(self): + output = io.StringIO() + with redirect_stderr(output): + for _ in range(3): + tracker.record_tool_failure({"error": "boom"}) + self.assertIn("一切正常。旺。", output.getvalue()) + self.assertNotIn("This is fine", output.getvalue()) + + # 功能:验证全部成就名称都使用中文说明 + def test_achievement_messages_are_chinese(self): + for achievement_id, message in tracker.ACHIEVEMENTS.items(): + with self.subTest(achievement_id=achievement_id): + self.assertFalse(any("A" <= char <= "z" for char in message)) + + # 功能:验证 Drog 提示状态会在下一次成功事件解锁并消费 + def test_drog_trigger_unlocks_achievement_once(self): + runtime.write_json_atomic(self.state_dir / "state.json", {"drog_triggered": True}) + tracker.record_tool_success({"tool_name": "exec_command"}) + self.assertFalse(tracker.load_state()["drog_triggered"]) + self.assertIsNotNone(tracker.load_achievements()["drog_awakened"]) + + # 功能:验证关闭追踪后不会创建任何状态文件 + def test_disabled_tracker_does_not_write_state(self): + with mock.patch.object(tracker, "_is_enabled", return_value=False): + tracker.record_tool_success({"tool_name": "exec_command"}) + tracker.record_tool_failure({"error": "boom"}) + self.assertFalse(self.state_dir.exists()) + + # 功能:验证损坏和不完整的状态文件会与默认状态合并 + def test_state_loading_recovers_defaults(self): + self.state_dir.mkdir(parents=True) + (self.state_dir / "state.json").write_text('{"combo": 7}', encoding="utf-8") + state = tracker.load_state() + self.assertEqual(7, state["combo"]) + self.assertEqual(0, state["error_streak"]) + (self.state_dir / "achievements.json").write_text("not-json", encoding="utf-8") + self.assertEqual(set(tracker.ACHIEVEMENTS), set(tracker.load_achievements())) + + +if __name__ == "__main__": + unittest.main()