From d91c4018cf27dbc7b1c0847e42668b3c72281bd3 Mon Sep 17 00:00:00 2001 From: Fortuitous <92347892+wangfeizong1001@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:21:18 +0800 Subject: [PATCH 01/10] feat: port fork customizations - skills, vendors.contrib, changelog Stage 2 of rebuild-from-fork: port zero-risk new files from fork. - Add 20 custom skills (brainstorming, chinese-code-review, mcp-builder, etc.) - Add .agents/rules.md - Add vendors.contrib.ts (DeepSeek/Qwen/Zhipu/Baichuan/MiniMax/Ollama providers) - Add CHANGELOG.md from fork Verified: agent-core-v2 typecheck passes. --- .agents/rules.md | 39 + .agents/skills/brainstorming/SKILL.md | 169 +++ .../brainstorming/scripts/frame-template.html | 213 +++ .../skills/brainstorming/scripts/helper.js | 167 +++ .../skills/brainstorming/scripts/server.cjs | 723 +++++++++++ .../brainstorming/scripts/start-server.sh | 209 +++ .../brainstorming/scripts/stop-server.sh | 120 ++ .../spec-document-reviewer-prompt.md | 48 + .../skills/brainstorming/visual-companion.md | 286 ++++ .agents/skills/chinese-code-review/SKILL.md | 282 ++++ .../chinese-commit-conventions/SKILL.md | 369 ++++++ .agents/skills/chinese-documentation/SKILL.md | 453 +++++++ .agents/skills/chinese-git-workflow/SKILL.md | 552 ++++++++ .../dispatching-parallel-agents/SKILL.md | 187 +++ .agents/skills/executing-plans/SKILL.md | 180 +++ .../finishing-a-development-branch/SKILL.md | 280 ++++ .agents/skills/mcp-builder/SKILL.md | 260 ++++ .agents/skills/receiving-code-review/SKILL.md | 218 ++++ .../skills/requesting-code-review/SKILL.md | 108 ++ .../requesting-code-review/code-reviewer.md | 166 +++ .../subagent-driven-development/SKILL.md | 324 +++++ .../implementer-prompt.md | 139 ++ .../scripts/review-package | 44 + .../scripts/sdd-workspace | 22 + .../scripts/task-brief | 44 + .../task-reviewer-prompt.md | 169 +++ .../systematic-debugging/CREATION-LOG.md | 119 ++ .agents/skills/systematic-debugging/SKILL.md | 301 +++++ .../condition-based-waiting-example.ts | 158 +++ .../condition-based-waiting.md | 115 ++ .../systematic-debugging/defense-in-depth.md | 122 ++ .../systematic-debugging/find-polluter.sh | 63 + .../root-cause-tracing.md | 169 +++ .../systematic-debugging/test-academic.md | 14 + .../systematic-debugging/test-pressure-1.md | 58 + .../systematic-debugging/test-pressure-2.md | 68 + .../systematic-debugging/test-pressure-3.md | 69 + .../skills/test-driven-development/SKILL.md | 376 ++++++ .../testing-anti-patterns.md | 299 +++++ .agents/skills/using-git-worktrees/SKILL.md | 225 ++++ .agents/skills/using-superpowers/SKILL.md | 90 ++ .../references/codex-tools.md | 25 + .../references/copilot-tools.md | 52 + .../references/gemini-tools.md | 33 + .../references/hermes-tools.md | 44 + .../using-superpowers/references/pi-tools.md | 28 + .../references/qoder-tools.md | 43 + .../verification-before-completion/SKILL.md | 144 +++ .agents/skills/workflow-runner/SKILL.md | 177 +++ .agents/skills/writing-plans/SKILL.md | 157 +++ .../plan-document-reviewer-prompt.md | 49 + .agents/skills/writing-skills/SKILL.md | 659 ++++++++++ .../anthropic-best-practices.md | 1149 +++++++++++++++++ .../examples/CLAUDE_MD_TESTING.md | 189 +++ .../writing-skills/graphviz-conventions.dot | 172 +++ .../writing-skills/persuasion-principles.md | 187 +++ .../skills/writing-skills/render-graphs.js | 168 +++ .../testing-skills-with-subagents.md | 384 ++++++ CHANGELOG.md | 238 ++++ .../provider/providers/vendors.contrib.ts | 70 + 60 files changed, 11985 insertions(+) create mode 100644 .agents/rules.md create mode 100644 .agents/skills/brainstorming/SKILL.md create mode 100644 .agents/skills/brainstorming/scripts/frame-template.html create mode 100644 .agents/skills/brainstorming/scripts/helper.js create mode 100644 .agents/skills/brainstorming/scripts/server.cjs create mode 100644 .agents/skills/brainstorming/scripts/start-server.sh create mode 100644 .agents/skills/brainstorming/scripts/stop-server.sh create mode 100644 .agents/skills/brainstorming/spec-document-reviewer-prompt.md create mode 100644 .agents/skills/brainstorming/visual-companion.md create mode 100644 .agents/skills/chinese-code-review/SKILL.md create mode 100644 .agents/skills/chinese-commit-conventions/SKILL.md create mode 100644 .agents/skills/chinese-documentation/SKILL.md create mode 100644 .agents/skills/chinese-git-workflow/SKILL.md create mode 100644 .agents/skills/dispatching-parallel-agents/SKILL.md create mode 100644 .agents/skills/executing-plans/SKILL.md create mode 100644 .agents/skills/finishing-a-development-branch/SKILL.md create mode 100644 .agents/skills/mcp-builder/SKILL.md create mode 100644 .agents/skills/receiving-code-review/SKILL.md create mode 100644 .agents/skills/requesting-code-review/SKILL.md create mode 100644 .agents/skills/requesting-code-review/code-reviewer.md create mode 100644 .agents/skills/subagent-driven-development/SKILL.md create mode 100644 .agents/skills/subagent-driven-development/implementer-prompt.md create mode 100644 .agents/skills/subagent-driven-development/scripts/review-package create mode 100644 .agents/skills/subagent-driven-development/scripts/sdd-workspace create mode 100644 .agents/skills/subagent-driven-development/scripts/task-brief create mode 100644 .agents/skills/subagent-driven-development/task-reviewer-prompt.md create mode 100644 .agents/skills/systematic-debugging/CREATION-LOG.md create mode 100644 .agents/skills/systematic-debugging/SKILL.md create mode 100644 .agents/skills/systematic-debugging/condition-based-waiting-example.ts create mode 100644 .agents/skills/systematic-debugging/condition-based-waiting.md create mode 100644 .agents/skills/systematic-debugging/defense-in-depth.md create mode 100644 .agents/skills/systematic-debugging/find-polluter.sh create mode 100644 .agents/skills/systematic-debugging/root-cause-tracing.md create mode 100644 .agents/skills/systematic-debugging/test-academic.md create mode 100644 .agents/skills/systematic-debugging/test-pressure-1.md create mode 100644 .agents/skills/systematic-debugging/test-pressure-2.md create mode 100644 .agents/skills/systematic-debugging/test-pressure-3.md create mode 100644 .agents/skills/test-driven-development/SKILL.md create mode 100644 .agents/skills/test-driven-development/testing-anti-patterns.md create mode 100644 .agents/skills/using-git-worktrees/SKILL.md create mode 100644 .agents/skills/using-superpowers/SKILL.md create mode 100644 .agents/skills/using-superpowers/references/codex-tools.md create mode 100644 .agents/skills/using-superpowers/references/copilot-tools.md create mode 100644 .agents/skills/using-superpowers/references/gemini-tools.md create mode 100644 .agents/skills/using-superpowers/references/hermes-tools.md create mode 100644 .agents/skills/using-superpowers/references/pi-tools.md create mode 100644 .agents/skills/using-superpowers/references/qoder-tools.md create mode 100644 .agents/skills/verification-before-completion/SKILL.md create mode 100644 .agents/skills/workflow-runner/SKILL.md create mode 100644 .agents/skills/writing-plans/SKILL.md create mode 100644 .agents/skills/writing-plans/plan-document-reviewer-prompt.md create mode 100644 .agents/skills/writing-skills/SKILL.md create mode 100644 .agents/skills/writing-skills/anthropic-best-practices.md create mode 100644 .agents/skills/writing-skills/examples/CLAUDE_MD_TESTING.md create mode 100644 .agents/skills/writing-skills/graphviz-conventions.dot create mode 100644 .agents/skills/writing-skills/persuasion-principles.md create mode 100644 .agents/skills/writing-skills/render-graphs.js create mode 100644 .agents/skills/writing-skills/testing-skills-with-subagents.md create mode 100644 CHANGELOG.md create mode 100644 packages/agent-core-v2/src/kosong/provider/providers/vendors.contrib.ts diff --git a/.agents/rules.md b/.agents/rules.md new file mode 100644 index 0000000000..e5d7ae0cbb --- /dev/null +++ b/.agents/rules.md @@ -0,0 +1,39 @@ +# Superpowers-ZH 中文增强版 + +本项目已安装 superpowers-zh 技能框架(20 个 skills)。 + +## 核心规则 + +1. **收到任务时,先检查是否有匹配的 skill** — 哪怕只有 1% 的可能性也要检查 +2. **设计先于编码** — 收到功能需求时,先用 brainstorming skill 做需求分析 +3. **测试先于实现** — 写代码前先写测试(TDD) +4. **验证先于完成** — 声称完成前必须运行验证命令 + +## 可用 Skills + +Skills 位于 `.agents/skills/` 目录,每个 skill 有独立的 `SKILL.md` 文件。 + +- **brainstorming**: 在任何创造性工作之前必须使用此技能——创建功能、构建组件、添加功能或修改行为。在实现之前先探索用户意图、需求和设计。 +- **chinese-code-review**: 中文 review 沟通参考——话术模板、分级标注(必须修复/建议修改/仅供参考)、国内团队常见反模式应对。仅在用户显式 /chinese-code-review 时调用,不要根据上下文自动触发。 +- **chinese-commit-conventions**: 中文 commit 与 changelog 配置参考——Conventional Commits 中文适配、commitlint/husky/commitizen 中文模板、conventional-changelog 中文配置。仅在用户显式 /chinese-commit-conventions 时调用,不要根据上下文自动触发。 +- **chinese-documentation**: 中文文档排版参考——中英文空格、全半角标点、术语保留、链接格式、中文文案排版指北约定。仅在用户显式 /chinese-documentation 时调用,不要根据上下文自动触发。 +- **chinese-git-workflow**: 国内 Git 平台配置参考——Gitee、Coding.net、极狐 GitLab、CNB 的 SSH/HTTPS/凭据/CI 接入差异与镜像同步配置。仅在用户显式 /chinese-git-workflow 时调用,不要根据上下文自动触发。 +- **dispatching-parallel-agents**: 当面对 2 个以上可以独立进行、无共享状态或顺序依赖的任务时使用 +- **executing-plans**: 当你有一份书面实现计划需要在单独的会话中执行,并设有审查检查点时使用 +- **finishing-a-development-branch**: 当实现完成、所有测试通过、需要决定如何集成工作时使用——通过提供合并、PR 或清理等结构化选项来引导开发工作的收尾 +- **mcp-builder**: MCP 服务器构建方法论 — 系统化构建生产级 MCP 工具,让 AI 助手连接外部能力 +- **receiving-code-review**: 收到代码审查反馈后、实施建议之前使用,尤其当反馈不明确或技术上有疑问时——需要技术严谨性和验证,而非敷衍附和或盲目执行 +- **requesting-code-review**: 完成任务、实现重要功能或合并前使用,用于验证工作成果是否符合要求 +- **subagent-driven-development**: 当在当前会话中执行包含独立任务的实现计划时使用 +- **systematic-debugging**: 遇到任何 bug、测试失败或异常行为时使用,在提出修复方案之前执行 +- **test-driven-development**: 在实现任何功能或修复 bug 时使用,在编写实现代码之前 +- **using-git-worktrees**: 当需要开始与当前工作区隔离的功能开发,或在执行实现计划之前使用——通过原生工具或 git worktree 回退机制确保隔离工作区存在 +- **using-superpowers**: 在开始任何对话时使用——确立如何查找和使用技能,要求在任何响应(包括澄清性问题)之前调用 Skill 工具 +- **verification-before-completion**: 在宣称工作完成、已修复或测试通过之前使用,在提交或创建 PR 之前——必须运行验证命令并确认输出后才能声称成功;始终用证据支撑断言 +- **workflow-runner**: 在 Claude Code / OpenClaw / Cursor 中直接运行 agency-orchestrator YAML 工作流——无需 API key,使用当前会话的 LLM 作为执行引擎。当用户提供 .yaml 工作流文件或要求多角色协作完成任务时触发。 +- **writing-plans**: 当你有规格说明或需求用于多步骤任务时使用,在动手写代码之前 +- **writing-skills**: 当创建新技能、编辑现有技能或在部署前验证技能是否有效时使用 + +## 如何使用 + +当任务匹配某个 skill 时,读取对应的 `.agents/skills//SKILL.md` 并严格遵循其流程。 diff --git a/.agents/skills/brainstorming/SKILL.md b/.agents/skills/brainstorming/SKILL.md new file mode 100644 index 0000000000..fe47cb7141 --- /dev/null +++ b/.agents/skills/brainstorming/SKILL.md @@ -0,0 +1,169 @@ +--- +name: brainstorming +description: "在任何创造性工作之前必须使用此技能——创建功能、构建组件、添加功能或修改行为。在实现之前先探索用户意图、需求和设计。" +version: "1.0.0" +license: MIT +metadata: + hermes: + tags: [design, planning] +--- + +# 头脑风暴:将想法转化为设计 + +通过自然的协作对话,帮助将想法转化为完整的设计和规格说明。 + +首先了解当前项目的上下文,然后逐一提问来完善想法。一旦你理解了要构建的内容,就展示设计方案并获得用户批准。 + + +在你展示设计方案并获得用户批准之前,不要调用任何实现技能、编写任何代码、搭建任何项目或采取任何实现行动。这适用于所有项目,无论看起来多简单。 + + +## 反模式:"这个太简单了,不需要设计" + +每个项目都要经过这个流程。一个待办事项列表、一个单函数工具、一个配置变更——全都需要。"简单"的项目恰恰是未经检验的假设造成最多浪费的地方。设计可以很简短(对于真正简单的项目几句话就够了),但你必须展示出来并获得批准。 + +## 检查清单 + +你必须为以下每个条目创建任务,并按顺序完成: + +1. **探索项目上下文** — 检查文件、文档、最近的 commit +2. **提供视觉伴侣**(如果主题涉及视觉问题)— 这是一条独立的消息,不要与澄清问题合并。参见下方的"视觉伴侣"部分。 +3. **提出澄清问题** — 每次一个,了解目的/约束/成功标准 +4. **提出 2-3 种方案** — 附带权衡分析和你的推荐 +5. **展示设计** — 按复杂度分节展示,每节展示后获得用户批准 +6. **编写设计文档** — 保存到 `docs/superpowers/specs/YYYY-MM-DD--design.md` 并 commit +7. **规格自检** — 快速内联检查占位符、矛盾、模糊性、范围(详见下方) +8. **用户审查书面规格** — 在继续之前请用户审查规格文件 +9. **过渡到实现** — 调用 writing-plans 技能创建实现计划 + +## 流程图 + +```dot +digraph brainstorming { + "探索项目上下文" [shape=box]; + "有视觉相关问题?" [shape=diamond]; + "提供视觉伴侣\n(独立消息,不含其他内容)" [shape=box]; + "提出澄清问题" [shape=box]; + "提出 2-3 种方案" [shape=box]; + "分节展示设计" [shape=box]; + "用户批准设计?" [shape=diamond]; + "编写设计文档" [shape=box]; + "规格自检\n(内联修复)" [shape=box]; + "用户审查规格?" [shape=diamond]; + "调用 writing-plans 技能" [shape=doublecircle]; + + "探索项目上下文" -> "有视觉相关问题?"; + "有视觉相关问题?" -> "提供视觉伴侣\n(独立消息,不含其他内容)" [label="是"]; + "有视觉相关问题?" -> "提出澄清问题" [label="否"]; + "提供视觉伴侣\n(独立消息,不含其他内容)" -> "提出澄清问题"; + "提出澄清问题" -> "提出 2-3 种方案"; + "提出 2-3 种方案" -> "分节展示设计"; + "分节展示设计" -> "用户批准设计?"; + "用户批准设计?" -> "分节展示设计" [label="否,修改"]; + "用户批准设计?" -> "编写设计文档" [label="是"]; + "编写设计文档" -> "规格自检\n(内联修复)"; + "规格自检\n(内联修复)" -> "用户审查规格?"; + "用户审查规格?" -> "编写设计文档" [label="要求修改"]; + "用户审查规格?" -> "调用 writing-plans 技能" [label="批准"]; +} +``` + +**终止状态是调用 writing-plans。** 不要调用 frontend-design、mcp-builder 或任何其他实现技能。头脑风暴之后你唯一要调用的技能是 writing-plans。 + +## 流程详述 + +**理解想法:** + +- 首先查看当前项目状态(文件、文档、最近的 commit) +- 在提出详细问题之前,先评估范围:如果需求描述了多个独立子系统(例如"构建一个包含聊天、文件存储、计费和分析的平台"),立即指出这一点。不要花时间用问题去细化一个需要先拆分的项目。 +- 如果项目规模过大,单个规格说明无法覆盖,帮助用户分解为子项目:有哪些独立的部分,它们之间有什么关系,应该按什么顺序构建?然后通过正常的设计流程进行第一个子项目的头脑风暴。每个子项目都有自己的规格 → 计划 → 实现周期。 +- 对于范围适当的项目,每次提一个问题来完善想法 +- 尽量使用选择题,开放式问题也可以 +- 每条消息只提一个问题——如果一个主题需要更多探索,拆分成多个问题 +- 重点理解:目的、约束、成功标准 + +**探索方案:** + +- 提出 2-3 种不同的方案及其权衡 +- 以对话的方式展示选项,附上你的推荐和理由 +- 先展示你推荐的方案并解释原因 + +**展示设计:** + +- 一旦你认为理解了要构建的内容,就展示设计 +- 每个部分的篇幅与其复杂度匹配:简单的几句话,复杂的最多 200-300 字 +- 每个部分展示后询问是否正确 +- 涵盖:架构、组件、数据流、错误处理、测试 +- 随时准备回头澄清不明确的地方 + +**面向隔离和清晰的设计:** + +- 将系统拆分为更小的单元,每个单元有一个明确的职责,通过定义良好的接口通信,可以独立理解和测试 +- 对于每个单元,你应该能回答:它做什么,如何使用,它依赖什么? +- 别人能否不看内部实现就理解一个单元的功能?你能否在不影响调用者的情况下修改内部实现?如果不能,边界需要调整。 +- 更小、边界清晰的单元也更便于你工作——你对能一次放入上下文的代码推理得更好,文件越专注你的编辑越可靠。当文件变大时,这通常意味着它承担了过多职责。 + +**在现有代码库中工作:** + +- 在提出更改之前先探索现有结构。遵循现有模式。 +- 如果现有代码存在影响当前工作的问题(例如文件过大、边界不清、职责纠缠),在设计中包含有针对性的改进——就像一个优秀的开发者在工作中改进经手的代码一样。 +- 不要提议无关的重构。专注于服务当前目标的事情。 + +## 设计之后 + +**文档:** + +- 将验证通过的设计(规格说明)写入 `docs/superpowers/specs/YYYY-MM-DD--design.md` + - (用户对规格位置的偏好优先于此默认值) +- 如果可用,使用 elements-of-style:writing-clearly-and-concisely 技能 +- 将设计文档 commit 到 git + +**规格自检:** +编写规格文档后,以全新的视角审视它: + +1. **占位符扫描:** 有没有"待定"、"TODO"、未完成的章节或模糊的需求?修复它们。 +2. **内部一致性:** 各章节之间有矛盾吗?架构和功能描述匹配吗? +3. **范围检查:** 这是否聚焦到可以用一个实现计划覆盖,还是需要进一步拆分? +4. **模糊性检查:** 有没有需求可以被两种方式理解?如果有,选择一种并明确写出来。 + +发现问题就直接内联修复。无需重新审查——修好继续推进。 + +**用户审查关卡:** +规格自检完成后,请用户在继续之前审查书面规格: + +> "规格已编写并 commit 到 ``。请审查一下,如果在我们开始编写实现计划之前你想做任何修改,请告诉我。" + +等待用户回复。如果他们要求修改,做出修改并重新运行规格自检。只有在用户批准后才继续。 + +**实现:** + +- 调用 writing-plans 技能创建详细的实现计划 +- 不要调用任何其他技能。writing-plans 是下一步。 + +## 核心原则 + +- **每次一个问题** — 不要同时抛出多个问题 +- **优先选择题** — 在可能的情况下比开放式问题更容易回答 +- **严格遵循 YAGNI** — 从所有设计中移除不必要的功能 +- **探索替代方案** — 在做决定之前始终提出 2-3 种方案 +- **增量验证** — 展示设计,获得批准后再继续 +- **保持灵活** — 有不明确的地方就回头澄清 + +## 视觉伴侣 + +一个基于浏览器的伴侣工具,用于在头脑风暴过程中展示原型、图表和视觉选项。它是一个工具——不是一种模式。接受伴侣意味着它可用于适合视觉呈现的问题;并不意味着每个问题都要通过浏览器。 + +**提供伴侣:** 当你预计后续问题会涉及视觉内容(原型、布局、图表)时,提供一次以获得同意: +> "我们接下来讨论的一些内容,如果能在浏览器中展示给你看可能会更直观。我可以在讨论过程中为你制作原型、图表、对比图和其他视觉材料。这个功能还比较新,可能会消耗较多 token。要试试吗?(需要打开一个本地 URL)" + +**此提议必须是一条独立的消息。** 不要将它与澄清问题、上下文摘要或任何其他内容合并。消息中应该只包含上述提议,没有其他内容。等待用户回复后再继续。如果他们拒绝,继续纯文本的头脑风暴。 + +**逐问题决策:** 即使用户接受了,也要对每个问题单独决定是使用浏览器还是终端。判断标准:**用户看到它是否比读到它更容易理解?** + +- **使用浏览器** 展示本身就是视觉的内容——原型、线框图、布局对比、架构图、并排视觉设计 +- **使用终端** 展示文本内容——需求问题、概念选择、权衡列表、A/B/C/D 文字选项、范围决策 + +关于 UI 主题的问题不一定是视觉问题。"在这个上下文中个性化是什么意思?"是一个概念问题——使用终端。"哪种向导布局更好?"是一个视觉问题——使用浏览器。 + +如果他们同意使用伴侣,在继续之前阅读详细指南: +`skills/brainstorming/visual-companion.md` diff --git a/.agents/skills/brainstorming/scripts/frame-template.html b/.agents/skills/brainstorming/scripts/frame-template.html new file mode 100644 index 0000000000..f540bb8a4f --- /dev/null +++ b/.agents/skills/brainstorming/scripts/frame-template.html @@ -0,0 +1,213 @@ + + + + + Superpowers Brainstorming + + + +
+ +
Connecting…
+
+ +
+
+ +
+
+ + + diff --git a/.agents/skills/brainstorming/scripts/helper.js b/.agents/skills/brainstorming/scripts/helper.js new file mode 100644 index 0000000000..e11d26489c --- /dev/null +++ b/.agents/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: ['Connecting…', 'var(--text-tertiary)'], + connected: ['Connected', 'var(--success)'], + reconnecting: ['Reconnecting…', 'var(--warning)'], + disconnected: ['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 = '
' + + '

Companion paused

' + + '

This brainstorm companion has stopped. ' + + 'Ask your coding agent to bring it back — this page reconnects automatically.

'; + 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/.agents/skills/brainstorming/scripts/server.cjs b/.agents/skills/brainstorming/scripts/server.cjs new file mode 100644 index 0000000000..a828b35af6 --- /dev/null +++ b/.agents/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(` + +Brainstorm Companion + + +

Brainstorm Companion

+

Waiting for the agent to push a screen...

`); +} + +const FORBIDDEN_PAGE = ` + +Session key required + + +

Session key required

+

This page needs the full URL your coding agent gave you, including the +?key=… part. Copy the complete URL and open it again.

`; + +function bootstrapPage(key) { + const jsonKey = JSON.stringify(String(key)); + return ` + +Opening Brainstorm Companion + + + +`; +} + +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/.agents/skills/brainstorming/scripts/start-server.sh b/.agents/skills/brainstorming/scripts/start-server.sh new file mode 100644 index 0000000000..016a8e4870 --- /dev/null +++ b/.agents/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/.agents/skills/brainstorming/scripts/stop-server.sh b/.agents/skills/brainstorming/scripts/stop-server.sh new file mode 100644 index 0000000000..7cacfe9440 --- /dev/null +++ b/.agents/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/.agents/skills/brainstorming/spec-document-reviewer-prompt.md b/.agents/skills/brainstorming/spec-document-reviewer-prompt.md new file mode 100644 index 0000000000..7e6186491d --- /dev/null +++ b/.agents/skills/brainstorming/spec-document-reviewer-prompt.md @@ -0,0 +1,48 @@ +# 规格文档审查员提示模板 + +调度规格文档审查员子代理时使用此模板。 + +**用途:** 验证规格是否完整、一致,并为实现计划做好准备。 + +**调度时机:** 规格文档写入 docs/superpowers/specs/ 之后 + +``` +Task tool(通用): + description: "审查规格文档" + prompt: | + 你是一名规格文档审查员。验证此规格是否完整并准备好进行计划编写。 + + **待审查规格:** [SPEC_FILE_PATH] + + ## 检查内容 + + | 类别 | 检查要点 | + |------|----------| + | 完整性 | TODO、占位符、"TBD"、不完整的章节 | + | 一致性 | 内部矛盾、相互冲突的需求 | + | 清晰度 | 需求模糊到可能导致构建出错误的东西 | + | 范围 | 是否足够聚焦以用于单个计划——而非涵盖多个独立子系统 | + | YAGNI | 未请求的功能、过度设计 | + + ## 校准标准 + + **只标记会在实现计划阶段造成实际问题的事项。** + 缺失的章节、矛盾之处、或者模糊到可能被两种不同方式理解的需求—— + 这些才是问题。措辞上的小改进、风格偏好、以及"某些章节不如其他章节详细"则不是。 + + 除非存在会导致计划出错的严重缺陷,否则应予以通过。 + + ## 输出格式 + + ## 规格审查 + + **状态:** 通过 | 发现问题 + + **问题(如有):** + - [章节 X]:[具体问题] - [为什么这对计划编写很重要] + + **建议(仅供参考,不阻止通过):** + - [改进建议] +``` + +**审查员返回:** 状态、问题(如有)、建议 diff --git a/.agents/skills/brainstorming/visual-companion.md b/.agents/skills/brainstorming/visual-companion.md new file mode 100644 index 0000000000..287c87ca0a --- /dev/null +++ b/.agents/skills/brainstorming/visual-companion.md @@ -0,0 +1,286 @@ +# 视觉伴侣指南 + +基于浏览器的视觉头脑风暴伴侣,用于展示原型、图表和选项。 + +## 何时使用 + +逐问题决定,而非按会话决定。判断标准:**用户看到它是否比读到它更容易理解?** + +**使用浏览器** 当内容本身是视觉的: + +- **UI 原型** — 线框图、布局、导航结构、组件设计 +- **架构图** — 系统组件、数据流、关系图 +- **并排视觉对比** — 对比两种布局、两种配色方案、两种设计方向 +- **设计细节打磨** — 当问题涉及外观感受、间距、视觉层次 +- **空间关系** — 状态机、流程图、实体关系图 + +**使用终端** 当内容是文字或表格的: + +- **需求和范围问题** — "X 是什么意思?"、"哪些功能在范围内?" +- **概念性 A/B/C 选择** — 在用文字描述的方案之间做选择 +- **权衡列表** — 优缺点、对比表 +- **技术决策** — API 设计、数据建模、架构方案选择 +- **澄清问题** — 任何回答是文字而非视觉偏好的问题 + +关于 UI 主题的问题不一定是视觉问题。"你想要什么样的向导?"是概念性的——使用终端。"这些向导布局中哪个感觉对?"是视觉性的——使用浏览器。 + +## 工作原理 + +服务器监视一个目录中的 HTML 文件,将最新的文件提供给浏览器。你写入 HTML 内容,用户在浏览器中看到它,并可以点击选择选项。选择结果被记录到一个 `.events` 文件中,你在下一轮会话中读取它。 + +**内容片段 vs 完整文档:** 如果你的 HTML 文件以 `/.superpowers/brainstorm/` 获取会话目录。 + +**注意:** 传入项目根目录作为 `--project-dir`,这样原型会持久化在 `.superpowers/brainstorm/` 中,不会因服务器重启而丢失。不传的话,文件会保存到 `/tmp` 并在清理时被删除。提醒用户将 `.superpowers/` 添加到 `.gitignore`(如果尚未添加)。 + +**按平台启动服务器:** + +**Claude Code (macOS / Linux):** +```bash +# 默认模式即可——脚本会自动将服务器放到后台 +scripts/start-server.sh --project-dir /path/to/project +``` + +**Claude Code (Windows):** +```bash +# Windows 会自动检测并使用前台模式,这会阻塞工具调用。 +# 在 Bash 工具调用上设置 run_in_background: true, +# 让服务器在会话轮次之间存活。 +scripts/start-server.sh --project-dir /path/to/project +``` +通过 Bash 工具调用时,设置 `run_in_background: true`。然后在下一轮读取 `$SCREEN_DIR/.server-info` 获取 URL 和端口。 + +**Codex:** +```bash +# Codex 会回收后台进程。脚本会自动检测 CODEX_CI 并 +# 切换到前台模式。正常运行即可——不需要额外标志。 +scripts/start-server.sh --project-dir /path/to/project +``` + +**Gemini CLI:** +```bash +# 使用 --foreground 并在 shell 工具调用上设置 is_background: true, +# 让进程在轮次之间存活 +scripts/start-server.sh --project-dir /path/to/project --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` 中的新文件: + - 每次写入前,检查 `$SCREEN_DIR/.server-info` 是否存在。如果不存在(或 `.server-stopped` 存在),服务器已关闭——在继续之前用 `start-server.sh` 重启。服务器在 30 分钟无活动后会自动退出。 + - 使用语义化文件名:`platform.html`、`visual-style.html`、`layout.html` + - **绝不复用文件名** — 每个屏幕用一个新文件 + - 使用 Write 工具 — **绝不使用 cat/heredoc**(会在终端产生噪音) + - 服务器自动提供最新的文件 + +2. **告诉用户预期内容并结束你的回合:** + - 每一步都提醒他们 URL(不仅仅是第一次) + - 简要文字说明屏幕上的内容(例如"展示了 3 个首页布局选项") + - 请他们在终端中回复:"看一下,告诉我你的想法。如果你愿意,可以点击选择一个选项。" + +3. **在你的下一轮** — 用户在终端回复后: + - 如果存在 `$SCREEN_DIR/.events`,读取它——其中包含用户的浏览器交互(点击、选择),格式为 JSON 行 + - 将终端文字和事件合并以获得完整信息 + - 终端消息是主要反馈;`.events` 提供结构化的交互数据 + +4. **迭代或推进** — 如果反馈要求修改当前屏幕,写入新文件(例如 `layout-v2.html`)。只有当前步骤验证通过后才进入下一个问题。 + +5. **回到终端时卸载** — 当下一步不需要浏览器时(例如澄清问题、权衡讨论),推送一个等待屏幕以清除过时内容: + + ```html + +
+

在终端中继续...

+
+ ``` + + 这样可以防止用户盯着一个已经解决的选择,而对话已经继续了。当下一个视觉问题出现时,照常推送新的内容文件。 + +6. 重复直到完成。 + +## 编写内容片段 + +只写放在页面内部的内容。服务器会自动用框架模板包裹它(头部、主题 CSS、选择指示器和所有交互基础设施)。 + +**最简示例:** + +```html +

哪种布局更好?

+

考虑可读性和视觉层次

+ +
+
+
A
+
+

单栏

+

简洁、专注的阅读体验

+
+
+
+
B
+
+

双栏

+

侧边栏导航加主内容区

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