From 4b409b5a298957f6f0cd3ddc92a63d55de761c91 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Sun, 20 Sep 2026 00:44:14 +0800 Subject: [PATCH] update --- .codebuddy/skills/brainstorming/SKILL.md | 212 +++ .../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 | 295 +++++ .../skills/chinese-code-review/SKILL.md | 282 ++++ .../chinese-commit-conventions/SKILL.md | 369 ++++++ .../skills/chinese-documentation/SKILL.md | 453 +++++++ .../skills/chinese-git-workflow/SKILL.md | 552 ++++++++ .../dispatching-parallel-agents/SKILL.md | 170 +++ .codebuddy/skills/executing-plans/SKILL.md | 181 +++ .../finishing-a-development-branch/SKILL.md | 209 +++ .codebuddy/skills/mcp-builder/SKILL.md | 260 ++++ .../skills/receiving-code-review/SKILL.md | 211 +++ .../skills/requesting-code-review/SKILL.md | 100 ++ .../requesting-code-review/code-reviewer.md | 174 +++ .../subagent-driven-development/SKILL.md | 355 +++++ .../implementer-prompt.md | 144 +++ .../re-review-prompt.md | 104 ++ .../scripts/review-package | 46 + .../scripts/sdd-workspace | 40 + .../scripts/task-brief | 45 + .../task-reviewer-prompt.md | 184 +++ .../systematic-debugging/CREATION-LOG.md | 119 ++ .../skills/systematic-debugging/SKILL.md | 289 +++++ .../condition-based-waiting-example.ts | 158 +++ .../condition-based-waiting.md | 115 ++ .../systematic-debugging/defense-in-depth.md | 122 ++ .../systematic-debugging/find-polluter.sh | 72 ++ .../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 | 325 +++++ .../writing-good-tests.md | 145 +++ .../skills/using-git-worktrees/SKILL.md | 175 +++ .codebuddy/skills/using-superpowers/SKILL.md | 94 ++ .../references/antigravity-tools.md | 14 + .../references/codex-tools.md | 76 ++ .../references/copilot-tools.md | 82 ++ .../references/gemini-tools.md | 63 + .../references/hermes-tools.md | 57 + .../using-superpowers/references/pi-tools.md | 28 + .../references/qoder-tools.md | 52 + .../verification-before-completion/SKILL.md | 126 ++ .codebuddy/skills/workflow-runner/SKILL.md | 177 +++ .codebuddy/skills/writing-plans/SKILL.md | 162 +++ .../plan-document-reviewer-prompt.md | 49 + .codebuddy/skills/writing-skills/SKILL.md | 679 ++++++++++ .../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 | 176 +++ .../testing-skills-with-subagents.md | 384 ++++++ CODEBUDDY.md | 41 + README.md | 95 +- package-lock.json | 125 +- package.json | 2 +- src/components/AuthModal.astro | 8 +- src/components/BrandModal.astro | 12 +- src/components/ComingSoon.astro | 4 +- src/components/GalleryLightbox.astro | 8 +- src/components/LoadingOverlay.astro | 6 +- src/components/RunwayLookCard.astro | 20 +- src/components/StreetSnapCard.astro | 18 +- src/components/views/Account.astro | 32 +- src/components/views/Home.astro | 59 +- src/components/views/Login.astro | 24 +- src/components/views/RunwayLooks.astro | 2 +- src/layouts/Layout.astro | 45 +- src/lib/crypto.ts | 2 +- src/lib/gallery.ts | 21 +- src/lib/history.ts | 4 +- src/lib/i18n/dictionary.ts | 3 +- src/lib/i18n/index.ts | 4 +- src/lib/routes.ts | 3 +- src/styles/global.css | 24 + src/styles/look-grid.css | 48 +- 83 files changed, 12072 insertions(+), 218 deletions(-) create mode 100644 .codebuddy/skills/brainstorming/SKILL.md create mode 100644 .codebuddy/skills/brainstorming/scripts/frame-template.html create mode 100644 .codebuddy/skills/brainstorming/scripts/helper.js create mode 100644 .codebuddy/skills/brainstorming/scripts/server.cjs create mode 100644 .codebuddy/skills/brainstorming/scripts/start-server.sh create mode 100644 .codebuddy/skills/brainstorming/scripts/stop-server.sh create mode 100644 .codebuddy/skills/brainstorming/spec-document-reviewer-prompt.md create mode 100644 .codebuddy/skills/brainstorming/visual-companion.md create mode 100644 .codebuddy/skills/chinese-code-review/SKILL.md create mode 100644 .codebuddy/skills/chinese-commit-conventions/SKILL.md create mode 100644 .codebuddy/skills/chinese-documentation/SKILL.md create mode 100644 .codebuddy/skills/chinese-git-workflow/SKILL.md create mode 100644 .codebuddy/skills/dispatching-parallel-agents/SKILL.md create mode 100644 .codebuddy/skills/executing-plans/SKILL.md create mode 100644 .codebuddy/skills/finishing-a-development-branch/SKILL.md create mode 100644 .codebuddy/skills/mcp-builder/SKILL.md create mode 100644 .codebuddy/skills/receiving-code-review/SKILL.md create mode 100644 .codebuddy/skills/requesting-code-review/SKILL.md create mode 100644 .codebuddy/skills/requesting-code-review/code-reviewer.md create mode 100644 .codebuddy/skills/subagent-driven-development/SKILL.md create mode 100644 .codebuddy/skills/subagent-driven-development/implementer-prompt.md create mode 100644 .codebuddy/skills/subagent-driven-development/re-review-prompt.md create mode 100644 .codebuddy/skills/subagent-driven-development/scripts/review-package create mode 100644 .codebuddy/skills/subagent-driven-development/scripts/sdd-workspace create mode 100644 .codebuddy/skills/subagent-driven-development/scripts/task-brief create mode 100644 .codebuddy/skills/subagent-driven-development/task-reviewer-prompt.md create mode 100644 .codebuddy/skills/systematic-debugging/CREATION-LOG.md create mode 100644 .codebuddy/skills/systematic-debugging/SKILL.md create mode 100644 .codebuddy/skills/systematic-debugging/condition-based-waiting-example.ts create mode 100644 .codebuddy/skills/systematic-debugging/condition-based-waiting.md create mode 100644 .codebuddy/skills/systematic-debugging/defense-in-depth.md create mode 100644 .codebuddy/skills/systematic-debugging/find-polluter.sh create mode 100644 .codebuddy/skills/systematic-debugging/root-cause-tracing.md create mode 100644 .codebuddy/skills/systematic-debugging/test-academic.md create mode 100644 .codebuddy/skills/systematic-debugging/test-pressure-1.md create mode 100644 .codebuddy/skills/systematic-debugging/test-pressure-2.md create mode 100644 .codebuddy/skills/systematic-debugging/test-pressure-3.md create mode 100644 .codebuddy/skills/test-driven-development/SKILL.md create mode 100644 .codebuddy/skills/test-driven-development/writing-good-tests.md create mode 100644 .codebuddy/skills/using-git-worktrees/SKILL.md create mode 100644 .codebuddy/skills/using-superpowers/SKILL.md create mode 100644 .codebuddy/skills/using-superpowers/references/antigravity-tools.md create mode 100644 .codebuddy/skills/using-superpowers/references/codex-tools.md create mode 100644 .codebuddy/skills/using-superpowers/references/copilot-tools.md create mode 100644 .codebuddy/skills/using-superpowers/references/gemini-tools.md create mode 100644 .codebuddy/skills/using-superpowers/references/hermes-tools.md create mode 100644 .codebuddy/skills/using-superpowers/references/pi-tools.md create mode 100644 .codebuddy/skills/using-superpowers/references/qoder-tools.md create mode 100644 .codebuddy/skills/verification-before-completion/SKILL.md create mode 100644 .codebuddy/skills/workflow-runner/SKILL.md create mode 100644 .codebuddy/skills/writing-plans/SKILL.md create mode 100644 .codebuddy/skills/writing-plans/plan-document-reviewer-prompt.md create mode 100644 .codebuddy/skills/writing-skills/SKILL.md create mode 100644 .codebuddy/skills/writing-skills/anthropic-best-practices.md create mode 100644 .codebuddy/skills/writing-skills/examples/CLAUDE_MD_TESTING.md create mode 100644 .codebuddy/skills/writing-skills/graphviz-conventions.dot create mode 100644 .codebuddy/skills/writing-skills/persuasion-principles.md create mode 100644 .codebuddy/skills/writing-skills/render-graphs.js create mode 100644 .codebuddy/skills/writing-skills/testing-skills-with-subagents.md create mode 100644 CODEBUDDY.md diff --git a/.codebuddy/skills/brainstorming/SKILL.md b/.codebuddy/skills/brainstorming/SKILL.md new file mode 100644 index 0000000..aaf9274 --- /dev/null +++ b/.codebuddy/skills/brainstorming/SKILL.md @@ -0,0 +1,212 @@ +--- +name: brainstorming +description: "在任何创造性工作之前必须使用此技能——创建功能、构建组件、添加功能或修改行为。在实现之前先探索用户意图、需求和设计。" +version: "1.0.0" +license: MIT +metadata: + hermes: + tags: [design, planning] +--- + +# 头脑风暴:将想法转化为设计 + +通过自然的协作对话,帮助将想法转化为完整的设计和规格说明。 + +先判断这个需求需要多少流程,然后沿着对应的路径推进:理解上下文、完善想法、展示设计、获得你的人类伙伴批准。 + + +在你告诉你的人类伙伴你打算做什么、并得到他们批准之前,不要调用任何实现技能、编写任何代码、搭建任何项目或采取任何实现行动。这适用于下面**每一条路径上的每一个任务**——仪式感随任务大小缩放,批准这道关卡永远不缩放。 + + +## 三条路径 + +在提出第一个问题之前,先给需求分类,并把分类**说出来**——"这个看起来是有界的,所以我会在这里直接给一份简短设计,而不是写规格文档"——好让你的人类伙伴能纠正你: + +- **探路(Spike)** — 一个可行性问题("我们能不能……"、"有没有可能……"、"糙一点没关系"),它的产出是一个**答案**,不是要留下的代码。用 2-3 句话说明问题和你打算怎么试,得到一个点头,然后用不牺牲正确性的最低成本去弄清楚。不写设计文档,不写规格文件。以建议的形式汇报发现;过程中搭的任何东西都明确标注为一次性的。 +- **有界(Bounded)** — 对**本仓库里已经存在的代码**做范围明确的改动:加一个开关、一个小接口、改一个文件的 bug。"知道这是个什么类型的应用"不算数——有界意味着**你要改的那条流程此刻就在仓库里、可以读**。如果没有现成的流程可改,这个任务就不是有界的。问那些真正重要的澄清问题,**在对话里**给出一份简短设计(几句话到几个短段落),然后**停下**。只有在你的人类伙伴对这份设计说"可以"之后,实现才开始——有界任务的批准和架构级任务的批准是同样硬的关卡。不写规格文件,不写实现计划文档。 +- **架构级(Architectural)** — 新项目、新子系统,以及会重构组件之间关系、或改动他人依赖的接口的改动。走完整流程:提问、方案对比、分节设计、书面规格,然后交给 writing-plans 技能。 + +在两条路径之间拿不准时,选更重的那条。这个棘轮只朝一个方向转:任务进行中发现隐藏的复杂度,就**升级**路径——停下来、说明情况、升上去。任何情况下都不在任务中途降级。 + +## 反模式:"这个太简单了,不需要批准" + +每条路径的终点都是你的人类伙伴在实现之前批准你的意图。一个待办事项列表、一个单函数工具、一个配置变更——设计可以只是对话里的两句话,但你**必须**把它展示出来并获得批准。"简单"的任务恰恰是未经检验的假设造成最多浪费的地方。随简单程度缩放的是**产出物**,永远不是批准。 + +## 危险信号 + +| 心里的想法 | 实际情况 | +|---------|---------| +| "这个太简单了,不需要设计" | 简单意味着简短的设计,不是没有设计。对话里两句话,然后获得批准。 | +| "我就说它是有界的,跳过规格文档" | 为了少干活而去够一个标签,这本身就是"拿不准"——选更重的那条路径。 | +| "它是有界的,设计也很显然——我一边让他们读一边开工" | 关卡是**批准**,不是设计的长度。展示完就停,直到听见"可以"。 | +| "这类应用我很熟,所以它是有界的" | 有界衡量的是**仓库**,不是你的熟悉程度。新项目没有现成的流程可改——那是架构级。 | +| "探路跑通了,那这些代码就留着吧" | 探路的产出是一个答案。要留下代码是一个**新的需求**——给它重新分类。 | +| "范围是变大了,但我快做完了,不用重新分类" | 隐藏的复杂度会在任务中途升级路径。停下来,说明情况。 | +| "他们批准了探路,那后续改动也算批准了" | 每个任务有自己的分类,也有自己的批准。 | + +## 检查清单 + +先分类,宣布路径,然后为你所在路径上的每个条目创建任务,并按顺序完成。 + +**探路(Spike):** +1. **探索项目上下文** — 够用来框定这次试探即可 +2. **展示问题 + 试探计划** — 2-3 句话 +3. **获得批准** — 一个点头就够 +4. **动手调查** — 用不牺牲正确性的最低成本 +5. **汇报发现** — 以建议的形式;搭出来的任何东西都标注为一次性的 + +**有界(Bounded):** +1. **探索项目上下文** — 检查文件、文档、最近的 commit +2. **提出澄清问题** — 每次一个,只问那些真正重要的 +3. **在对话里展示简短设计** — 思路、会动哪些文件、怎么测 +4. **获得批准** — **停下**并等待一个明确的"可以";展示完设计顺口就开工,等于跳过了关卡 +5. **实现** — 走正常的开发工作流(TDD 同样适用);不写计划文档 + +**架构级(Architectural):** +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=diamond]; + "展示问题 + 试探计划(2-3 句)" [shape=box]; + "提出澄清问题(有界)" [shape=box]; + "在对话里展示简短设计" [shape=box]; + "人类伙伴批准?" [shape=diamond]; + "动手调查;汇报建议" [shape=doublecircle]; + "走正常工作流实现(无计划文档)" [shape=doublecircle]; + "探索项目上下文" [shape=box]; + "提出澄清问题" [shape=box]; + "提出 2-3 种方案" [shape=box]; + "分节展示设计" [shape=box]; + "用户批准设计?" [shape=diamond]; + "编写设计文档" [shape=box]; + "规格自检\n(内联修复)" [shape=box]; + "用户审查规格?" [shape=diamond]; + "调用 writing-plans 技能" [shape=doublecircle]; + "发现隐藏复杂度? 升级路径" [shape=box]; + + "分类:探路 / 有界 / 架构级" -> "展示问题 + 试探计划(2-3 句)" [label="探路"]; + "分类:探路 / 有界 / 架构级" -> "提出澄清问题(有界)" [label="有界"]; + "分类:探路 / 有界 / 架构级" -> "探索项目上下文" [label="架构级"]; + "展示问题 + 试探计划(2-3 句)" -> "人类伙伴批准?"; + "提出澄清问题(有界)" -> "在对话里展示简短设计"; + "在对话里展示简短设计" -> "人类伙伴批准?"; + "人类伙伴批准?" -> "动手调查;汇报建议" [label="探路:是"]; + "人类伙伴批准?" -> "走正常工作流实现(无计划文档)" [label="有界:是"]; + "发现隐藏复杂度? 升级路径" -> "分类:探路 / 有界 / 架构级"; + "探索项目上下文" -> "提出澄清问题"; + "提出澄清问题" -> "提出 2-3 种方案"; + "提出 2-3 种方案" -> "分节展示设计"; + "分节展示设计" -> "用户批准设计?"; + "用户批准设计?" -> "分节展示设计" [label="否,修改"]; + "用户批准设计?" -> "编写设计文档" [label="是"]; + "编写设计文档" -> "规格自检\n(内联修复)"; + "规格自检\n(内联修复)" -> "用户审查规格?"; + "用户审查规格?" -> "编写设计文档" [label="要求修改"]; + "用户审查规格?" -> "调用 writing-plans 技能" [label="批准"]; +} +``` + +**终止状态跟着路径走。** 架构级:头脑风暴之后你唯一要调用的技能是 writing-plans——绝不调用 frontend-design、mcp-builder 或任何其他实现技能。有界:获得批准之后,直接走正常的开发工作流去实现,不写计划文档。探路:终止状态是一份汇报出去的建议。 + +## 流程详述 + +下面这些小节服务于**有界**和**架构级**两条路径(探路在"展示试探计划、拿到点头"就停了)。从**探索方案**往后都是架构级路径的深度——对有界的工作来说,上下文加几个问题再加一份对话里的简短设计,就是全部流程。 + +**理解想法:** + +- 首先查看当前项目状态(文件、文档、最近的 commit) +- 在提出详细问题之前,先评估范围:如果需求描述了多个独立子系统(例如"构建一个包含聊天、文件存储、计费和分析的平台"),立即指出这一点。不要花时间用问题去细化一个需要先拆分的项目。 +- 如果项目规模过大,单个规格说明无法覆盖,帮助用户分解为子项目:有哪些独立的部分,它们之间有什么关系,应该按什么顺序构建?然后通过正常的设计流程进行第一个子项目的头脑风暴。每个子项目都有自己的规格 → 计划 → 实现周期。 +- 对于范围适当的项目,每次提一个问题来完善想法 +- 尽量使用选择题,开放式问题也可以 +- 每条消息只提一个问题——如果一个主题需要更多探索,拆分成多个问题 +- 重点理解:目的、约束、成功标准 + +**探索方案:** + +- 提出 2-3 种不同的方案及其权衡 +- 以对话的方式展示选项,附上你的推荐和理由 +- 先展示你推荐的方案并解释原因 +- 严格遵循 YAGNI —— 从每个方案和设计里移除不必要的功能 + +**展示设计:** + +- 一旦你认为理解了要构建的内容,就展示设计 +- 每个部分的篇幅与其复杂度匹配:简单的几句话,复杂的最多 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 是下一步。 + +## 视觉伴侣 + +一个基于浏览器的伴侣工具,用于在头脑风暴过程中展示原型、图表和视觉选项。它是一个工具——不是一种模式。接受伴侣意味着它可用于适合视觉呈现的问题;并不意味着每个问题都要通过浏览器。 + +**提供伴侣(在需要时才提):** **不要一上来就提。** 等到某个问题确实"画出来比说出来更清楚"时再提——要是真正的原型 / 布局 / 图表问题,而不仅仅是话题跟 UI 沾边。第一次出现这种情况时,就在那一刻提供,作为独立的一条消息: +> "接下来这部分,我展示给你看可能更容易理解——我可以在讨论过程中,在一个浏览器标签页里做原型、图表和对比。这个功能还比较新,可能会消耗较多 token。要我打开吗?我来帮你打开。" + +**此提议必须是一条独立的消息。** 只有这条提议——不含澄清问题、内容摘要或任何其他内容。等待用户回复。如果他们接受,用 `--open` 启动服务,浏览器会自动打开到第一屏。如果他们拒绝,继续纯文本进行,并且不要再提,除非他们自己提起。 + +**逐问题决策:** 即使用户接受了,也要对每个问题单独决定是使用浏览器还是终端。判断标准:**用户看到它是否比读到它更容易理解?** + +- **使用浏览器** 展示本身就是视觉的内容——原型、线框图、布局对比、架构图、并排视觉设计 +- **使用终端** 展示文本内容——需求问题、概念选择、权衡列表、A/B/C/D 文字选项、范围决策 + +关于 UI 主题的问题不一定是视觉问题。"在这个上下文中个性化是什么意思?"是一个概念问题——使用终端。"哪种向导布局更好?"是一个视觉问题——使用浏览器。 + +如果他们同意使用伴侣,在继续之前阅读详细指南: +`skills/brainstorming/visual-companion.md` diff --git a/.codebuddy/skills/brainstorming/scripts/frame-template.html b/.codebuddy/skills/brainstorming/scripts/frame-template.html new file mode 100644 index 0000000..f540bb8 --- /dev/null +++ b/.codebuddy/skills/brainstorming/scripts/frame-template.html @@ -0,0 +1,213 @@ + + + + + Superpowers Brainstorming + + + +
+ +
Connecting…
+
+ +
+
+ +
+
+ + + diff --git a/.codebuddy/skills/brainstorming/scripts/helper.js b/.codebuddy/skills/brainstorming/scripts/helper.js new file mode 100644 index 0000000..e11d264 --- /dev/null +++ b/.codebuddy/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/.codebuddy/skills/brainstorming/scripts/server.cjs b/.codebuddy/skills/brainstorming/scripts/server.cjs new file mode 100644 index 0000000..a828b35 --- /dev/null +++ b/.codebuddy/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/.codebuddy/skills/brainstorming/scripts/start-server.sh b/.codebuddy/skills/brainstorming/scripts/start-server.sh new file mode 100644 index 0000000..016a8e4 --- /dev/null +++ b/.codebuddy/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/.codebuddy/skills/brainstorming/scripts/stop-server.sh b/.codebuddy/skills/brainstorming/scripts/stop-server.sh new file mode 100644 index 0000000..7cacfe9 --- /dev/null +++ b/.codebuddy/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/.codebuddy/skills/brainstorming/spec-document-reviewer-prompt.md b/.codebuddy/skills/brainstorming/spec-document-reviewer-prompt.md new file mode 100644 index 0000000..7e61864 --- /dev/null +++ b/.codebuddy/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/.codebuddy/skills/brainstorming/visual-companion.md b/.codebuddy/skills/brainstorming/visual-companion.md new file mode 100644 index 0000000..0451e3f --- /dev/null +++ b/.codebuddy/skills/brainstorming/visual-companion.md @@ -0,0 +1,295 @@ +# 视觉伴侣指南 + +基于浏览器的视觉头脑风暴伴侣,用于展示原型、图表和选项。 + +## 何时使用 + +逐问题决定,而非按会话决定。判断标准:**用户看到它是否比读到它更容易理解?** + +**使用浏览器** 当内容本身是视觉的: + +- **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 +``` + +**Copilot CLI:** +```bash +# 用 Copilot CLI 的非阻塞 / 后台 shell 机制启动,让服务器能跨会话轮次存活。 +# 保留 --foreground —— 由 harness 而不是脚本来负责放到后台。 +# 启动器是 .sh,所以要通过 bash 调用(Windows 上用 Git Bash 的 bash.exe, +# 从 PowerShell 工具里调)。 +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` 中的新文件: + - 每次写入前,检查 `$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,不需要 ` +{/* 图片加载状态组件独立成块,刻意不依赖上面的 swiper 模块: + 避免 swiper 依赖预构建异常(如 Vite 504 Outdated Optimize Dep)时,整段脚本不执行、 + 连 Alpine 的 imgLoader 注册一起失效,导致 x-data="imgLoader()" 报未定义。 */} + {/* Top Footer Grid */} -
- +
{/* Column 1: Brand & Manifesto */}
- Portfolio Studio + {SITE_NAME} - - 2026 Edition + + 2026
-

+

{t('Minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. Integrated with 2026 color forecast intelligence and AI palette generator.')}

{/* Location Badge */} -
+
Paris • Milan @@ -341,10 +340,30 @@ const headers = { New York
+ + {/* Column 2: Explore */} + + + {/* Column 3: Connect */} +
+

Connect

+ +
-
- {t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')} +
+ © 2026 {SITE_NAME}. {t('all rights reserved.')}
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index afff98e..358ded4 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,6 +1,6 @@ // src/lib/crypto.ts — 前端 JS 请求签名(反爬一层,非加密) // -// 仅做 HMAC-SHA256 计算,由 api.ts 的公开请求原语附带 X-Sign / X-Sign-Ts / X-Sign-Nonce 头, +// 仅做 HMAC-SHA256 计算,由 request.ts 的唯一请求入口 request() 附带 X-Sign / X-Sign-Ts / X-Sign-Nonce 头, // 供后端 middleware.ClientSign 校验「请求大概率来自真前端」。 // // 安全说明:secret 必须出现在前端 bundle 才能签名(对浏览器可见),故只是「提高成本」, diff --git a/src/lib/gallery.ts b/src/lib/gallery.ts index 0892a92..2d90efa 100644 --- a/src/lib/gallery.ts +++ b/src/lib/gallery.ts @@ -1,6 +1,6 @@ // src/lib/gallery.ts —— 走秀 / 街拍详情页共享的「图集 + 大图灯箱」Alpine 视图工厂。 // -// Item.astro(走秀)与 StreetSnap.astro(街拍)的灯箱交互逐行同构,差异只有三处: +// RunwayLookCard.astro(走秀)与 StreetSnapCard.astro(街拍)的灯箱交互逐行同构,差异只有三处: // ① 根 / 画廊选择器 ② 单图收藏类型 ③ 已登录全量取数函数 // 故抽成工厂;各自独有的部分(每行张数、细节图灯箱)由组件在返回值上自行叠加。 // @@ -79,6 +79,8 @@ export function createGalleryView(cfg: GalleryConfig) { // 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 // getUser,故在此初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。 isAuthed: false, + // 登录事件处理器引用:组件销毁(destroy)时据此移除全局监听,避免跨页面 / 视图切换累积监听器泄漏。 + onAuthLogin: null as (() => void) | null, // 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张) preview: false, // 图集级收藏态:由 loadAuthedState 取回,广播给页面右下角 fav-fab @@ -107,11 +109,22 @@ export function createGalleryView(cfg: GalleryConfig) { // 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用) if (getUser()) this.loadAuthedState() - // 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态 - window.addEventListener("auth:login", () => { + // 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态。 + // 用命名引用保存处理器,组件销毁(destroy)时据此移除,避免跨页面 / 视图切换累积监听器泄漏。 + this.onAuthLogin = () => { this.isAuthed = true if (getUser()) this.loadAuthedState() - }) + } + window.addEventListener("auth:login", this.onAuthLogin) + }, + + // 组件销毁时移除全局登录监听(Alpine 在 x-data 根节点移除时自动调用 destroy), + // 防止内存 / 监听器随路由切换(尤其是启用 View Transitions 时)不断累积。 + destroy() { + if (this.onAuthLogin) { + window.removeEventListener("auth:login", this.onAuthLogin) + this.onAuthLogin = null + } }, countImages(): number { diff --git a/src/lib/history.ts b/src/lib/history.ts index 63a6478..2dc2cdb 100644 --- a/src/lib/history.ts +++ b/src/lib/history.ts @@ -4,7 +4,7 @@ // 真正的 HTTP 端点(/me/history*)与单篇回查(getHistoryMeta)在 api.ts。 // // 语义(2026-09-02 用户拍板): -// - 浏览历史 = 「点开过哪篇文章」,触发时机在 item 详情页(打开某篇走秀/街拍即记一条)。 +// - 浏览历史 = 「点开过哪篇文章」,触发时机在走秀/街拍详情页(打开某篇即记一条)。 // 列表页进入不记。 // - 服务端只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串 // (首字符区分类型:s 开头=街拍 / r 开头=走秀)。 @@ -77,7 +77,7 @@ function serverMode(): boolean { // ---------- 对外 API ---------- /** - * 记录一次浏览:打开某篇文章详情时调用(见 item 详情页)。 + * 记录一次浏览:打开某篇文章详情时调用(走秀/街拍详情页)。 * 登录态:写入本地缓存 + 后台 POST 服务端(upsert 刷新时间,失败不回滚本地)。 * 未登录:仅写入本地缓存。 */ diff --git a/src/lib/i18n/dictionary.ts b/src/lib/i18n/dictionary.ts index 9df8773..a2fc4a5 100644 --- a/src/lib/i18n/dictionary.ts +++ b/src/lib/i18n/dictionary.ts @@ -3,7 +3,7 @@ import { DEFAULT_LOCALE, type Locale } from './index'; /** * 单文件翻译字典。 * - * 设计(按你的要求): + * 设计: * - key 就是「英文原文」,直接当文案用,无需额外维护英文副本; * - value 只存「非默认语言」的翻译(本项目默认语言是 en,所以只存 cn 等); * - 英文环境调用 t(key) 直接返回 key 本身; @@ -80,6 +80,7 @@ export const dict: Record = { // ── 页脚 / Footer ── '© 2026 portfolio studio. all rights reserved.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' }, + 'all rights reserved.': { cn: '保留所有权利。' }, // ── 页脚简介 / Manifesto ── 'minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.': diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index e875321..5d58478 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -9,7 +9,7 @@ import { getLocaleByPath, getRelativeLocaleUrl } from 'astro:i18n'; import { translate } from './dictionary'; // ── 配置 / Config ── -/** 默认语言(无前缀时使用的语言,如 /shows)。 */ +/** 默认语言(配置为 prefixDefaultLocale: true,故也带前缀,如 /en/shows)。 */ export const DEFAULT_LOCALE = 'en' as const; /** 支持的语言列表。URL 前缀用的就是这些值,例如 /cn/shows 的 `cn`。 */ @@ -31,7 +31,7 @@ export const LOCALE_NAMES: Record // ── 工具 / Utils ── /** * 从 URL 解析语言,委托 Astro 内置的 astro:i18n.getLocaleByPath。 - * 无前缀(默认语言)路径会解析回 DEFAULT_LOCALE。 + * 解析失败或非受支持语言时回退 DEFAULT_LOCALE。 */ export function getLocaleFromUrl(url: URL | string): Locale { const pathname = typeof url === 'string' ? url : url.pathname; diff --git a/src/lib/routes.ts b/src/lib/routes.ts index f4220eb..57a70c3 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -1,7 +1,7 @@ // 全站 URL 路由集中管理 // - ROUTES:逻辑路由(不带 locale 前缀)。静态用字符串,动态用工厂函数。 // - href:服务端(.astro frontmatter)使用,跟随 astro.config 的 prefixDefaultLocale 规则拼前缀。 -// - clientHref:客户端脚本(Alpine/Vue)使用,astro:i18n 在浏览器不可用,故自行拼接。 +// - clientHref:客户端脚本(Alpine)使用,astro:i18n 在浏览器不可用,故自行拼接。 import { getRelativeLocaleUrl } from 'astro:i18n'; @@ -16,7 +16,6 @@ export const ROUTES = { // 动态路由(工厂函数)。 // 走秀 / 街拍详情按内容类型拆分到独立路由,与列表页 slug 保持一致; // 前端按类型化路由分流到对应后端强类型接口(/runway-looks/:id、/street-snaps/:id)。 - // 旧 /item/[id] 仅保留为 301 重定向(见 src/pages/{locale}/item/[id].astro),按 id 前缀回退。 runwayLook: (id: string | number) => `/runway-looks/${id}`, streetSnap: (id: string | number) => `/street-snaps/${id}`, } as const; diff --git a/src/styles/global.css b/src/styles/global.css index 7c43fae..a11d835 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,5 +1,18 @@ @import "tailwindcss"; +/* 中性灰阶令牌:统一全站散落的 #f1f1f1/#efefef/#e6e6e6/#ccc/#bdbdbd/#999/#888/#666 等 */ +:root { + --c-gray-50: #f6f6f6; + --c-gray-100: #f1f1f1; + --c-gray-200: #efefef; + --c-gray-300: #e6e6e6; + --c-gray-400: #ccc; + --c-gray-500: #bdbdbd; + --c-gray-600: #999; + --c-gray-700: #888; + --c-gray-800: #666; +} + [x-cloak] { display: none !important; } /* ===== 共享原子:被多个组件复用的极小样式片段 ===== @@ -25,4 +38,15 @@ .no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } +/* 键盘聚焦可见性:所有可交互元素加黑色描边环,提升无障碍与精致度 + (hover:opacity 只覆盖鼠标态,键盘 Tab 用户需要明确的焦点指示) */ +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +[role="button"]:focus-visible { + outline: 2px solid #111; + outline-offset: 2px; +} + diff --git a/src/styles/look-grid.css b/src/styles/look-grid.css index 846db68..9e38ebc 100644 --- a/src/styles/look-grid.css +++ b/src/styles/look-grid.css @@ -5,8 +5,6 @@ @reference "tailwindcss"; -[x-cloak] { display: none !important; } - /* ====================== 顶部 / 主体布局 ====================== */ .showsroot, .snapsroot { @@ -27,8 +25,8 @@ } /* 已激活筛选 pills */ -.showspills { @apply flex flex-wrap gap-2 pt-3.5; } -.snapspills { @apply flex flex-wrap gap-2 mb-4; } +.showspills { @apply flex flex-wrap gap-2 mt-4; } +.snapspills { @apply flex flex-wrap gap-2 mt-4; } .showspill, .snapspill { @apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white; @@ -49,7 +47,7 @@ @apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] flex flex-col max-md:static max-md:max-h-none max-md:w-full; } .showsaside { - @apply rounded-none transition-[flex-basis] duration-250 ease-in-out; + @apply rounded-none transition-[flex-basis] duration-[250ms] ease-in-out; } .showsaside-head, .snapsaside-head { @@ -61,13 +59,13 @@ } .showsaside-body, .snapsaside-body { - @apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff]; + @apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:var(--c-gray-400)_#fff]; } /* 筛选分组 */ .showsg, .snapsg { - @apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1; + @apply border-t border-[var(--c-gray-300)] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1; } .showsg-h, .snapsg-h { @@ -78,7 +76,7 @@ } .showsg-chev, .snapsg-chev { - @apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1; + @apply inline-block text-[9px] leading-none text-[var(--c-gray-600)] transition-transform duration-200 ml-1; } .showsg-chev.is-collapsed, .snapsg-chev.is-collapsed { @apply -rotate-90; } @@ -90,7 +88,7 @@ } .showschecks li, .snapschecks li { - @apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6]; + @apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[var(--c-gray-50)]; } .showschecks input[type="radio"], .snapschecks input[type="radio"] { @@ -114,7 +112,7 @@ } .showsclear, .snapsclear { - @apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[#666] cursor-pointer hover:text-black transition-colors duration-150; + @apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-800)] cursor-pointer hover:text-black transition-colors duration-150; } /* ====================== 右 网格 / 卡片 ====================== */ @@ -124,7 +122,7 @@ .showsgrid, .snapsgrid { - @apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1; + @apply grid grid-cols-2 gap-[22px] relative max-sm:grid-cols-1; } .showsgrid.is-loading::before, .snapscontent.is-loading::before { @@ -135,29 +133,29 @@ .showscard, .snapscard { - @apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px]; + @apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-md:flex-col max-md:gap-[10px]; } .showscard:hover .showscard-img img, -.snapscard:hover .snapscard-img img { @apply scale-104; } +.snapscard:hover .snapscard-img img { @apply scale-[1.04]; } .showscard-left, .snapscard-left { - @apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none; + @apply flex-none w-[30%] min-w-[110px] max-md:w-full max-md:flex-none; } .showscard-img, .snapscard-img { - @apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none; + @apply relative w-full aspect-[3/4] bg-[var(--c-gray-100)] overflow-hidden rounded-none; } .showscard-img img, .snapscard-img img { - @apply w-full h-full object-cover transition-transform duration-600 opacity-0; + @apply w-full h-full object-cover transition-transform duration-[400ms] opacity-0; } .showscard-img img.is-loaded, .snapscard-img img.is-loaded { @apply opacity-100; } .showsspin, .snapsspin { - @apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10; + @apply absolute inset-0 flex items-center justify-center text-[var(--c-gray-500)] opacity-100 transition-opacity duration-300 pointer-events-none z-10; } .showscard-img.is-loaded .showsspin, .snapscard-img.is-loaded .snapsspin, @@ -166,7 +164,7 @@ .showscard-right, .snapscard-right { - @apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full; + @apply flex-1 min-w-0 flex flex-col justify-between max-md:w-full; } .showscard-info, .snapscard-info { @@ -178,7 +176,7 @@ } .showscard-meta, .snapscard-meta { - @apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden; + @apply text-[9.5px] text-[var(--c-gray-700)] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden; } .showscard-strip, @@ -187,16 +185,16 @@ } .showscard-thumb, .snapscard-thumb { - @apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden; + @apply relative aspect-[3/4] bg-[var(--c-gray-100)] overflow-hidden; } .showscard-thumb img, .snapscard-thumb img { - @apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out; + @apply w-full h-full object-cover opacity-0 transition-opacity duration-[450ms] ease-in-out; } .showscard-thumb img.is-loaded, .snapscard-thumb img.is-loaded { @apply opacity-100; } .showscard-thumb.placeholder, -.snapscard-thumb.placeholder { @apply bg-[#f6f6f6]; } +.snapscard-thumb.placeholder { @apply bg-[var(--c-gray-50)]; } .showscard-thumb-more { @apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center; } @@ -208,7 +206,7 @@ .snapscard--skel .snapscard-img, .snapscard--skel .snapscard-thumb, .snapscard--skel .snapsline { - @apply bg-[#efefef]; + @apply bg-[var(--c-gray-200)]; animation: looksShim 1.6s ease infinite; } @@ -220,12 +218,12 @@ } .pg.is-current { @apply bg-black text-white cursor-default; } .pg:disabled { @apply opacity-30 cursor-default; } -.pg-ellipsis { @apply px-1 text-[#999] select-none text-[11px]; } +.pg-ellipsis { @apply px-1 text-[var(--c-gray-600)] select-none text-[11px]; } .pg-wrap { @apply inline-flex items-center; } .showsempty, .snapsempty { - @apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc]; + @apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[var(--c-gray-600)] border border-dashed border-[var(--c-gray-400)]; } @keyframes looksProg { 0% { left: -38%; } 100% { left: 100%; } }