diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 86dd6dbe..eadba58b 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -351,6 +351,18 @@ "authentication": "ON_INSTALL" }, "category": "MCP" + }, + { + "name": "superpowers-zh", + "source": { + "source": "local", + "path": "./plugins/codex/plugins/superpowers-zh" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "开发工具" } ] } diff --git a/README.md b/README.md index da6e657b..e60fd1d6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ codex plugin add @eapil-skill-market 使用 `codex plugin list --marketplace eapil-skill-market` 输出里的精确 `plugin-id`。不要猜测中文名、翻译名或 display name。 +同一插件的原版与本地化版本可能同时存在。它们的展示名可以相近,但 `plugin-id` 必须不同;安装前应通过列表确认语言版本和精确 ID。 + 安装公共 skill 或第三方 Codex plugin 时也使用同一套命令;具体可安装项以 `codex plugin list --marketplace eapil-skill-market` 的实时输出为准。 有些插件是 skill 集合,安装一个 `plugin-id` 后会同时提供多个 `$skill-name`。可安装项和安装后可用的 skills 均以 Codex 的实际输出为准。 diff --git a/config/external-sources.json b/config/external-sources.json index f33b8ac3..e60ca3a1 100644 --- a/config/external-sources.json +++ b/config/external-sources.json @@ -46,6 +46,30 @@ "writing-skills": "创建、修改或验证 Agent skill 时使用,确保 skill 可触发、可维护、可测试。" } }, + { + "id": "superpowers-zh", + "repo": "https://github.com/AreChen/superpowers-zh.git", + "ref": "main", + "adapter": "codex-plugin", + "pluginName": "superpowers-zh", + "category": "开发工具", + "sourcePath": ".", + "include": [ + ".codex-plugin", + "skills", + "assets", + "README.md", + "LICENSE", + "CODE_OF_CONDUCT.md" + ], + "manifestOverrides": { + "name": "superpowers-zh", + "interface": { + "developerName": "AreChen / Jesse Vincent", + "category": "开发工具" + } + } + }, { "id": "oh-my-codex", "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index d2fa1ac8..84f9d6bc 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -98,6 +98,15 @@ "adapter": "skill-collection", "commit": "41b823880d688b3e9f9aae3323b08ddd457240c2", "syncedAt": "2026-07-13T16:00:00Z" + }, + { + "id": "superpowers-zh", + "pluginName": "superpowers-zh", + "repo": "https://github.com/AreChen/superpowers-zh.git", + "ref": "main", + "adapter": "codex-plugin", + "commit": "c51f23adcd482fd908aa60928f2ece34d12f7768", + "syncedAt": "2026-07-14T02:27:30Z" } ] } diff --git a/plugins/codex/.agents/plugins/marketplace.json b/plugins/codex/.agents/plugins/marketplace.json index 9654e912..fb5be88f 100644 --- a/plugins/codex/.agents/plugins/marketplace.json +++ b/plugins/codex/.agents/plugins/marketplace.json @@ -351,6 +351,18 @@ "authentication": "ON_INSTALL" }, "category": "MCP" + }, + { + "name": "superpowers-zh", + "source": { + "source": "local", + "path": "./plugins/superpowers-zh" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "开发工具" } ] } diff --git a/plugins/codex/plugins/superpowers-zh/.codex-plugin/plugin.json b/plugins/codex/plugins/superpowers-zh/.codex-plugin/plugin.json new file mode 100644 index 00000000..b07cb6e7 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/.codex-plugin/plugin.json @@ -0,0 +1,48 @@ +{ + "name": "superpowers-zh", + "version": "6.1.1-zh.1", + "description": "面向 Agent 的中文技能框架与软件开发方法论:规划、TDD、系统化调试和协作工作流。", + "author": { + "name": "Jesse Vincent", + "email": "jesse@fsck.com", + "url": "https://github.com/obra" + }, + "homepage": "https://github.com/AreChen/superpowers-zh", + "repository": "https://github.com/AreChen/superpowers-zh", + "license": "MIT", + "keywords": [ + "brainstorming", + "subagent-driven-development", + "skills", + "planning", + "tdd", + "debugging", + "code-review", + "workflow" + ], + "skills": "./skills/", + "hooks": {}, + "interface": { + "displayName": "Superpowers 中文版", + "shortDescription": "面向编程 Agent 的规划、TDD、调试与交付工作流", + "longDescription": "使用 Superpowers 中文版引导 Agent 完成头脑风暴、实施规划、测试驱动开发、系统化调试、并行执行、代码审查和分支收尾工作流。", + "developerName": "AreChen / Jesse Vincent", + "category": "开发工具", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "defaultPrompt": [ + "我有一个想实现的点子。", + "让我们给这个项目添加一个功能。" + ], + "websiteURL": "https://github.com/AreChen/superpowers-zh", + "privacyPolicyURL": "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement", + "termsOfServiceURL": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service", + "brandColor": "#F59E0B", + "composerIcon": "./assets/superpowers-small.svg", + "logo": "./assets/app-icon.png", + "screenshots": [] + } +} diff --git a/plugins/codex/plugins/superpowers-zh/CODE_OF_CONDUCT.md b/plugins/codex/plugins/superpowers-zh/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f6422545 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +jesse@primeradiant.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/plugins/codex/plugins/superpowers-zh/LICENSE b/plugins/codex/plugins/superpowers-zh/LICENSE new file mode 100644 index 00000000..abf03903 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jesse Vincent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/codex/plugins/superpowers-zh/README.md b/plugins/codex/plugins/superpowers-zh/README.md new file mode 100644 index 00000000..f16e76a4 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/README.md @@ -0,0 +1,310 @@ +# Superpowers 中文版 + +Superpowers 是一套面向编程 Agent 的完整软件开发方法论。它由一组可组合的技能和启动指令构成,确保 Agent 能在合适的时机主动调用这些技能。 + +本项目是 [obra/superpowers](https://github.com/obra/superpowers) 的中文分叉版本,主要面向使用 Codex、OpenCode 的中文用户,同时兼容 Windows、Linux 和 macOS。 + +## 版本对齐 + +- **当前中文发行版:** `v6.1.1-zh.1` +- **对齐的上游正式版:** [`obra/superpowers v6.1.1`](https://github.com/obra/superpowers/releases/tag/v6.1.1) +- **上游基线提交:** [`d884ae0`](https://github.com/obra/superpowers/commit/d884ae04edebef577e82ff7c4e143debd0bbec99) +- **对齐日期:** 2026-07-13 + +版本号中的 `zh.1` 表示:功能基线与上游 `v6.1.1` 对齐,这是该基线上的第 1 个中文发行版。后续同步新的上游版本时,会先更新前三段版本号,再从 `zh.1` 重新开始计数。 + +## 上游正在招聘 + +Superpowers 上游团队正在招聘一名全职工程师,协助社区运营与代码开发。 + +职位详情:https://primeradiant.com/jobs/superpowers-community-engineer/ + +如果你认识合适的人选,欢迎推荐给上游团队。 + +## 快速开始 + +为你的编程 Agent 安装 Superpowers 中文版:[Claude Code](#claude-code)、[Antigravity](#antigravity)、[Codex App](#codex-app)、[Codex CLI](#codex-cli)、[Cursor](#cursor)、[Factory Droid](#factory-droid)、[GitHub Copilot CLI](#github-copilot-cli)、[Kimi Code](#kimi-code)、[OpenCode](#opencode)、[Pi](#pi)。 + +## 工作原理 + +Superpowers 从你启动编程 Agent 的那一刻开始发挥作用。当 Agent 发现你准备构建某项功能时,它不会立刻埋头写代码,而是先退一步,询问你真正想解决的问题。 + +Agent 会通过对话逐步梳理出规格,并以便于阅读和确认的小段内容向你展示。 + +设计获批后,Agent 会编写一份足够清晰的实施计划:即使交给一名缺少项目背景、工程判断欠佳且不喜欢测试的初级工程师,也能据此完成任务。计划强调真正的红灯—绿灯 TDD、YAGNI(你不会需要它)和 DRY。 + +当你确认“开始”后,Agent 会启动由子 Agent 驱动的开发流程。不同 Agent 会依次完成各项工程任务,检查并审查彼此的工作,然后继续推进。只要计划足够清晰,Agent 连续自主工作数小时而不偏离目标并不罕见。 + +系统还包含更多细节,但以上就是核心流程。由于技能会自动触发,你不需要记忆特殊命令——安装后,你的编程 Agent 就拥有了 Superpowers。 + +## 商业服务 + +如果你在企业环境中使用 Superpowers,并需要商业支持、额外工具或托管式成本管理,可联系上游团队:sales@primeradiant.com。 + +## 安装 + +不同编程 Agent 的安装方式不同。如果你同时使用多个 Agent,需要分别安装。 + +### Claude Code + +推荐直接注册本中文仓库提供的插件市场: + +```bash +/plugin marketplace add AreChen/superpowers-zh +``` + +然后安装中文插件: + +```bash +/plugin install superpowers@superpowers-dev +``` + +如果你希望安装上游英文版,也可以使用 [Claude 官方插件市场](https://claude.com/plugins/superpowers): + +```bash +/plugin install superpowers@claude-plugins-official +``` + +### Antigravity + +直接从本仓库安装插件: + +```bash +agy plugin install https://github.com/AreChen/superpowers-zh +``` + +Antigravity 会运行插件的会话启动钩子,因此 Superpowers 会从第一条消息起生效。更新时重新执行同一条命令即可。 + +### Codex App + +先通过 Codex CLI 添加本项目的 Git 插件市场: + +```bash +codex plugin marketplace add AreChen/superpowers-zh --ref main +``` + +然后: + +1. 重启 Codex App。 +2. 在侧边栏打开“插件”。 +3. 选择 `Superpowers 中文版开发版` 市场。 +4. 找到 `Superpowers 中文版`,点击旁边的 `+` 并按提示安装。 + +### Codex CLI + +先添加本项目的插件市场: + +```bash +codex plugin marketplace add AreChen/superpowers-zh --ref main +``` + +打开插件界面: + +```text +/plugins +``` + +选择 `Superpowers 中文版开发版` 市场,搜索 `superpowers` 并安装插件。 + +查看已配置的市场: + +```bash +codex plugin marketplace list +``` + +更新市场: + +```bash +codex plugin marketplace upgrade superpowers-dev +``` + +### Cursor + +在 Cursor Agent 对话中从插件市场安装: + +```text +/add-plugin superpowers +``` + +也可以在插件市场中搜索 `superpowers`。请确认安装详情显示的是 `Superpowers 中文版`;如果只看到上游英文版,可改用本仓库的本地插件方式。 + +### Factory Droid + +注册本中文仓库的插件市场: + +```bash +droid plugin marketplace add https://github.com/AreChen/superpowers-zh +``` + +安装插件: + +```bash +droid plugin install superpowers@superpowers-dev +``` + +### GitHub Copilot CLI + +注册本中文仓库的插件市场: + +```bash +copilot plugin marketplace add AreChen/superpowers-zh +``` + +安装插件: + +```bash +copilot plugin install superpowers@superpowers-dev +``` + +### Kimi Code + +可以直接从本中文仓库安装: + +```text +/plugins install https://github.com/AreChen/superpowers-zh +``` + +也可以打开 Kimi Code 的插件管理器: + +```text +/plugins +``` + +进入 `Marketplace`,搜索并安装 `Superpowers`。请在安装前确认插件描述为中文版本。 + +详细说明:[docs/README.kimi.md](docs/README.kimi.md) + +### OpenCode + +OpenCode 使用自己的插件安装机制。即使你已经在其他编程 Agent 中安装了 Superpowers,仍需为 OpenCode 单独安装。 + +在全局或项目级 `opencode.json` 的 `plugin` 数组中加入: + +```json +{ + "plugin": [ + "superpowers@git+https://github.com/AreChen/superpowers-zh.git" + ] +} +``` + +重启 OpenCode。插件会自动注册本项目中的全部技能,无需手动为技能目录创建符号链接。 + +也可以让 OpenCode 获取并遵循仓库内的安装说明: + +```text +获取并遵循 https://raw.githubusercontent.com/AreChen/superpowers-zh/refs/heads/main/.opencode/INSTALL.md 中的说明 +``` + +详细说明:[docs/README.opencode.md](docs/README.opencode.md) + +### Pi + +从本仓库安装为 Pi 软件包: + +```bash +pi install git:github.com/AreChen/superpowers-zh +``` + +本地开发时,可以把当前检出目录作为临时软件包加载: + +```bash +pi -e /path/to/superpowers-zh +``` + +Pi 软件包会加载 Superpowers 技能,并通过一个小型扩展在会话启动和上下文压缩后注入 `using-superpowers` 引导。Pi 原生支持技能,因此不需要兼容性的 `Skill` 工具。子 Agent 和任务列表工具仍可作为可选的 Pi 配套软件包使用。 + +## 基本工作流 + +1. **brainstorming** —— 在编写代码前触发。通过提问澄清初步想法、探索替代方案,分段展示设计供用户确认,并保存设计文档。 + +2. **using-git-worktrees** —— 设计获批后触发。在新分支上创建隔离工作区,完成项目初始化,并验证测试基线干净。 + +3. **writing-plans** —— 获得批准的设计后触发。把工作拆分为小型任务,每项任务通常需要 2–5 分钟,并包含准确的文件路径、完整代码和验证步骤。 + +4. **subagent-driven-development** 或 **executing-plans** —— 计划完成后触发。前者为每项任务派遣新的子 Agent,并进行规格符合性和代码质量两阶段审查;后者按批次执行,并在关键节点等待人工确认。 + +5. **test-driven-development** —— 实施期间触发。强制执行“红灯—绿灯—重构”:先写失败测试并确认失败,再编写最小实现并确认通过,然后提交。测试之前编写的生产代码需要删除并重新按 TDD 实现。 + +6. **requesting-code-review** —— 在任务之间触发。根据计划审查实现,按严重程度报告问题;严重问题会阻止继续推进。 + +7. **finishing-a-development-branch** —— 全部任务完成后触发。验证测试并提供合并、创建 PR、保留或丢弃分支等选项,最后清理 worktree。 + +**Agent 会在执行任何任务前检查是否存在相关技能。** 这些是必须遵循的工作流,而不是可选建议。 + +## 包含内容 + +### 技能库 + +**测试** + +- **test-driven-development** —— 红灯—绿灯—重构循环,包含测试反模式参考。 + +**调试** + +- **systematic-debugging** —— 四阶段根因分析流程,包含根因追踪、纵深防御和基于条件的等待技术。 +- **verification-before-completion** —— 在声称完成前用证据确认问题确实已经解决。 + +**协作** + +- **brainstorming** —— 通过苏格拉底式提问完善设计。 +- **writing-plans** —— 编写详细实施计划。 +- **executing-plans** —— 带检查点的分批执行。 +- **dispatching-parallel-agents** —— 并行子 Agent 工作流。 +- **requesting-code-review** —— 请求审查前的完整流程。 +- **receiving-code-review** —— 理解并处理审查反馈。 +- **using-git-worktrees** —— 使用隔离分支并行开发。 +- **finishing-a-development-branch** —— 合并或创建 PR 的决策流程。 +- **subagent-driven-development** —— 通过规格符合性和代码质量两阶段审查快速迭代。 + +**元技能** + +- **writing-skills** —— 按最佳实践创建和测试新技能。 +- **using-superpowers** —— 技能系统的入口和使用规则。 + +## 核心理念 + +- **测试驱动开发** —— 始终先写测试。 +- **系统化优于临时应对** —— 遵循流程,不靠猜测。 +- **降低复杂度** —— 把简单作为首要目标。 +- **证据优于声明** —— 声称成功之前先验证。 + +参阅 [Superpowers 最初的发布公告](https://blog.fsck.com/2025/10/09/superpowers/)。 + +## 参与贡献 + +本项目跟随上游 Superpowers 的总体贡献原则。通常不接受随意新增技能;任何技能修改都必须能在项目支持的编程 Agent 和主要操作系统上正常工作。 + +1. Fork [AreChen/superpowers-zh](https://github.com/AreChen/superpowers-zh)。 +2. 同步最新的 `main` 分支。 +3. 为你的改动创建独立分支。 +4. 创建或修改技能时,遵循 `writing-skills` 技能完成编写和测试。 +5. 向 `main` 提交 PR,并完整填写 PR 模板。 + +技能行为测试使用 [superpowers-evals](https://github.com/prime-radiant-inc/superpowers-evals/) 中的 drill 评测框架,将其克隆到 `evals/` 后可按 `evals/README.md` 配置。插件基础设施测试位于 `tests/`,通过对应的 `run-*.sh` 或 `npm test` 运行。 + +完整指南见 [`skills/writing-skills/SKILL.md`](skills/writing-skills/SKILL.md)。 + +## 更新 + +Superpowers 的更新方式取决于你使用的编程 Agent。通过 Git 市场安装时,通常可以使用对应 Agent 的市场更新命令;通过仓库 URL 安装时,可重新执行安装命令或拉取最新的 `main` 分支。 + +## 许可证 + +本项目采用 MIT 许可证,详情见 [LICENSE](LICENSE)。 + +## Visual Companion 遥测 + +技能和插件通常不会向作者提供任何使用反馈,因此我们无法直接了解有多少人在使用 Superpowers。默认情况下,`brainstorming` 的可选 Visual Companion 会从 Prime Radiant 网站加载品牌图标,并附带当前 Superpowers 版本号。 + +该请求不包含你的项目、提示词或编程 Agent 的详细信息,也不会记录点击行为或你正在构建的内容。它只用于粗略了解使用人数和版本分布,并且完全可选。 + +如需关闭,请把环境变量 `SUPERPOWERS_DISABLE_TELEMETRY` 设置为任意真值。Superpowers 也会遵循 Claude Code 的 `DISABLE_TELEMETRY` 和 `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` 退出设置。 + +## 社区 + +Superpowers 由 [Jesse Vincent](https://blog.fsck.com) 和 [Prime Radiant](https://primeradiant.com) 团队创建。本中文分叉由 [AreChen](https://github.com/AreChen) 维护。 + +- **Discord**:[加入上游社区](https://discord.gg/35wsABTejz),获取支持、提出问题并分享你使用 Superpowers 构建的项目。 +- **问题反馈**:https://github.com/AreChen/superpowers-zh/issues +- **上游版本公告**:[订阅通知](https://primeradiant.com/superpowers/) diff --git a/plugins/codex/plugins/superpowers-zh/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/superpowers-zh/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..01368767 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "superpowers-zh", + "repo": "https://github.com/AreChen/superpowers-zh.git", + "ref": "main", + "commit": "c51f23adcd482fd908aa60928f2ece34d12f7768", + "adapter": "codex-plugin", + "sourcePath": ".", + "syncedAt": "2026-07-14T02:27:30Z" +} diff --git a/plugins/codex/plugins/superpowers-zh/assets/app-icon.png b/plugins/codex/plugins/superpowers-zh/assets/app-icon.png new file mode 100644 index 00000000..25518da4 Binary files /dev/null and b/plugins/codex/plugins/superpowers-zh/assets/app-icon.png differ diff --git a/plugins/codex/plugins/superpowers-zh/assets/superpowers-small.svg b/plugins/codex/plugins/superpowers-zh/assets/superpowers-small.svg new file mode 100644 index 00000000..c514fe33 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/assets/superpowers-small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/SKILL.md b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/SKILL.md new file mode 100644 index 00000000..c00f5a9e --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/SKILL.md @@ -0,0 +1,155 @@ +--- +name: brainstorming +description: "在进行任何创造性工作之前,你都必须使用此技能——包括创建功能、构建组件、添加功能或修改行为。在实现之前探索用户意图、需求和设计。" +--- + +# 通过头脑风暴将想法转化为设计 + +通过自然的协作式对话,帮助将想法转化为完整成形的设计和规格说明。 + +首先了解当前项目的上下文,然后一次提出一个问题来完善想法。一旦你理解了要构建的内容,就展示设计并获得用户批准。 + + +在你展示设计并获得用户批准之前,不得调用任何实现技能、编写任何代码、搭建任何项目脚手架或采取任何实现行动。无论项目看起来多么简单,这都适用于每一个项目。 + + +## 反模式:“这太简单了,不需要设计” + +每个项目都要经过这一流程。待办事项列表、单函数实用工具、配置变更——无一例外。“简单”项目最容易因未经审视的假设而造成最多的工作浪费。设计可以很简短(对于真正简单的项目,只需几句话),但你必须展示设计并获得批准。 + +## 清单 + +你必须为以下每一项创建一项任务,并按顺序完成它们: + +1. **探索项目上下文**——检查文件、文档和最近的提交 +2. **在恰当时机提供视觉伴侣**——不要预先提供。当某个问题第一次确实用展示比用描述更清楚时,就在那时提出使用它(用一条单独的消息);获得批准后,它的浏览器标签页会为你打开。如果始终没有出现视觉问题,就绝不要提出使用它。参见下方的“视觉伴侣”一节。 +3. **提出澄清问题**——一次一个,了解目的、约束和成功标准 +4. **提出 2-3 种方案**——说明权衡取舍和你的建议 +5. **展示设计**——按复杂度划分并调整各节内容,每展示一节后都要获得用户批准 +6. **编写设计文档**——保存到 `docs/superpowers/specs/YYYY-MM-DD--design.md` 并提交 +7. **规格说明自审**——快速进行内联检查,查找占位符、矛盾、歧义和范围问题(见下文) +8. **用户审查已写好的规格说明** — 请用户在继续之前审查规格说明文件 +9. **过渡到实现** — 调用 writing-plans 技能来创建实现计划 + +## 流程 + +```dot +digraph brainstorming { + "Explore project context" [shape=box, label="探索项目上下文"]; + "Ask clarifying questions" [shape=box, label="提出澄清问题"]; + "Propose 2-3 approaches" [shape=box, label="提出 2-3 种方案"]; + "Present design sections" [shape=box, label="分节呈现设计"]; + "User approves design?" [shape=diamond, label="用户批准设计了吗?"]; + "Write design doc" [shape=box, label="编写设计文档"]; + "Spec self-review\n(fix inline)" [shape=box, label="规格说明自审\n(内联修正)"]; + "User reviews spec?" [shape=diamond, label="用户审查规格说明了吗?"]; + "Invoke writing-plans skill" [shape=doublecircle, label="调用 writing-plans 技能"]; + + "Explore project context" -> "Ask clarifying questions"; + "Ask clarifying questions" -> "Propose 2-3 approaches"; + "Propose 2-3 approaches" -> "Present design sections"; + "Present design sections" -> "User approves design?"; + "User approves design?" -> "Present design sections" [label="否,修改"]; + "User approves design?" -> "Write design doc" [label="是"]; + "Write design doc" -> "Spec self-review\n(fix inline)"; + "Spec self-review\n(fix inline)" -> "User reviews spec?"; + "User reviews spec?" -> "Write design doc" [label="要求更改"]; + "User reviews spec?" -> "Invoke writing-plans skill" [label="已批准"]; +} +``` +**终止状态是调用 writing-plans。** 绝不要调用 frontend-design、mcp-builder 或任何其他实现技能。在 brainstorming 之后,你调用的唯一技能是 writing-plans。 + +## 流程 + +**理解想法:** + +- 首先查看当前项目状态(文件、文档、最近的提交) +- 在询问详细问题之前,先评估范围:如果请求描述了多个相互独立的子系统(例如,“构建一个包含聊天、文件存储、计费和分析的平台”),应立即指出这一点。不要把问题浪费在细化一个首先需要拆分的项目的细节上。 +- 如果项目规模过大,无法用一份规格说明涵盖,请帮助用户将其拆分为多个子项目:哪些是独立的部分、它们如何关联、应按什么顺序构建?然后按照正常的设计流程,对第一个子项目进行头脑风暴。每个子项目都有其自己的规格说明 → 计划 → 实现周期。 +- 对于范围适当的项目,每次询问一个问题以细化想法 +- 尽可能优先使用多项选择题,但开放式问题也可以 +- 每条消息只问一个问题——如果某个主题需要进一步探索,请将其拆成多个问题 +- 专注于理解:目的、约束、成功标准 + +**探索方案:** + +- 提出 2-3 种不同的方案,并说明各自的权衡 +- 以对话方式呈现选项,并给出你的建议和理由 +- 先介绍你推荐的选项,并解释原因 + +**呈现设计:** + +- 一旦你确信自己理解了要构建的内容,就呈现设计 +- 根据每个部分的复杂程度调整篇幅:如果简单直接,就用几句话;如果细节微妙复杂,则最多使用 200-300 词 +- 在每个部分之后询问目前看来是否正确 +- 涵盖:架构、组件、数据流、错误处理、测试 +- 如果有些内容不合理,随时准备返回并澄清 + +**为隔离性和清晰性而设计:** +- 将系统拆分为更小的单元,每个单元都有一个明确的用途,通过定义清晰的接口进行通信,并且可以被独立理解和测试 +- 对于每个单元,你都应该能够回答:它做什么、如何使用它,以及它依赖什么? +- 其他人能否在不阅读某个单元内部实现的情况下理解它做什么?你能否在不破坏使用方的情况下更改其内部实现?如果不能,就需要改进边界。 +- 更小且边界清晰的单元也更便于你处理——对于能够一次性纳入上下文的代码,你可以进行更好的推理;当文件职责集中时,你的编辑也会更可靠。当一个文件变得很大时,这通常表明它承担了过多职责。 + +**在现有代码库中工作:** + +- 在提出更改之前,先探索当前结构。遵循现有模式。 +- 如果现有代码存在影响这项工作的问题(例如,文件变得过大、边界不清晰、职责纠缠),应将有针对性的改进纳入设计——就像优秀的开发者会改进自己正在处理的代码一样。 +- 不要提出无关的重构。始终专注于服务当前目标的内容。 + +## 设计之后 + +**文档:** + +- 将经过验证的设计(规格说明)写入 `docs/superpowers/specs/YYYY-MM-DD--design.md` + - (用户对规格说明位置的偏好会覆盖此默认设置) +- 如果可用,使用 elements-of-style:writing-clearly-and-concisely 技能 +- 将设计文档提交到 git + +**规格说明自审:** +写完规格说明文档后,以全新的视角审视它: + +1. **占位符扫描:** 是否有任何 "TBD"、"TODO"、未完成的章节或模糊的要求?修复它们。 +2. **内部一致性:** 是否有任何章节相互矛盾?架构是否与功能描述一致? +3. **范围检查:** 范围是否足够聚焦,可以用单个实现计划完成,还是需要进行拆分? +4. **歧义检查:** 是否有任何要求可能被理解为两种不同的含义?如果有,选择一种并明确写出。 + +直接修复所有问题。无需再次审查——修复后继续推进。 +**用户审查关卡:** +规格审查循环通过后,在继续之前,请用户审查已写好的规格说明: + +> “规格说明已写入 `` 并提交。请审查它,并告诉我在我们开始编写实施计划之前,你是否希望进行任何更改。” + +等待用户回复。如果他们要求更改,就进行更改并重新运行规格审查循环。只有在用户批准后才能继续。 + +**实施:** + +- 调用 writing-plans 技能来创建详细的实施计划 +- 不要调用任何其他技能。writing-plans 是下一步。 + +## 关键原则 + +- **一次只问一个问题** - 不要用多个问题让用户不知所措 +- **优先使用选择题** - 条件允许时,选择题比开放式问题更容易回答 +- **坚决贯彻 YAGNI** - 从所有设计中移除不必要的功能 +- **探索替代方案** - 在定案前始终提出 2-3 种方案 +- **增量验证** - 展示设计,获得批准后再继续 +- **保持灵活** - 当某些内容不合理时,返回并加以澄清 + +## 可视化伴侣 + +一个基于浏览器的伴侣工具,用于在头脑风暴期间展示原型图、图表和可视化选项。它作为工具提供——而不是一种模式。同意使用该伴侣工具,意味着它可用于那些采用可视化方式会更有帮助的问题;这并不意味着每个问题都要通过浏览器进行。 + +**提供伴侣工具(即时):** 不要一开始就提出使用它。等到某个问题确实用展示的方式会比口头描述更清楚时——真正涉及原型图 / 布局 / 图表的问题,而不仅仅是一个 UI *话题*。第一次出现这种情况时,就在那时提出使用它,并且该提议要单独作为一条消息: +> “接下来的这部分如果展示给你看,可能会更容易理解——在我们推进的过程中,我可以在浏览器标签页中制作原型图、图表和对比内容。它仍是新功能,并且可能会消耗大量 token。你想让我这样做吗?我会为你打开它。” + +**此提议必须单独作为一条消息发送。** 只发送该提议——不要包含澄清问题、摘要或任何其他内容。等待用户回复。如果他们接受,使用 `--open` 启动服务器,以便他们的浏览器自动打开第一个界面。如果他们拒绝,则继续仅使用文本,并且不要再次提出使用它,除非他们主动提起。 +**逐问题决策:** 即使用户接受了,也要针对每个问题分别决定使用浏览器还是终端。判断标准是:**相比阅读,用户通过观看是否能更好地理解这一点?** + +- **使用浏览器**呈现确实属于视觉内容的内容——模型图、线框图、布局对比、架构图、并排的视觉设计 +- **使用终端**呈现文本内容——需求问题、概念性选择、权衡清单、A/B/C/D 文本选项、范围决策 + +关于 UI 主题的问题并不自动等同于视觉问题。“在此上下文中,个性意味着什么?”是一个概念性问题——使用终端。“哪种向导布局效果更好?”是一个视觉问题——使用浏览器。 + +如果他们同意使用视觉伴侣,请在继续之前阅读详细指南: +`skills/brainstorming/visual-companion.md` diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/frame-template.html b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/frame-template.html new file mode 100644 index 00000000..b1cae3f1 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/frame-template.html @@ -0,0 +1,213 @@ + + + + + Superpowers 头脑风暴 + + + +
+ +
正在连接…
+
+ +
+
+ +
+
+ + + diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/helper.js b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/helper.js new file mode 100644 index 00000000..7987b3af --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/helper.js @@ -0,0 +1,167 @@ +(function() { + const MIN_RECONNECT_MS = 500; + const MAX_RECONNECT_MS = 30000; + const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected + + // Pure: next backoff delay (doubles, capped). Exported for unit tests. + function nextReconnectDelay(current, max) { + return Math.min(current * 2, max); + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS }; + } + + // Everything below is browser-only; bail out when loaded in Node (tests). + if (typeof window === 'undefined') return; + + let ws = null; + let eventQueue = []; + let reconnectDelay = MIN_RECONNECT_MS; + let reconnectTimer = null; + let disconnectedSince = null; + let everConnected = false; + let tombstoneShown = false; + + function sessionKey() { + try { + return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key'); + } catch (e) {} + return null; + } + + function websocketUrl() { + const key = sessionKey(); + return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : ''); + } + + function reloadAfterRecovery() { + const key = sessionKey(); + if (key) { + window.location.replace('/?key=' + encodeURIComponent(key)); + } else { + window.location.reload(); + } + } + + // Reflect connection state in the frame's status pill (absent on full-doc screens). + function setStatus(state) { + const el = document.querySelector('.status'); + if (!el) return; + const map = { + connecting: ['正在连接…', 'var(--text-tertiary)'], + connected: ['已连接', 'var(--success)'], + reconnecting: ['正在重新连接…', 'var(--warning)'], + disconnected: ['已断开连接', 'var(--error)'] + }; + const [text, color] = map[state] || map.disconnected; + el.textContent = text; + el.style.setProperty('--status-color', color); + } + + // Self-styled so it works on framed and full-document screens alike. + function showTombstone() { + if (tombstoneShown) return; + tombstoneShown = true; + const el = document.createElement('div'); + el.id = 'bs-tombstone'; + el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' + + 'align-items:center;justify-content:center;padding:2rem;text-align:center;' + + 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif'; + el.innerHTML = '
' + + '

伴侣已暂停

' + + '

头脑风暴伴侣已停止。' + + '请让你的编程 Agent 重新启动它——本页面会自动重新连接。

'; + if (document.body) document.body.appendChild(el); + } + + function connect() { + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + setStatus(everConnected ? 'reconnecting' : 'connecting'); + ws = new WebSocket(websocketUrl()); + + ws.onopen = () => { + const recovered = tombstoneShown; + everConnected = true; + disconnectedSince = null; + reconnectDelay = MIN_RECONNECT_MS; + tombstoneShown = false; + setStatus('connected'); + eventQueue.forEach(e => ws.send(JSON.stringify(e))); + eventQueue = []; + // Recovered from a tombstoned outage (e.g. the server restarted on the same + // port) — reload through the keyed bootstrap when possible so the cookie is + // refreshed before the visible URL returns to bare /. + if (recovered) reloadAfterRecovery(); + }; + + ws.onmessage = (msg) => { + let data; + try { data = JSON.parse(msg.data); } catch (e) { return; } + if (data.type === 'reload') window.location.reload(); + }; + + ws.onclose = () => { + ws = null; + if (disconnectedSince === null) disconnectedSince = Date.now(); + if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) { + setStatus('disconnected'); + showTombstone(); + } else { + setStatus('reconnecting'); + } + reconnectTimer = setTimeout(connect, reconnectDelay); + reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS); + }; + + // Let onclose own reconnection so we don't schedule it twice. + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + + function sendEvent(event) { + event.timestamp = Date.now(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(event)); + } else { + eventQueue.push(event); + } + } + + // Capture clicks on choice elements + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-choice]'); + if (!target) return; + + sendEvent({ + type: 'click', + text: target.textContent.trim(), + choice: target.dataset.choice, + id: target.id || null + }); + + }); + + // Frame UI: selection tracking + window.selectedChoice = null; + + window.toggleSelect = function(el) { + const container = el.closest('.options') || el.closest('.cards'); + const multi = container && container.dataset.multiselect !== undefined; + if (container && !multi) { + container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected')); + } + if (multi) { + el.classList.toggle('selected'); + } else { + el.classList.add('selected'); + } + window.selectedChoice = el.dataset.choice; + }; + + // Expose API for explicit use + window.brainstorm = { + send: sendEvent, + choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata }) + }; + + connect(); +})(); diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/server.cjs b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/server.cjs new file mode 100644 index 00000000..91fb8796 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/server.cjs @@ -0,0 +1,723 @@ +const crypto = require('crypto'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +// ========== WebSocket Protocol (RFC 6455) ========== + +const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A }; +const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024; + +function computeAcceptKey(clientKey) { + return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64'); +} + +function encodeFrame(opcode, payload) { + const fin = 0x80; + const len = payload.length; + let header; + + if (len < 126) { + header = Buffer.alloc(2); + header[0] = fin | opcode; + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = fin | opcode; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = fin | opcode; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + + return Buffer.concat([header, payload]); +} + +function decodeFrame(buffer) { + if (buffer.length < 2) return null; + + const secondByte = buffer[1]; + const opcode = buffer[0] & 0x0F; + const masked = (secondByte & 0x80) !== 0; + let payloadLen = secondByte & 0x7F; + let offset = 2; + + if (!masked) throw new Error('Client frames must be masked'); + + if (payloadLen === 126) { + if (buffer.length < 4) return null; + payloadLen = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buffer.length < 10) return null; + const extendedLen = buffer.readBigUInt64BE(2); + if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + payloadLen = Number(extendedLen); + offset = 10; + } + + if (payloadLen > MAX_FRAME_PAYLOAD_BYTES) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + + const maskOffset = offset; + const dataOffset = offset + 4; + const totalLen = dataOffset + payloadLen; + if (buffer.length < totalLen) return null; + + const mask = buffer.slice(maskOffset, dataOffset); + const data = Buffer.alloc(payloadLen); + for (let i = 0; i < payloadLen; i++) { + data[i] = buffer[dataOffset + i] ^ mask[i % 4]; + } + + return { opcode, payload: data, bytesConsumed: totalLen }; +} + +// ========== Configuration ========== + +const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null; +const randomPort = () => 49152 + Math.floor(Math.random() * 16383); +// Prefer an explicit port, else the port this session last bound (so a restart +// reuses it and an already-open browser tab reconnects), else a random high port. +function preferredPort() { + if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT); + if (PORT_FILE) { + try { + const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim()); + if (Number.isInteger(p) && p > 1023 && p < 65536) return p; + } catch (e) { /* no prior port recorded */ } + } + return randomPort(); +} +let PORT = preferredPort(); +const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1'; +const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST); +const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm'; +const CONTENT_DIR = path.join(SESSION_DIR, 'content'); +const STATE_DIR = path.join(SESSION_DIR, 'state'); +const SUPERPOWERS_VERSION = readSuperpowersVersion(); +const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png'; +const TELEMETRY_DISABLE_ENV_VARS = [ + 'SUPERPOWERS_DISABLE_TELEMETRY', + 'DISABLE_TELEMETRY', + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' +]; +const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name])); +let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null; + +// Per-session secret key. The companion is reachable by any local browser tab +// and, when bound to a non-loopback host, by any host that can route to it. +// The key authenticates the real client uniformly across loopback, tunnel, and +// remote binds — and defeats DNS rebinding — where a Host/Origin allowlist +// cannot. It rides the served URL as ?key= and is mirrored into a cookie on +// first load so same-origin subresources and the WebSocket carry it for free. +// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the +// same key and an already-open tab's cookie still validates. +const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null; +function generateToken() { + return crypto.randomBytes(32).toString('hex'); +} + +function chmodOwnerOnly(file) { + try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ } +} + +function initialToken() { + if (process.env.BRAINSTORM_TOKEN) { + return { value: process.env.BRAINSTORM_TOKEN, source: 'env' }; + } + if (TOKEN_FILE) { + try { + const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim(); + if (/^[0-9a-f]{32,}$/i.test(t)) { + chmodOwnerOnly(TOKEN_FILE); + return { value: t, source: 'file' }; + } + } catch (e) { /* no prior token recorded */ } + } + return { value: generateToken(), source: 'generated' }; +} + +const tokenInfo = initialToken(); +let TOKEN = tokenInfo.value; +let tokenSource = tokenInfo.source; +let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen + +const MIME_TYPES = { + '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml' +}; + +// ========== Templates and Constants ========== + +function waitingPage() { + return renderBranding(` + +头脑风暴伴侣 + + +

头脑风暴伴侣

+

正在等待 Agent 推送界面…

`); +} + +const FORBIDDEN_PAGE = ` + +需要会话密钥 + + +

需要会话密钥

+

本页面需要编程 Agent 提供的完整 URL,其中必须包含 +?key=… 部分。请复制完整 URL 后重新打开。

`; + +function bootstrapPage(key) { + const jsonKey = JSON.stringify(String(key)); + return ` + +正在打开头脑风暴伴侣 + + + +`; +} + +const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8'); +const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8'); +const helperInjection = ''; + +// ========== Helper Functions ========== + +function readSuperpowersVersion() { + const root = path.join(__dirname, '../../..'); + const manifests = [ + path.join(root, 'package.json'), + path.join(root, '.codex-plugin/plugin.json') + ]; + + for (const manifest of manifests) { + try { + const data = JSON.parse(fs.readFileSync(manifest, 'utf-8')); + if (data.version) return String(data.version); + } catch (e) { + // Packaged Codex plugins omit package.json; try the next manifest. + } + } + + return 'unknown'; +} + +function isTruthyEnv(value) { + if (!value) return false; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return false; + return !['0', 'false', 'no', 'off'].includes(normalized); +} + +function escapeHtmlText(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function brandMarkup() { + const version = escapeHtmlText(SUPERPOWERS_VERSION); + const text = SUPERPOWERS_TELEMETRY_DISABLED + ? 'Prime Radiant Superpowers v' + version + : 'Superpowers v' + version; + const logo = SUPERPOWERS_TELEMETRY_DISABLED + ? '' + : ''; + + return ''; +} + +function renderBranding(html) { + return html.split('').join(brandMarkup()); +} + +function isFullDocument(html) { + const trimmed = html.trimStart().toLowerCase(); + return trimmed.startsWith('', content); +} + +function getNewestScreen() { + const files = fs.readdirSync(CONTENT_DIR) + .filter(f => !f.startsWith('.') && f.endsWith('.html')) + .map(f => { + const fp = path.join(CONTENT_DIR, f); + if (!isRegularFileInsideContentDir(fp)) return null; + return { path: fp, mtime: fs.statSync(fp).mtime.getTime() }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); + return files.length > 0 ? files[0].path : null; +} + +function urlHostForHttp(host) { + const h = String(host); + if (h.startsWith('[') && h.endsWith(']')) return h; + return h.includes(':') ? '[' + h + ']' : h; +} + +function companionUrl() { + return 'http://' + urlHostForHttp(URL_HOST) + ':' + PORT + '/?key=' + TOKEN; +} + +function browserLauncherForPlatform(url, { + platform = process.platform, + osRelease = require('os').release(), + env = process.env +} = {}) { + const isWSL = platform === 'linux' && /microsoft/i.test(osRelease); + if (platform === 'darwin') return { bin: 'open', args: [url] }; + if (platform === 'win32' || isWSL) { + return { bin: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }; + } + if (env.DISPLAY || env.WAYLAND_DISPLAY) return { bin: 'xdg-open', args: [url] }; + return null; +} + +function isRegularFileInsideContentDir(filePath) { + let stat, realContentDir, realFilePath; + try { + stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) return false; + if (!stat.isFile()) return false; + if (stat.nlink !== 1) return false; + realContentDir = fs.realpathSync(CONTENT_DIR); + realFilePath = fs.realpathSync(filePath); + } catch (e) { + return false; + } + return realFilePath.startsWith(realContentDir + path.sep); +} + +// ========== Authentication ========== + +function timingSafeEqualStr(a, b) { + const ab = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim(); + } + return out; +} + +// A request is authorized if it carries the session key as ?key= or as the +// session cookie. Both are compared in constant time. +function isAuthorized(req) { + const q = req.url.indexOf('?'); + if (q >= 0) { + const params = new URLSearchParams(req.url.slice(q + 1)); + if (params.has('key')) { + const key = params.get('key'); + return Boolean(key && timingSafeEqualStr(key, TOKEN)); + } + } + const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME]; + if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true; + return false; +} + +function pathnameOf(url) { + const q = url.indexOf('?'); + return q >= 0 ? url.slice(0, q) : url; +} + +function queryKey(url) { + const q = url.indexOf('?'); + if (q < 0) return null; + return new URLSearchParams(url.slice(q + 1)).get('key'); +} + +function securityHeaders(headers = {}) { + return { + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "frame-ancestors 'none'", + 'Cross-Origin-Resource-Policy': 'same-origin', + ...headers + }; +} + +function isAllowedWebSocketOrigin(req) { + const origin = req.headers.origin; + if (!origin) return true; + const host = req.headers.host; + if (!host) return false; + return origin === 'http://' + host; +} + +// ========== HTTP Request Handler ========== + +function handleRequest(req, res) { + if (!isAuthorized(req)) { + res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(FORBIDDEN_PAGE); + return; + } + touchActivity(); // only authorized requests count as activity + + // Mirror the key into a cookie so same-origin subresources (/files/*) can + // authenticate after bootstrap. HttpOnly keeps it away from page scripts; the + // WebSocket Origin check below is what blocks cross-origin localhost injection. + res.setHeader('Set-Cookie', + COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/'); + + const pathname = pathnameOf(req.url); + const keyFromQuery = queryKey(req.url); + if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) { + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(bootstrapPage(keyFromQuery)); + } else if (req.method === 'GET' && pathname === '/') { + const screenFile = getNewestScreen(); + let html = screenFile + ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8')) + : waitingPage(); + + if (html.includes('')) { + html = html.replace('', helperInjection + '\n'); + } else { + html += helperInjection; + } + + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(html); + } else if (req.method === 'GET' && pathname.startsWith('/files/')) { + const fileName = path.basename(pathname.slice(7)); + const filePath = path.join(CONTENT_DIR, fileName); + // Reject empty/dotfile names and anything that isn't a regular file — + // `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR). + if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, securityHeaders({ 'Content-Type': contentType })); + res.end(fs.readFileSync(filePath)); + } else { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + } +} + +// ========== WebSocket Connection Handling ========== + +const clients = new Set(); + +function handleUpgrade(req, socket) { + if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; } + + const key = req.headers['sec-websocket-key']; + if (!key) { socket.destroy(); return; } + + const accept = computeAcceptKey(key); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n' + ); + + let buffer = Buffer.alloc(0); + clients.add(socket); + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length > 0) { + let result; + try { + result = decodeFrame(buffer); + } catch (e) { + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + } + if (!result) break; + buffer = buffer.slice(result.bytesConsumed); + + switch (result.opcode) { + case OPCODES.TEXT: + handleMessage(result.payload.toString()); + break; + case OPCODES.CLOSE: + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + case OPCODES.PING: + socket.write(encodeFrame(OPCODES.PONG, result.payload)); + break; + case OPCODES.PONG: + break; + default: { + const closeBuf = Buffer.alloc(2); + closeBuf.writeUInt16BE(1003); + socket.end(encodeFrame(OPCODES.CLOSE, closeBuf)); + clients.delete(socket); + return; + } + } + } + }); + + socket.on('close', () => clients.delete(socket)); + socket.on('error', () => clients.delete(socket)); +} + +function handleMessage(text) { + let event; + try { + event = JSON.parse(text); + } catch (e) { + console.error('Failed to parse WebSocket message:', e.message); + return; + } + touchActivity(); + console.log(JSON.stringify({ source: 'user-event', ...event })); + if (event && event.choice) { + const eventsFile = path.join(STATE_DIR, 'events'); + fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n'); + } +} + +function broadcast(msg) { + const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg))); + for (const socket of clients) { + try { socket.write(frame); } catch (e) { clients.delete(socket); } + } +} + +// Best-effort: open the user's browser the first time a screen is actually ready +// to show. Skips when disabled, on a non-loopback (remote) bind, or when a +// browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD. +let browserOpened = false; +function maybeOpenBrowser() { + if (browserOpened) return; + browserOpened = true; + if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion + if (HOST !== '127.0.0.1' && HOST !== 'localhost') return; + if (clients.size > 0) return; // the user already opened it + const url = companionUrl(); // must carry the key or the gate 403s it + const cp = require('child_process'); + // Operator-provided launcher: run as given (this env var is trusted operator input). + if (process.env.BRAINSTORM_OPEN_CMD) { + try { cp.exec(process.env.BRAINSTORM_OPEN_CMD + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ } + return; + } + // Platform launchers: pass the URL as an argv element via execFile (no shell), + // so a url-host containing shell metacharacters can't inject a command. + const launcher = browserLauncherForPlatform(url); + if (!launcher) return; // headless: nothing to open + try { cp.execFile(launcher.bin, launcher.args, () => {}); } catch (e) { /* best effort */ } +} + +// ========== Activity Tracking ========== + +// Idle timeout: shut down after this long with no activity. Default 4 hours; +// override with BRAINSTORM_IDLE_TIMEOUT_MS (start-server.sh: --idle-timeout-minutes). +const IDLE_TIMEOUT_MS = (() => { + const ms = Number(process.env.BRAINSTORM_IDLE_TIMEOUT_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 4 * 60 * 60 * 1000; +})(); +// How often the watchdog checks for owner-death / idleness. Configurable mainly +// so tests can run fast; production default is 60s. +const LIFECYCLE_CHECK_MS = (() => { + const ms = Number(process.env.BRAINSTORM_LIFECYCLE_CHECK_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 60 * 1000; +})(); +let lastActivity = Date.now(); + +function touchActivity() { + lastActivity = Date.now(); +} + +// ========== File Watching ========== + +const debounceTimers = new Map(); + +// ========== Server Startup ========== + +function startServer() { + if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true }); + if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); + + // Track known files to distinguish new screens from updates. + // macOS fs.watch reports 'rename' for both new files and overwrites, + // so we can't rely on eventType alone. + const knownFiles = new Set( + fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html')) + ); + + const server = http.createServer(handleRequest); + server.on('upgrade', handleUpgrade); + + const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => { + if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return; + + if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename)); + debounceTimers.set(filename, setTimeout(() => { + debounceTimers.delete(filename); + const filePath = path.join(CONTENT_DIR, filename); + + if (!fs.existsSync(filePath)) return; // file was deleted + touchActivity(); + + if (!knownFiles.has(filename)) { + knownFiles.add(filename); + const eventsFile = path.join(STATE_DIR, 'events'); + if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile); + console.log(JSON.stringify({ type: 'screen-added', file: filePath })); + maybeOpenBrowser(); + } else { + console.log(JSON.stringify({ type: 'screen-updated', file: filePath })); + } + + broadcast({ type: 'reload' }); + }, 100)); + }); + watcher.on('error', (err) => console.error('fs.watch error:', err.message)); + + function shutdown(reason) { + console.log(JSON.stringify({ type: 'server-stopped', reason })); + const infoFile = path.join(STATE_DIR, 'server-info'); + if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile); + fs.writeFileSync( + path.join(STATE_DIR, 'server-stopped'), + JSON.stringify({ reason, timestamp: Date.now() }) + '\n' + ); + watcher.close(); + clearInterval(lifecycleCheck); + // Close any upgraded WebSocket sockets so server.close() can complete and + // the process actually exits instead of lingering on an open connection. + for (const socket of clients) { + try { socket.destroy(); } catch (e) { /* already gone */ } + } + server.close(() => process.exit(0)); + } + + function ownerAlive() { + if (!ownerPid) return true; + try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; } + } + + // Periodically exit if the owner process died or we've been idle too long. + const lifecycleCheck = setInterval(() => { + if (!ownerAlive()) shutdown('owner process exited'); + else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout'); + }, LIFECYCLE_CHECK_MS); + lifecycleCheck.unref(); + + // Validate owner PID at startup. If it's already dead, the PID resolution + // was wrong (common on WSL, Tailscale SSH, and cross-user scenarios). + // Disable monitoring and rely on the idle timeout instead. + if (ownerPid) { + try { process.kill(ownerPid, 0); } + catch (e) { + if (e.code !== 'EPERM') { + console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' })); + ownerPid = null; + } + } + } + + // If the preferred port is already taken (e.g. a previous server is still + // alive), fall back to a random port once instead of failing. + let triedFallback = false; + + function onListen() { + // Cookie name keys on the ACTUAL bound port (may differ from the preferred + // one after an EADDRINUSE fallback) so it can't collide with another server's + // cookie in the shared localhost jar. + COOKIE_NAME = 'brainstorm-key-' + PORT; + // Record the bound port AND token so the next restart of this session reuses + // them — but ONLY when we got our preferred port. On a fallback we bound a + // *different* port because someone else holds the preferred one; persisting + // would overwrite the shared files and strand that other session's open tab. + if (PORT_FILE && !triedFallback) { + try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ } + if (TOKEN_FILE) { + try { + fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 }); + chmodOwnerOnly(TOKEN_FILE); + } catch (e) { /* best effort */ } + } + } + const info = JSON.stringify({ + type: 'server-started', port: Number(PORT), host: HOST, + url_host: URL_HOST, url: companionUrl(), + screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS + }); + console.log(info); + // server-info embeds the key — keep it owner-only. + fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 }); + } + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE' && !triedFallback) { + if (tokenSource === 'env') { + console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token'); + process.exit(1); + } + triedFallback = true; + PORT = randomPort(); + if (tokenSource === 'file') { + TOKEN = generateToken(); + tokenSource = 'generated-fallback'; + } + server.listen(PORT, HOST, onListen); + } else { + console.error('Server failed to bind:', err.message); + process.exit(1); + } + }); + server.listen(PORT, HOST, onListen); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { + computeAcceptKey, + encodeFrame, + decodeFrame, + browserLauncherForPlatform, + OPCODES, + MAX_FRAME_PAYLOAD_BYTES +}; diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/start-server.sh b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/start-server.sh new file mode 100755 index 00000000..016a8e48 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/start-server.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Start the brainstorm server and output connection info +# Usage: start-server.sh [--project-dir ] [--host ] [--url-host ] [--foreground] [--background] +# +# Starts server on a random high port, outputs JSON with URL. +# Each session gets its own directory to avoid conflicts. +# +# Options: +# --project-dir Store session files under /.superpowers/brainstorm/ +# instead of /tmp. Files persist after server stops. +# --host Host/interface to bind (default: 127.0.0.1). +# Use 0.0.0.0 in remote/containerized environments. +# --url-host Hostname shown in returned URL JSON. +# --idle-timeout-minutes Shut down after n minutes idle (default 240 = 4h). +# --open Auto-open the browser on the first screen (use only +# after the user approves the visual companion). +# --foreground Run server in the current terminal (no backgrounding). +# --background Force background mode (overrides Codex auto-foreground). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Parse arguments +PROJECT_DIR="" +FOREGROUND="false" +FORCE_BACKGROUND="false" +BIND_HOST="127.0.0.1" +URL_HOST="" +IDLE_TIMEOUT_MINUTES="" +while [[ $# -gt 0 ]]; do + case "$1" in + --project-dir) + PROJECT_DIR="$2" + shift 2 + ;; + --host) + BIND_HOST="$2" + shift 2 + ;; + --url-host) + URL_HOST="$2" + shift 2 + ;; + --idle-timeout-minutes) + IDLE_TIMEOUT_MINUTES="$2" + shift 2 + ;; + --open) + export BRAINSTORM_OPEN=1 + shift + ;; + --foreground|--no-daemon) + FOREGROUND="true" + shift + ;; + --background|--daemon) + FORCE_BACKGROUND="true" + shift + ;; + *) + echo "{\"error\": \"Unknown argument: $1\"}" + exit 1 + ;; + esac +done + +if [[ -z "$URL_HOST" ]]; then + if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then + URL_HOST="localhost" + else + URL_HOST="$BIND_HOST" + fi +fi + +if [[ -n "$IDLE_TIMEOUT_MINUTES" ]]; then + if ! [[ "$IDLE_TIMEOUT_MINUTES" =~ ^[0-9]+$ ]] || [[ "$IDLE_TIMEOUT_MINUTES" -lt 1 ]]; then + echo "{\"error\": \"--idle-timeout-minutes must be a positive integer\"}" + exit 1 + fi + export BRAINSTORM_IDLE_TIMEOUT_MS=$(( IDLE_TIMEOUT_MINUTES * 60 * 1000 )) +fi + +is_windows_like_shell() { + case "${OSTYPE:-}" in + msys*|cygwin*|mingw*) return 0 ;; + esac + if [[ -n "${MSYSTEM:-}" ]]; then + return 0 + fi + local uname_s + uname_s="$(uname -s 2>/dev/null || true)" + case "$uname_s" in + MSYS*|MINGW*|CYGWIN*) return 0 ;; + esac + return 1 +} + +# Some environments reap detached/background processes. Auto-foreground when detected. +if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + FOREGROUND="true" +fi + +# Windows/Git Bash reaps nohup background processes. Auto-foreground when detected. +if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + if is_windows_like_shell; then + FOREGROUND="true" + fi +fi + +# Session files (server.log, server-info, .last-token) embed the session key — +# keep everything this script and the server create owner-only. +umask 077 + +# Generate unique session directory +SESSION_ID="$$-$(date +%s)" + +if [[ -n "$PROJECT_DIR" ]]; then + SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}" + # Persist the bound port and key per project so a restart reuses them and an + # already-open browser tab reconnects to the same URL with a valid cookie. + export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port" + export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token" +else + SESSION_DIR="/tmp/brainstorm-${SESSION_ID}" +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +LOG_FILE="${STATE_DIR}/server.log" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +# Create fresh session directory with content and state peers +mkdir -p "${SESSION_DIR}/content" "$STATE_DIR" + +SERVER_ID="" +if [[ -r /dev/urandom ]]; then + SERVER_ID="$(od -An -N24 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || true)" +fi +if ! [[ "$SERVER_ID" =~ ^[A-Za-z0-9_-]{32,64}$ ]]; then + SERVER_ID="$(printf '%08x%08x%08x%08x' "$$" "$(date +%s)" "${RANDOM:-0}" "${RANDOM:-0}")" +fi +printf '%s\n' "$SERVER_ID" > "$SERVER_ID_FILE" +chmod 600 "$SERVER_ID_FILE" 2>/dev/null || true + +# Kill any existing server +if [[ -f "$PID_FILE" ]]; then + old_pid=$(cat "$PID_FILE") + kill "$old_pid" 2>/dev/null + rm -f "$PID_FILE" +fi + +cd "$SCRIPT_DIR" || exit 1 + +# Resolve the harness PID (grandparent of this script). +# $PPID is the ephemeral shell the harness spawned to run us — it dies +# when this script exits. The harness itself is $PPID's parent. +OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')" +if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then + OWNER_PID="$PPID" +fi + +# Windows/MSYS2: Node.js cannot see POSIX PIDs from the MSYS2 namespace. +# Passing a PID node cannot verify causes server to log owner-pid-invalid +# and self-terminate at the 60-second lifecycle check. Clear it so the +# watchdog is disabled and the idle timeout becomes the only shutdown trigger. +if is_windows_like_shell; then + OWNER_PID="" +fi + +# Foreground mode for environments that reap detached/background processes. +if [[ "$FOREGROUND" == "true" ]]; then + env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" & + SERVER_PID=$! + echo "$SERVER_PID" > "$PID_FILE" + wait "$SERVER_PID" + exit $? +fi + +# Start server, capturing output to log file +# Use nohup to survive shell exit; disown to remove from job table +nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +disown "$SERVER_PID" 2>/dev/null +echo "$SERVER_PID" > "$PID_FILE" + +# Wait for server-started message (check log file) +for _ in {1..50}; do + if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then + # Verify server is still alive after a short window (catches process reapers) + alive="true" + for _ in {1..20}; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + alive="false" + break + fi + sleep 0.1 + done + if [[ "$alive" != "true" ]]; then + echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}" + exit 1 + fi + grep "server-started" "$LOG_FILE" | head -1 + exit 0 + fi + sleep 0.1 +done + +# Timeout - server didn't start +echo '{"error": "Server failed to start within 5 seconds"}' +exit 1 diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/stop-server.sh b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/stop-server.sh new file mode 100755 index 00000000..7cacfe94 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/stop-server.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Stop the brainstorm server and clean up +# Usage: stop-server.sh +# +# Kills the server process. Only deletes session directory if it's +# under /tmp (ephemeral). Persistent directories (.superpowers/) are +# kept so mockups can be reviewed later. + +SESSION_DIR="$1" + +if [[ -z "$SESSION_DIR" ]]; then + echo '{"error": "Usage: stop-server.sh "}' + exit 1 +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +mark_stopped() { + local reason="$1" + rm -f "${STATE_DIR}/server-info" + printf '{"reason":"%s","timestamp":%s}\n' "$reason" "$(date +%s)" > "${STATE_DIR}/server-stopped" +} + +read_expected_server_id() { + [[ -f "$SERVER_ID_FILE" ]] || return 1 + local id + id="$(tr -d '\r\n' < "$SERVER_ID_FILE" 2>/dev/null || true)" + [[ "$id" =~ ^[A-Za-z0-9_-]{32,64}$ ]] || return 1 + printf '%s\n' "$id" +} + +command_line_for_pid() { + local pid="$1" + if [[ -r "/proc/$pid/cmdline" ]]; then + tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true + return 0 + fi + ps -ww -p "$pid" -o command= 2>/dev/null || ps -f -p "$pid" 2>/dev/null | sed '1d' || true +} + +command_has_server_id() { + local pid="$1" + local expected="$2" + local expected_arg="--brainstorm-server-id=$expected" + if [[ -r "/proc/$pid/cmdline" ]]; then + local arg + while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do + [[ "$arg" == "$expected_arg" ]] && return 0 + done < "/proc/$pid/cmdline" + return 1 + fi + local command_line + command_line="$(command_line_for_pid "$pid")" + [[ -n "$command_line" ]] || return 1 + case " $command_line " in + *" $expected_arg "*) return 0 ;; + *) return 1 ;; + esac +} + +# Confirm a PID has this session's per-start instance id, not just a familiar +# process name. Ambiguous or legacy metadata fails closed as stale_pid. +is_brainstorm_server() { + kill -0 "$1" 2>/dev/null || return 1 + local expected_id + expected_id="$(read_expected_server_id)" || return 1 + command_has_server_id "$1" "$expected_id" || return 1 + return 0 +} + +if [[ -f "$PID_FILE" ]]; then + pid=$(cat "$PID_FILE") + + # Refuse to signal a PID we can't prove is our server. A stale pid file may + # point at an unrelated process after a reboot/PID wraparound. + if ! is_brainstorm_server "$pid"; then + rm -f "$PID_FILE" "$SERVER_ID_FILE" + mark_stopped "stale_pid" + echo '{"status": "stale_pid"}' + exit 0 + fi + + # Try to stop gracefully, fallback to force if still alive + kill "$pid" 2>/dev/null || true + + # Wait for graceful shutdown (up to ~2s) + for _ in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + + # If still running, escalate to SIGKILL + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + + # Give SIGKILL a moment to take effect + sleep 0.1 + fi + + if kill -0 "$pid" 2>/dev/null; then + echo '{"status": "failed", "error": "process still running"}' + exit 1 + fi + + rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log" + mark_stopped "stop-server.sh" + + # Only delete ephemeral /tmp directories + if [[ "$SESSION_DIR" == /tmp/* ]]; then + rm -rf "$SESSION_DIR" + fi + + echo '{"status": "stopped"}' +else + echo '{"status": "not_running"}' +fi diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/spec-document-reviewer-prompt.md b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/spec-document-reviewer-prompt.md new file mode 100644 index 00000000..35938dc7 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/spec-document-reviewer-prompt.md @@ -0,0 +1,48 @@ +# 规范文档审查员提示词模板 + +派遣规范文档审查员子 Agent 时,请使用此模板。 + +**目的:** 验证规范是否完整、一致,并已准备好进入实施规划阶段。 + +**派遣时机:** 规范文档已写入 docs/superpowers/specs/ 后 + +``` +子 Agent (general-purpose): + description: "审查规范文档" + prompt: | + 你是一名规范文档审查员。请验证此规范是否完整并已准备好进入规划阶段。 + + **待审查的规范:** [SPEC_FILE_PATH] + + ## 检查内容 + + | 类别 | 检查要点 | + |----------|------------------| + | 完整性 | TODOs、占位符、"TBD"、不完整的章节 | + | 一致性 | 内部矛盾、相互冲突的要求 | + | 清晰度 | 要求是否含糊到足以导致他人构建出错误的内容 | + | 范围 | 是否足够聚焦,可由单个计划涵盖——而不是覆盖多个相互独立的子系统 | + | YAGNI | 未请求的功能、过度工程化 | + + ## 校准 + + **只标记会在实施规划期间造成实际问题的问题。** + 缺少章节、存在矛盾,或某项要求含糊到可能被解读为两种不同的含义—— + 这些才是问题。轻微的措辞改进、风格偏好以及“某些章节不如其他章节详细”均不属于问题。 + + 除非存在会导致计划有缺陷的严重缺漏,否则应予以批准。 + + ## 输出格式 + + ## 规范审查 + + **状态:** 通过 | 发现问题 + + **问题(如有):** + - [第 X 节]:[具体问题] — [它为何会影响规划] + + **建议(仅供参考,不阻碍批准):** + - [改进建议] +``` + +**审查员返回:** 状态、问题(如有)、建议 diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/visual-companion.md b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/visual-companion.md new file mode 100644 index 00000000..f57b9d61 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/visual-companion.md @@ -0,0 +1,281 @@ +# 可视化伴侣指南 + +基于浏览器的可视化头脑风暴伴侣,用于展示模型稿、图示和选项。 + +## 何时使用 + +针对每个问题而非每个会话进行决定。判断标准是:**用户通过看它而不是阅读它,是否能理解得更好?** + +当内容本身是可视化内容时,**使用浏览器**: + +- **UI 模型稿**——线框图、布局、导航结构、组件设计 +- **架构图**——系统组件、数据流、关系图 +- **并排视觉比较**——比较两种布局、两套配色方案、两个设计方向 +- **设计润色**——当问题涉及观感、间距、视觉层级时 +- **空间关系**——以图示形式呈现的状态机、流程图、实体关系 + +当内容是文本或表格时,**使用终端**: + +- **需求和范围问题**——“X 是什么意思?”、“哪些功能在范围内?” +- **概念性 A/B/C 选择**——在用文字描述的方案之间进行选择 +- **权衡清单**——优缺点、比较表 +- **技术决策**——API 设计、数据建模、架构方案选择 +- **澄清问题**——任何答案是文字而非视觉偏好的问题 + +一个*关于* UI 主题的问题并不自动等同于视觉问题。“你想要哪种向导?”是概念性问题——使用终端。“这些向导布局中,哪一种感觉合适?”是视觉问题——使用浏览器。 + +## 工作原理 + +服务器会监视一个目录中的 HTML 文件,并将最新的文件提供给浏览器。你将 HTML 内容写入 `screen_dir`,用户会在其浏览器中看到内容,并可通过点击选择选项。选择结果会记录到 `state_dir/events`,供你在下一轮读取。 + +**内容片段与完整文档:**如果你的 HTML 文件以 `/.superpowers/brainstorm/` 中查找会话目录。 + +**注意:**将项目根目录作为 `--project-dir` 传入,以便模型稿持久保存在 `.superpowers/brainstorm/` 中,并在服务器重启后继续保留。如果不这样做,文件会写入 `/tmp` 并被清理。如果 `.superpowers/` 尚未加入 `.gitignore`,请提醒用户添加它。 + +**按平台启动服务器:** + +**Claude Code:** +```bash +# 默认模式即可——脚本会自行将服务器置于后台运行。 +scripts/start-server.sh --project-dir /path/to/project --open +``` + +在 Windows 上,脚本会自动检测并切换到前台模式(这会阻塞工具调用)。在 Bash 工具调用中使用 `run_in_background: true`,使服务器能够跨对话轮次持续运行,然后在下一轮读取 `$STATE_DIR/server-info` 以获取 URL 和端口。 + +**Codex:** +```bash +# Codex 会清理后台进程。脚本会自动检测 CODEX_CI 并 +# 切换到前台模式。正常运行即可——无需额外标志。 +scripts/start-server.sh --project-dir /path/to/project --open +``` + +**Copilot CLI:** +```bash +# 使用 --foreground,并通过 bash 工具以 mode: "async" 启动服务器, +# 使进程能够跨轮次持续运行。保存返回的 shellId,以便之后需要与其交互时 +# 用于 read_bash / stop_bash。 +scripts/start-server.sh --project-dir /path/to/project --open --foreground +``` + +**其他环境:**服务器必须在后台跨对话轮次持续运行。如果你的环境会清理已分离的进程,请使用 `--foreground`,并通过你所在平台的后台执行机制启动该命令。 + +如果你的浏览器无法访问该 URL(这在远程/容器化环境中很常见),请绑定非环回主机: + +```bash +scripts/start-server.sh \ + --project-dir /path/to/project \ + --host 0.0.0.0 \ + --url-host localhost +``` + +使用 `--url-host` 控制返回的 URL JSON 中输出的主机名。 + +## 循环 +1. **检查服务器是否仍在运行**,然后**将 HTML 写入** `screen_dir` 中的新文件: + - **必需:在提及 URL 或推送界面之前,确认服务器仍在运行。** 检查 `$STATE_DIR/server-info` 是否存在,并且 `$STATE_DIR/server-stopped` 不存在。如果服务器已关闭,请使用**相同的 `--project-dir`** 通过 `start-server.sh` 重新启动它——它会复用相同的端口,因此用户已打开的标签页会自行重新连接(服务器停机期间会显示“已暂停”遮罩层),你无需发送新的 URL。服务器空闲 4 小时后会自动退出(可通过 `--idle-timeout-minutes` 配置)。 + - 使用语义化文件名:`platform.html`、`visual-style.html`、`layout.html` + - **绝不要重复使用文件名**——每个界面都使用一个全新的文件 + - 使用你的文件创建工具——**绝不要使用 cat/heredoc**(会向终端倾倒大量杂乱信息) + - 服务器会自动提供最新的文件 + +2. **告诉用户会看到什么,然后结束你的回合:** + - 每一步都提醒他们 URL(而不只是第一步) + - 简要概括界面上显示的内容(例如,“正在展示主页的 3 种布局选项”) + - 请他们在终端中回复:“看一下,然后告诉我你的想法。如果愿意,请点击选择一个选项。” + +3. **在你的下一个回合中**——用户在终端中回复后: + - 如果 `$STATE_DIR/events` 存在,则读取它——其中以 JSON 行的形式包含用户的浏览器交互 + - 将其与用户的终端文本合并,以了解完整情况 + - 终端消息是主要反馈;`state_dir/events` 提供结构化交互数据 + +4. **迭代或推进**——如果反馈会改变当前界面,则写入一个新文件(例如 `layout-v2.html`)。只有当前步骤得到验证后,才能进入下一个问题。 + +5. **返回终端时卸载内容**——当下一步不需要浏览器时(例如,提出澄清问题、讨论权衡取舍),推送一个等待界面以清除过时内容: + + ```html + +
+

继续在终端中...

+
+ ``` + + 这样可避免在对话已经继续推进后,用户仍盯着一个已经解决的选择。当下一个可视化问题出现时,像往常一样推送一个新的内容文件。 + +6. 重复上述步骤,直到完成。 + +## 编写内容片段 + +只编写放入页面内部的内容。服务器会自动使用框架模板将其包装起来(页眉、主题 CSS、连接状态以及所有交互基础设施)。 + +**最小示例:** + +```html +

哪种布局更合适?

+

请考虑可读性和视觉层级

+ +
+
+
A
+
+

单栏

+

简洁、专注的阅读体验

+
+
+
+
B
+
+

双栏

+

侧边栏导航搭配主内容区

+
+
+
+``` + +就这些。不需要 ``、CSS,也不需要 `