@routerhub/agent-rules 1.5.146 → 1.5.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.base.md CHANGED
@@ -131,9 +131,9 @@
131
131
 
132
132
  - ⚠️ PR Title / Description / Test Plan 全部中文。
133
133
  - ⚠️ **PR 必须附效果截图作为可视化证据,且逐条满足以下硬性要求(缺一不可,禁止跳过)**:
134
- - **4K 全页**:截图视口统一 3840px 宽、`fullPage` 截完整页面,禁止用 1920px、禁止只截视口一屏。
134
+ - **全页图**:用浏览器真实视口(`window.innerWidth` 即截图宽度,4K 屏自然宽 3840)`fullPage` 截完整页面,禁止只截视口一屏。⚠️ **禁止强制把视口硬拉成 3840 宽**——浏览器视口宽由屏幕实际分辨率决定,硬拉宽会让内容按比例缩小(看不清),且与截图像素发生缩放错位,是标注不准的根因之一。若要更高清晰度,用 `set viewport <W> <H> 2`(DPR 倍率)提高渲染精度(CSS 宽不变、像素更密),而不是拉宽 CSS 视口。
135
135
  - **全面多角度**:一张全页图 + 每个关键改动区域的局部放大图,多个改动点要逐个覆盖,确保 reviewer 不看代码就能看全本次全部改动。
136
- - **箭头标注**:每张截图必须用醒目箭头 + 简短文字标签标注关键改动区域/验证点(修复前红框/红箭头、修复后绿框/绿箭头),标注放在不遮挡原内容的位置,禁止只贴裸图不标注。
136
+ - **箭头标注(必须标注准)**:每张截图必须用醒目箭头 + 简短文字标签标注关键改动区域/验证点(修复前红框/红箭头、修复后绿框/绿箭头),标注放在不遮挡原内容的位置,禁止只贴裸图不标注。⚠️ **标注坐标必须来自浏览器 DOM 测量(`getBoundingClientRect()` + `scrollX/scrollY`)精确换算成截图像素,禁止肉眼看图估坐标**——全页拼接/缩放截图里 CSS 坐标 ≠ 截图像素,肉眼估位是标注不准的直接原因。标注统一走 `/screenshot-annotate` skill(坐标换算方法 + `annotate.js` 统一脚本)。
137
137
  - **URL 可见**:截图中必须能看到当前页面 URL,确保证据可追溯。
138
138
  - **前后对比**:必须同时展示修复前与修复后。
139
139
  - ⚠️ **截图必须直接内嵌在 PR Description 中,让 reviewer 打开 PR 就能看到效果图(`![](CDN_URL)` 方式渲染为可见图片),禁止只在文字里描述"改动了什么"而不放图,也禁止把截图只作为文件附件/提交到分支目录而不在 PR 正文中引用。** 原因:reviewer 看 PR 的第一眼就是看描述,如果看不到图、只能读文字,完全无法直观感知改动效果;截图不内嵌 = 等于没附。
@@ -147,9 +147,9 @@
147
147
  2. **GitHub 静态编译检查**:用 `gh pr checks <PR>` 查看 CI 检查状态(`success`=通过,`failure`=失败,`pending`=进行中)。存在失败项时,必须定位失败根因、修改代码并重新推送,直到全部通过,禁止把静态编译未通过的 PR 抛给 reviewer。
148
148
  3. **发新版本**:PR 合并后按项目发布流程发布新版本(本项目统一执行根目录 `./release.sh`,自动完成 patch 版本号 +1、更新 package.json、`git commit`/`push`、打 tag、`npm publish`)。
149
149
  - ⚠️ **创建完 PR 后自动走完整闭环流程,未走完不算完成**:创建 PR → 循环 review → 重新部署测试环境验证 → 确认没问题 → 发 PR 链接给用户 → 发新版本,六步缺一不可,全程自动执行:
150
- 1. **自动走循环 review**:创建完 PR 后自动触发 `/loop-review` skill,反复「拉取 AI review(Claude Opus + GPT 交叉验证)→ 逐条读真实代码判断哪些值得修 → 值得修的改、不值得修/误报的 Won't fix 切断 → push 触发新一轮 review」,直到某一轮不再冒出值得修的新问题才结束,禁止只跑一轮就收工。
151
- 2. **重新部署到测试环境**:循环 review 走完后,自动触发 `/deploy-test` skill,把最新代码重新部署到测试环境,确保测试的是循环 review 之后的最终代码。
152
- 3. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️ 截图规范」:视口 3840px 宽、`fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并在每张截图上用**箭头标注**关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
150
+ 1. **自动走循环 review**:创建完 PR 后自动触发 `/loop-review` skill,反复「拉取 AI review(Claude Opus + GPT 交叉验证)→ 逐条读真实代码判断哪些值得修 → 值得修的改、不值得修/误报的 Won't fix 切断 → push 触发新一轮 review」,直到某一轮不再冒出值得修的新问题才结束,禁止只跑一轮就收工。**循环 review 走完后,必须显式输出「✅ 循环 review 完成,进入发布收尾闭环」,并调用 `/pr-release-loop` skill 走完后续步骤。**
151
+ 2. **重新部署到测试环境(循环 review 后最容易漏掉的一步)**:循环 review 走完后,自动触发 `/deploy-test` skill,把最新代码重新部署到测试环境,确保测试的是循环 review 之后的最终代码。⚠️ **循环 review 期间每次修复都会 push 新 commit;不重新部署 = 测试环境跑的还是 review 之前的旧代码 = 用旧代码验证新改动 = 结论无效。因此循环 review 结束后禁止直接发 PR 链接 / 发版,必须先 `/deploy-test` 重部署。**
152
+ 3. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️ 截图规范」:真实视口 + `fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并在每张截图上用**坐标换算后的箭头标注**(走 `/screenshot-annotate` skill)关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
153
153
  4. **确认没问题才算完成**:测试通过、截图与箭头标注齐全、功能符合预期,才算真正完成。禁止测试没跑、截图没标注就宣称完成。
154
154
  5. **把 PR 链接发给用户**:确认没问题后,先把 PR 链接发送给用户(`gh pr view <PR> --json url -q .url`),让用户能直接打开查看,再执行发版。
155
155
  6. **发新版本**:确认没问题、PR 链接已发送后,按上面三步收尾完成冲突检查与静态编译检查,PR 合并后执行根目录 `./release.sh` 发新版本。
@@ -199,9 +199,10 @@
199
199
 
200
200
  ## ⚠️ 截图规范
201
201
 
202
- - ⚠️ **截图视口宽度统一按 4K3840px 宽)设置,禁止用 1920px**。1920 宽对复杂内容看不全、看不清。用 agent-browser 时先 `agent-browser --cdp 9223 viewport 3840 <height>`(高度按内容给足,如 2400+),或全页截图并保证宽度 3840。
203
- - ⚠️ **截图必须 `fullPage: true` 截完整页面**,不要只截视口的一部分。
202
+ - ⚠️ **截图用浏览器真实视口宽度(`window.innerWidth` 即截图宽,4K 屏自然宽 3840),禁止强制把视口硬拉成 3840px**。浏览器视口宽度由屏幕实际分辨率决定,强行 `set viewport 3840 <h>` CSS 视口拉宽到比屏幕还宽,会让页面按比例缩小、内容看不清,且 CSS 像素与截图设备像素发生缩放错位——这正是「强制 4K 时标注不准」的根因。需要更高清晰度时用 `set viewport <W> <H> <DPR>`(如 `2` 倍 DPR,CSS 宽不变、像素更密),而不是拉宽 CSS 视口。
203
+ - ⚠️ **全页截图**:用 `screenshot --full`(agent-browser)或 `page.screenshot({ fullPage: true })`(Playwright)截完整页面,不要只截视口的一部分;用 agent-browser 截全页前先 `set viewport <视口宽> <合适高度>` 固定视口,确保截图宽度 = 视口宽度。
204
204
  - ⚠️ **截图中必须能看到当前页面 URL**(浏览器地址栏,或页面顶部叠加 URL 标注),确保证据可追溯。
205
+ - ⚠️ **标注必须准确,坐标必须有浏览器测量来源**:箭头/框要指哪儿,用 `getBoundingClientRect()` + `scrollX/scrollY` 取元素在整页中的 CSS 坐标,再按截图实际宽度换算成截图像素,统一用 `/screenshot-annotate` skill(含 `annotate.js` 脚本)标注。禁止用肉眼看图估坐标,禁止在强制缩放造成坐标错位的前提下标注。
205
206
  - **保存到磁盘文件**(`page.screenshot({ path, fullPage: true })`),禁止只用内联展示的截图工具——内联的不落盘,用户无法在 Markdown / HTML 文档里查看。路径统一放 `docs/` 对应功能子目录,文件名用「编号 + 英文描述」(如 `01-login-page.png`)。
206
207
 
207
208
  ### 非 UI / 后端 / 基础设施改动的效果截图获取方法
@@ -329,6 +330,24 @@
329
330
  - ⚠️ **日常对话中,只要聊到写代码/改代码/排查问题/验证功能/实现需求等任何越过「闲聊」的实质内容,就自动触发 `/visual-report` skill,用它管理「验证证据」的产出**,无需用户额外吩咐。触发不依赖于用户正式说「提需求」「做功能」——用户在平常对话里随口提到的开发任务(如「这个页面的按钮没反应」「Redis 里这个 key 好像不对」「帮我把这个查询优化一下」)同样自动触发。该 skill 的职责是:真实数据 + 真实交互 + 可视化证据(页面截图 / Redis 截图 / 数据库截图),把「功能对不对」用用户能一眼看懂的截图和报告证明给用户看。
330
331
  - ⚠️ **只有用户显式提到「测试 / TDD / 测试用例」时,才调用 `/tdd-workflow` skill**(写测试用例、跑回归)。没有明确指令时,禁止把 TDD 当作默认开发流程;默认开发流程是上面的 `/visual-report`。
331
332
 
333
+ ## 🚨 agent-browser 标签页防串扰(所有项目通用)
334
+
335
+ **根因**:agent-browser 的「当前活动标签页」由共享守护进程维护。多个 Claude 会话
336
+ 共用同一个 CDP Chrome(如端口 9226)时,任一会话执行 `tab new` / `tab <n>` /
337
+ `screenshot` 都会把 Chrome 全局活动页切走,导致本会话的 `eval` / `snapshot` /
338
+ `click` / `fill` 落到**别人正在操作的标签页**上(已实测复现:eval 返回了另一个
339
+ 任务的页面)。
340
+
341
+ **必须遵守**:
342
+ 1. 每次浏览器操作都固定带专属 `--namespace <会话唯一标识>`,同一会话全程不变。
343
+ 2. 不信任「当前活动页是哪一页」。任何操作前先 `agent-browser tab` 列出标签页,
344
+ 按 URL 特征找到自己的页,再执行操作;**把「切到自己的 tab + 全部操作」合并进
345
+ 同一次 Bash 调用**(`tab 15 && ...`),中间不等待、不留空隙,不给其他会话插队。
346
+ 3. `tab new` 打开页面后**立即记录返回的 tab id**(如 `t15`),后续每条操作先
347
+ `tab 15` 再操作,不要靠默认活动页猜测。
348
+ 4. 一旦 `eval` / `snapshot` 返回的内容不是自己操作的页面(URL/内容不符),
349
+ **第一反应是标签页被抢占**:切回自己的 tab id 后重试,禁止盲目重试同一条命令。
350
+
332
351
  ## 优先级
333
352
 
334
353
  1. 项目私有规则(AGENTS.private.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routerhub/agent-rules",
3
- "version": "1.5.146",
3
+ "version": "1.5.148",
4
4
  "description": "Shared Copilot agent rules and guidelines for RouterHub projects",
5
5
  "main": "AGENTS.base.md",
6
6
  "bin": {
package/rules/global.md CHANGED
@@ -131,9 +131,9 @@ name: "通用规则"
131
131
 
132
132
  - ⚠️ PR Title / Description / Test Plan 全部中文。
133
133
  - ⚠️ **PR 必须附效果截图作为可视化证据,且逐条满足以下硬性要求(缺一不可,禁止跳过)**:
134
- - **4K 全页**:截图视口统一 3840px 宽、`fullPage` 截完整页面,禁止用 1920px、禁止只截视口一屏。
134
+ - **全页图**:用浏览器真实视口(`window.innerWidth` 即截图宽度,4K 屏自然宽 3840)`fullPage` 截完整页面,禁止只截视口一屏。⚠️ **禁止强制把视口硬拉成 3840 宽**——浏览器视口宽由屏幕实际分辨率决定,硬拉宽会让内容按比例缩小(看不清),且与截图像素发生缩放错位,是标注不准的根因之一。若要更高清晰度,用 `set viewport <W> <H> 2`(DPR 倍率)提高渲染精度(CSS 宽不变、像素更密),而不是拉宽 CSS 视口。
135
135
  - **全面多角度**:一张全页图 + 每个关键改动区域的局部放大图,多个改动点要逐个覆盖,确保 reviewer 不看代码就能看全本次全部改动。
136
- - **箭头标注**:每张截图必须用醒目箭头 + 简短文字标签标注关键改动区域/验证点(修复前红框/红箭头、修复后绿框/绿箭头),标注放在不遮挡原内容的位置,禁止只贴裸图不标注。
136
+ - **箭头标注(必须标注准)**:每张截图必须用醒目箭头 + 简短文字标签标注关键改动区域/验证点(修复前红框/红箭头、修复后绿框/绿箭头),标注放在不遮挡原内容的位置,禁止只贴裸图不标注。⚠️ **标注坐标必须来自浏览器 DOM 测量(`getBoundingClientRect()` + `scrollX/scrollY`)精确换算成截图像素,禁止肉眼看图估坐标**——全页拼接/缩放截图里 CSS 坐标 ≠ 截图像素,肉眼估位是标注不准的直接原因。标注统一走 `/screenshot-annotate` skill(坐标换算方法 + `annotate.js` 统一脚本)。
137
137
  - **URL 可见**:截图中必须能看到当前页面 URL,确保证据可追溯。
138
138
  - **前后对比**:必须同时展示修复前与修复后。
139
139
  - ⚠️ **截图必须直接内嵌在 PR Description 中,让 reviewer 打开 PR 就能看到效果图(`![](CDN_URL)` 方式渲染为可见图片),禁止只在文字里描述"改动了什么"而不放图,也禁止把截图只作为文件附件/提交到分支目录而不在 PR 正文中引用。** 原因:reviewer 看 PR 的第一眼就是看描述,如果看不到图、只能读文字,完全无法直观感知改动效果;截图不内嵌 = 等于没附。
@@ -147,9 +147,9 @@ name: "通用规则"
147
147
  2. **GitHub 静态编译检查**:用 `gh pr checks <PR>` 查看 CI 检查状态(`success`=通过,`failure`=失败,`pending`=进行中)。存在失败项时,必须定位失败根因、修改代码并重新推送,直到全部通过,禁止把静态编译未通过的 PR 抛给 reviewer。
148
148
  3. **发新版本**:PR 合并后按项目发布流程发布新版本(本项目统一执行根目录 `./release.sh`,自动完成 patch 版本号 +1、更新 package.json、`git commit`/`push`、打 tag、`npm publish`)。
149
149
  - ⚠️ **创建完 PR 后自动走完整闭环流程,未走完不算完成**:创建 PR → 循环 review → 重新部署测试环境验证 → 确认没问题 → 发 PR 链接给用户 → 发新版本,六步缺一不可,全程自动执行:
150
- 1. **自动走循环 review**:创建完 PR 后自动触发 `/loop-review` skill,反复「拉取 AI review(Claude Opus + GPT 交叉验证)→ 逐条读真实代码判断哪些值得修 → 值得修的改、不值得修/误报的 Won't fix 切断 → push 触发新一轮 review」,直到某一轮不再冒出值得修的新问题才结束,禁止只跑一轮就收工。
151
- 2. **重新部署到测试环境**:循环 review 走完后,自动触发 `/deploy-test` skill,把最新代码重新部署到测试环境,确保测试的是循环 review 之后的最终代码。
152
- 3. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️ 截图规范」:视口 3840px 宽、`fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并在每张截图上用**箭头标注**关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
150
+ 1. **自动走循环 review**:创建完 PR 后自动触发 `/loop-review` skill,反复「拉取 AI review(Claude Opus + GPT 交叉验证)→ 逐条读真实代码判断哪些值得修 → 值得修的改、不值得修/误报的 Won't fix 切断 → push 触发新一轮 review」,直到某一轮不再冒出值得修的新问题才结束,禁止只跑一轮就收工。**循环 review 走完后,必须显式输出「✅ 循环 review 完成,进入发布收尾闭环」,并调用 `/pr-release-loop` skill 走完后续步骤。**
151
+ 2. **重新部署到测试环境(循环 review 后最容易漏掉的一步)**:循环 review 走完后,自动触发 `/deploy-test` skill,把最新代码重新部署到测试环境,确保测试的是循环 review 之后的最终代码。⚠️ **循环 review 期间每次修复都会 push 新 commit;不重新部署 = 测试环境跑的还是 review 之前的旧代码 = 用旧代码验证新改动 = 结论无效。因此循环 review 结束后禁止直接发 PR 链接 / 发版,必须先 `/deploy-test` 重部署。**
152
+ 3. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️ 截图规范」:真实视口 + `fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并在每张截图上用**坐标换算后的箭头标注**(走 `/screenshot-annotate` skill)关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
153
153
  4. **确认没问题才算完成**:测试通过、截图与箭头标注齐全、功能符合预期,才算真正完成。禁止测试没跑、截图没标注就宣称完成。
154
154
  5. **把 PR 链接发给用户**:确认没问题后,先把 PR 链接发送给用户(`gh pr view <PR> --json url -q .url`),让用户能直接打开查看,再执行发版。
155
155
  6. **发新版本**:确认没问题、PR 链接已发送后,按上面三步收尾完成冲突检查与静态编译检查,PR 合并后执行根目录 `./release.sh` 发新版本。
@@ -199,9 +199,10 @@ name: "通用规则"
199
199
 
200
200
  ## ⚠️ 截图规范
201
201
 
202
- - ⚠️ **截图视口宽度统一按 4K3840px 宽)设置,禁止用 1920px**。1920 宽对复杂内容看不全、看不清。用 agent-browser 时先 `agent-browser --cdp 9223 viewport 3840 <height>`(高度按内容给足,如 2400+),或全页截图并保证宽度 3840。
203
- - ⚠️ **截图必须 `fullPage: true` 截完整页面**,不要只截视口的一部分。
202
+ - ⚠️ **截图用浏览器真实视口宽度(`window.innerWidth` 即截图宽,4K 屏自然宽 3840),禁止强制把视口硬拉成 3840px**。浏览器视口宽度由屏幕实际分辨率决定,强行 `set viewport 3840 <h>` CSS 视口拉宽到比屏幕还宽,会让页面按比例缩小、内容看不清,且 CSS 像素与截图设备像素发生缩放错位——这正是「强制 4K 时标注不准」的根因。需要更高清晰度时用 `set viewport <W> <H> <DPR>`(如 `2` 倍 DPR,CSS 宽不变、像素更密),而不是拉宽 CSS 视口。
203
+ - ⚠️ **全页截图**:用 `screenshot --full`(agent-browser)或 `page.screenshot({ fullPage: true })`(Playwright)截完整页面,不要只截视口的一部分;用 agent-browser 截全页前先 `set viewport <视口宽> <合适高度>` 固定视口,确保截图宽度 = 视口宽度。
204
204
  - ⚠️ **截图中必须能看到当前页面 URL**(浏览器地址栏,或页面顶部叠加 URL 标注),确保证据可追溯。
205
+ - ⚠️ **标注必须准确,坐标必须有浏览器测量来源**:箭头/框要指哪儿,用 `getBoundingClientRect()` + `scrollX/scrollY` 取元素在整页中的 CSS 坐标,再按截图实际宽度换算成截图像素,统一用 `/screenshot-annotate` skill(含 `annotate.js` 脚本)标注。禁止用肉眼看图估坐标,禁止在强制缩放造成坐标错位的前提下标注。
205
206
  - **保存到磁盘文件**(`page.screenshot({ path, fullPage: true })`),禁止只用内联展示的截图工具——内联的不落盘,用户无法在 Markdown / HTML 文档里查看。路径统一放 `docs/` 对应功能子目录,文件名用「编号 + 英文描述」(如 `01-login-page.png`)。
206
207
 
207
208
  ### 非 UI / 后端 / 基础设施改动的效果截图获取方法
@@ -329,6 +330,24 @@ name: "通用规则"
329
330
  - ⚠️ **日常对话中,只要聊到写代码/改代码/排查问题/验证功能/实现需求等任何越过「闲聊」的实质内容,就自动触发 `/visual-report` skill,用它管理「验证证据」的产出**,无需用户额外吩咐。触发不依赖于用户正式说「提需求」「做功能」——用户在平常对话里随口提到的开发任务(如「这个页面的按钮没反应」「Redis 里这个 key 好像不对」「帮我把这个查询优化一下」)同样自动触发。该 skill 的职责是:真实数据 + 真实交互 + 可视化证据(页面截图 / Redis 截图 / 数据库截图),把「功能对不对」用用户能一眼看懂的截图和报告证明给用户看。
330
331
  - ⚠️ **只有用户显式提到「测试 / TDD / 测试用例」时,才调用 `/tdd-workflow` skill**(写测试用例、跑回归)。没有明确指令时,禁止把 TDD 当作默认开发流程;默认开发流程是上面的 `/visual-report`。
331
332
 
333
+ ## 🚨 agent-browser 标签页防串扰(所有项目通用)
334
+
335
+ **根因**:agent-browser 的「当前活动标签页」由共享守护进程维护。多个 Claude 会话
336
+ 共用同一个 CDP Chrome(如端口 9226)时,任一会话执行 `tab new` / `tab <n>` /
337
+ `screenshot` 都会把 Chrome 全局活动页切走,导致本会话的 `eval` / `snapshot` /
338
+ `click` / `fill` 落到**别人正在操作的标签页**上(已实测复现:eval 返回了另一个
339
+ 任务的页面)。
340
+
341
+ **必须遵守**:
342
+ 1. 每次浏览器操作都固定带专属 `--namespace <会话唯一标识>`,同一会话全程不变。
343
+ 2. 不信任「当前活动页是哪一页」。任何操作前先 `agent-browser tab` 列出标签页,
344
+ 按 URL 特征找到自己的页,再执行操作;**把「切到自己的 tab + 全部操作」合并进
345
+ 同一次 Bash 调用**(`tab 15 && ...`),中间不等待、不留空隙,不给其他会话插队。
346
+ 3. `tab new` 打开页面后**立即记录返回的 tab id**(如 `t15`),后续每条操作先
347
+ `tab 15` 再操作,不要靠默认活动页猜测。
348
+ 4. 一旦 `eval` / `snapshot` 返回的内容不是自己操作的页面(URL/内容不符),
349
+ **第一反应是标签页被抢占**:切回自己的 tab id 后重试,禁止盲目重试同一条命令。
350
+
332
351
  ## 优先级
333
352
 
334
353
  1. 项目私有规则(AGENTS.private.md)
@@ -67,9 +67,9 @@ description: >-
67
67
 
68
68
  ### 截图规范
69
69
 
70
- - 截取整页(full page),不是可视区域
70
+ - 截取整页(full page),不是可视区域;用真实视口宽度(4K 屏自然宽 3840),禁止强制把视口拉宽
71
71
  - 截图结果必须包含当前页面 URL
72
- - ⚠️ **截图版 HTML 的每张截图都必须加箭头(或红框)标注**,指向该图要说明的关键操作点或关键数据,禁止放无标注的「裸截图」;标注放在页面空白区域,不遮挡关键内容
72
+ - ⚠️ **截图版 HTML 的每张截图都必须加箭头(或红框)标注**,指向该图要说明的关键操作点或关键数据,禁止放无标注的「裸截图」;标注放在页面空白区域,不遮挡关键内容。⚠️ **标注坐标必须精确**:用 `/screenshot-annotate` skill(浏览器 DOM 测得的坐标 → `annotate.js` 换算到截图像素),禁止肉眼估位
73
73
  - 制作过程中产生的中间截图文件统一放到 `screenshots/` 目录
74
74
 
75
75
  ### 分步操作记录(每步必录)
@@ -58,7 +58,7 @@ Closes #issue编号
58
58
  1. **截修复后**:浏览器打开改动页面 → 滚动到改动区域 → 全页截图
59
59
  2. **截修复前**:临时注释/回退改动代码 → 等 hot reload → 同位置截图 → 恢复代码
60
60
  - ⚠️ **若线上数据已变化,导致无法在真实页面复现"修复前"效果**:允许改用等价的纯逻辑对比代替(用新旧两版逻辑代码跑同样的输入数据,把输出结果差异渲染成对比图),但必须在 Description 里明确写清楚"为什么无法复现 + 用了什么替代方案",禁止因此省略截图或假装能复现。
61
- 3. **生成对比图**:用 sharp 拼接 → 上方红/绿色标注条(❌修复前 / ✅修复后)→ 下方左右并排截图 → 关键改动区域用箭头 + 红/绿框圈选
61
+ 3. **生成对比图**:用 `annotate.js`(截图标注统一脚本,见 `/screenshot-annotate` skill)拼接并标注 → 上方红/绿色标注条(❌修复前 / ✅修复后)→ 下方左右并排截图 → 关键改动区域用坐标精确换算后的箭头 + 红/绿框圈选
62
62
  4. **上传 GitHub CDN**(在 PR Description 编辑区直接上传):
63
63
  - 打开目标 PR 页面 → 在 **Description 编辑区**直接上传图片(拖拽/粘贴,或定位 Description 编辑区的隐藏 `input[type=file]` 上传),GitHub 会自动把图片转成 `https://github.com/user-attachments/assets/...` 的 CDN 地址并插入 Description 正文,`![](CDN_URL)` 内嵌
64
64
  - ⚠️ **禁止走评论区上传再搬运 CDN URL**:评论区上传需要多一步「提交评论 → 复制 URL → 粘贴到 Description」,产生的临时图片评论会留在 PR 对话里干扰 reviewer 阅读,且多一步手动搬运容易出错
@@ -66,10 +66,10 @@ Closes #issue编号
66
66
  5. **保存 Description**:确认图片已内嵌进 Description 正文后,保存 PR Description 使图片持久化
67
67
 
68
68
  **截图硬性规范(PR 效果截图必须逐条满足,缺一不可,禁止跳过)**:
69
- - ⚠️ **视口宽度统一 4K(3840px 宽)**:用 agent-browser 时先 `agent-browser --cdp 9223 viewport 3840 <height>`(高度按内容给足,如 2400+),或全页截图并保证宽度 3840。禁止用 1920px——1920 宽对复杂内容看不全、看不清。
69
+ - ⚠️ **全页图用浏览器真实视口宽度(`window.innerWidth` 即截图宽,4K 屏自然宽 3840)**,用 agent-browser 时先 `set viewport <视口宽> <合适高度>` 固定视口,再 `screenshot --full` 截完整页面。⚠️ **禁止强制 `set viewport 3840 <h>` CSS 视口拉宽到比屏幕还宽**(页面会缩小看不清、且 CSS 像素与截图像素缩放错位导致标注不准);需要更高清晰度时用 `set viewport <W> <H> 2`(DPR 倍率,CSS 宽不变、像素更密)。
70
70
  - ⚠️ **必须 `fullPage: true` 截完整页面**,不要只截视口的一部分。每个改动点都要覆盖到,禁止只截视口内一屏。
71
71
  - ⚠️ **截图必须全面(多图覆盖多个角度)**:一张全页图 + 每个关键改动区域的局部放大图。只截一处、截局部、漏掉改动点都不算全。整份 PR Description 的效果截图要使 reviewer 不看代码就能看全本次改动。
72
- - ⚠️ **每张截图必须用醒目箭头 + 简短文字标签标注关键改动区域/验证点**,让看的人一眼看懂这张图证明了什么;修复前用红框/红箭头,修复后用绿框/绿箭头,标注放在不遮挡原内容的位置。禁止只贴裸图不标注。
72
+ - ⚠️ **每张截图必须用醒目箭头 + 简短文字标签标注关键改动区域/验证点**,让看的人一眼看懂这张图证明了什么;修复前用红框/红箭头,修复后用绿框/绿箭头,标注放在不遮挡原内容的位置。禁止只贴裸图不标注。⚠️ **标注坐标必须精确**:统一用 `/screenshot-annotate` skill(`getBoundingClientRect()` + `scrollX/scrollY` 换算到截图像素,用 `annotate.js` 脚本画箭头),禁止肉眼看图估坐标。
73
73
  - ⚠️ **截图中必须能看到当前页面 URL**(浏览器地址栏,或页面顶部叠加 URL 标注),确保证据可追溯。
74
74
  - ⚠️ 截图保存到本地磁盘 `docs/` 对应子目录(文件名「编号 + 英文描述」,如 `01-before.png` / `02-after.png`),上传 CDN 后按规则清理临时文件,禁止提交到 Git 仓库。
75
75
  - ⚠️ 必须展示前后对比(修复前红框,修复后绿框),标注不遮挡页面内容。
@@ -101,7 +101,7 @@ gh pr create \
101
101
  1. **先做 CI 闸门检查**:创建 PR 后立即执行 `gh pr checks <PR>`(必要时轮询直到非 `pending`)。若存在任一 `failure`,必须先定位失败根因并修复,推送后复查到全部 `success`,再继续后续步骤;禁止带红 CI 进入下一步。
102
102
  2. **自动走循环 review**:自动触发 `/loop-review` skill,反复「拉取 AI review(Claude Opus + GPT 交叉验证)→ 逐条读真实代码判断哪些值得修 → 值得修的改、不值得修/误报的 Won't fix 切断 → push 触发新一轮 review」,直到某一轮不再冒出值得修的新问题才结束。
103
103
  3. **重新部署到测试环境**:循环 review 走完后,自动触发 `/deploy-test` skill,把最新代码重新部署到测试环境,确保测试的是循环 review 之后的最终代码。
104
- 4. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️ 截图规范」:视口 3840px 宽、`fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并在每张截图上用**箭头标注**关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
104
+ 4. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️ 截图规范」:真实视口 + `fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并在每张截图上用**坐标换算后的箭头标注**(走 `/screenshot-annotate` skill)关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
105
105
  5. **确认没问题才算完成**:测试通过、截图与箭头标注齐全、功能符合预期,才算真正完成。禁止测试没跑、截图没标注就宣称完成。
106
106
  6. **发新版本**:确认没问题后,回到下方步骤 7 完成冲突检查与静态编译检查,PR 合并后执行根目录 `./release.sh` 发新版本。
107
107
 
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: pr-release-loop
3
+ description: >-
4
+ PR 发布完整闭环(循环 review 之后的收尾)——创建 PR 后按 AGENTS.base.md「⚠️ 创建完 PR 后自动走完整闭环流程」自动走完
5
+ 循环 review → 重新部署测试环境 → 测试环境复测 → 确认 → 发 PR 链接 → 发新版本 六步。核心防线:**循环 review 走完后,
6
+ 必须重新部署到测试环境并复测,确认没问题之后才能发链接/发版**——这是最容易漏掉的一环(review 会改代码,跳过复测=拿旧代码下结论)。
7
+ 触发场景包括但不限于:
8
+ 「PR闭环」「闭环走完」「完整闭环」「六步闭环」「走完整流程」「收尾」「发布闭环」「发布收尾」「循环review完再部署测一遍」「测试环境再测一遍」。
9
+ 以及「创建PR后的完整流程」「走完循环review流程」「循环review完之后」。
10
+ 当循环 review 显示「无值得修的新问题」、进入收尾阶段时,**自动触发本 Skill**,无需用户再开口。
11
+ ---
12
+
13
+ # PR 发布完整闭环(循环 review 之后的收尾)
14
+
15
+ ⚠️ **本 Skill 已触发。在用户回复中第一句话必须输出:「🔧 已触发 `pr-release-loop`,进入循环 review 之后的发布收尾闭环…」然后严格按照以下步骤执行,不得跳过。**
16
+
17
+ 创建 PR 之后,自动把完整闭环走完。**重点不是「做六件事」本身,而是第 3、4 步是重活、最容易在「收尾惯性」中被省略**——循环 review 走完时往往急着交付,直接把「重新部署测试环境 + 复测」跳过去了。本 Skill 就是那道闸。
18
+
19
+ ## ⚠️ 核心铁律(违反即错误)
20
+
21
+ - ⚠️ **循环 review 结束 ≠ 完成。** review 期间会 push 新 commit,**只有把最新代码重新部署到测试环境、在测试环境复测通过,才算真正完成**。跳过复测直接用旧代码下结论 = 白 review。
22
+ - ⚠️ **完成定义清单(终局前必须逐项自查并输出):**
23
+ 1. ✅ 循环 review 走完(`/loop-review`,直到某一轮不再冒出值得修的新问题)
24
+ 2. ✅ 最新代码已提交并推送到 PR 分支
25
+ 3. ✅ 已通过 `/deploy-test` 重新部署到测试环境(部署的是 review 之后的最终代码)
26
+ 4. ✅ 测试环境用真实数据 + 真实交互复测通过(每张截图真实视口 + `fullPage` 全页、坐标换算箭头标注、URL 可见)
27
+ 5. ✅ 与主分支无冲突(`gh pr view <PR> --json mergeable -q .mergeable` = `MERGEABLE`)
28
+ 6. ✅ GitHub 静态编译通过(`gh pr checks <PR>` 无 `failure`)
29
+ 7. ✅ PR 链接已发送给用户
30
+ 8. ✅ 发新版本(`./release.sh`)
31
+ - ⚠️ **循环 review 走完后,显式输出「✅ 循环 review 完成,进入发布收尾闭环」,然后逐项执行下面第 2~8 步,禁止在循环 review 结束后直接发链接/发版。** 若发现某一步没做(典型:忘了重新部署、复测是旧数据),必须停下来补齐再继续,禁止把「漏掉的第 3/4 步」带进交付。
32
+
33
+ ## 核心流程
34
+
35
+ ### 1. 确认循环 review 已完成
36
+
37
+ ```bash
38
+ gh pr view <PR> --json comments -q '.comments | length'
39
+ ```
40
+
41
+ 确认 `/loop-review` 已走到「无值得修的新问题」那一轮(或达到最大轮数由用户决定结束)。若循环 review 还没走完,**先补跑 `/loop-review`,不得跳过**。
42
+
43
+ ### 2. 确保最新代码已推送
44
+
45
+ 循环 review 期间若有改动,确保已提交并推送到 PR 分支。若 review 过程中没有新增 commit,且已满足「无值得修的新问题」,则跳过本步(不必为了触发而空 push)。
46
+
47
+ ```bash
48
+ git status && git push origin "$(git branch --show-current)"
49
+ ```
50
+
51
+ ### 3. 重新部署到测试环境(最容易漏的一步)
52
+
53
+ ⚠️ 必须触发 `/deploy-test` skill,**把「当前功能分支的最新代码」合并到 test 并部署**,确保测试的就是 review 之后的最终代码。
54
+
55
+ > 为什么必须重部署:循环 review 的每次修复都 push 了新 commit。如果不重部署,测试环境跑的还是 review 之前的旧代码,等于用旧代码验证新改动,结论无效。
56
+
57
+ ### 4. 测试环境复测 + 全程截图标注
58
+
59
+ - 在测试环境用**真实数据 + 真实交互**测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。
60
+ - 每步截图保留(遵循「⚠️ 截图规范」:真实视口 + `fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并用**坐标换算后的箭头标注**(走 `/screenshot-annotate` skill)关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
61
+ - 若本次改动是纯后端/基础设施,按「非 UI / 后端 / 基础设施改动的效果截图获取方法」把 curl 响应渲染成暗色终端 HTML 截图。
62
+
63
+ ### 5. 确认没问题才算完成
64
+
65
+ 测试通过、截图与箭头标注齐全、功能符合预期,才算真正完成。**禁止测试没跑、截图没标注就宣称完成。**
66
+
67
+ ### 6. 冲突检查 + 静态编译检查(三步收尾)
68
+
69
+ 用 `gh pr view <PR> --json mergeable -q .mergeable` 检查冲突(`CONFLICTING` 必须先解决),用 `gh pr checks <PR>` 检查 CI(有 `failure` 必须修复重推)。两者都通过后才发链接/发版。
70
+
71
+ ### 7. 发 PR 链接给用户
72
+
73
+ ```bash
74
+ gh pr view <PR> --json url -q .url
75
+ ```
76
+
77
+ 一般按下流程:循环 review 全部通过 → 部署测试环境复测 → 确认后**先发 PR 链接,再发版**。
78
+
79
+ ### 8. 发新版本
80
+
81
+ PR 合并后按项目发布流程执行根目录 `./release.sh` 发新版本(自动完成 patch 号 +1、更新 `package.json`、`git commit`/`push`、打 tag、`npm publish`)。
82
+
83
+ ## 判断边界
84
+
85
+ - ⚠️ **如果循环 review 走完但没有重新部署测试就发链接/发版 → 直接违背闭环,必须停下并报告缺失项**(你没说过「跳过复测」,这属于该做而没做)。
86
+ - ⚠️ **用户明确说「不用部署测试」「跳过复测,直接发版」等显式指令** → 按用户指令执行,不必拦(但要在交付时说明「本次跳过了测试环境复测」)。
87
+ - ⚠️ **纯文档/无逻辑改动(如只改 README)** → 可以跳过复测,但发链接/发版前仍要完成冲突+编译检查。
88
+
89
+ ## 相关
90
+
91
+ - 循环 review 细节:`loop-review` skill
92
+ - 部署测试环境:`deploy-test` skill
93
+ - 创建 PR / 截图上传:`create-pr` skill
94
+ - 验证证据产出:`visual-report` skill
@@ -0,0 +1,116 @@
1
+ ---
2
+ name: screenshot-annotate
3
+ description: >-
4
+ 截图标注的标准化流程。触发场景包括但不限于:
5
+ 「给截图加标注」「截图标注」「标注截图的箭头」「给截图画箭头」「截图圈出来」「标注哪个区域」「标注关键区域」
6
+ 「加箭头和文字」「给这张截图标注一下」「用箭头标注截图」「截图上的红框指着哪」等任何「对截图做标注/圈选/加箭头文字」的说法。
7
+ 当截图规范要求「箭头标注」但需要保证「标注得准」(坐标换算、不靠肉眼估)时,也自动使用本 Skill。
8
+ 核心:坐标必须来自浏览器 DOM 测量(getBoundingClientRect + 滚动量)精确换算到截图像素,再用统一脚本标注,
9
+ 禁止肉眼看图估坐标。
10
+ ---
11
+
12
+ # 截图标注标准化流程(Screenshot Annotate)
13
+
14
+ ⚠️ **本 Skill 已触发。在用户回复中第一句话必须输出:「🔧 已触发 `screenshot-annotate`,按坐标换算流程标注截图…」然后严格按照以下步骤执行。**
15
+
16
+ ## 核心原则
17
+
18
+ - **标注必须准时归一到「坐标换算」**:箭头/框/文字放哪,由浏览器告诉我们的元素坐标(`getBoundingClientRect()` + `scrollX/scrollY`)经公式换算成截图里的像素位置;**禁止肉眼看图估坐标**。坐标对不上 = 标注就是歪的,这是以前「强制 4K 时标注不准」的根因。
19
+ - **坐标必须是页面 CSS 坐标或浏览器直接给的缩放信息**,不能是截图里目测出来的点。
20
+
21
+ ## 标注坐标换算方法
22
+
23
+ ### 情形 A:真实视口 + `fullPage` 全页截图(推荐做法)
24
+
25
+ 浏览器 `window.innerWidth` 就是截图宽度(设了截图画布时也一样)。对全页截图:
26
+
27
+ ```
28
+ 元素在「整页」中的 CSS 位置 = rect.left + scrollX
29
+ rect.top + scrollY
30
+ 截图里对应像素位置 = 该 CSS 位置(fullPage 拼接时 1 CSS px = 1 设备像素) x 截图宽度 / 整页宽
31
+ ```
32
+
33
+ 一个 CSS 像素等于一个设备像素的前提下,`fullPage` 拼接图的坐标与 CSS 坐标直接对应,**无需再缩放**:
34
+ 标注时给 annotate.js 传 `x = rect.left + scrollX`、`y = rect.top + scrollY`,宽度 `w = rect.width`、高度 `h = rect.height`。
35
+
36
+ ### 情形 B:设置了自定义 viewport 视口的截图(截图宽度 = viewport 宽)
37
+
38
+ 浏览器把 viewport 设成 X×Y,截图宽高 = X×Y(`window.innerWidth` / `innerHeight`)。元素坐标直接用 `rect` 的值即可。
39
+
40
+ ### 情形 C:一个普通(非拼接、无缩放)截图
41
+
42
+ 如果截图就是某视口的渲染结果,且截图宽 ≠ CSS 视口宽,说明有 `devicePixelRatio` 缩放。
43
+ 此时标注坐标 = CSS 坐标 × (截图宽 / 视口 CSS 宽)(横向),(截图高 / 视口 CSS 高)(纵向)。
44
+
45
+ ## 坐标捕获命令(在打开的浏览器标签页上执行)
46
+
47
+ 用 `agent-browser --cdp 9226 --namespace <NS> eval` 捕获目标元素的精确坐标:
48
+
49
+ ```js
50
+ (function () {
51
+ const el = document.querySelector('选择器'); // 替换成真实目标元素的选择器
52
+ if (!el) return { error: '未找到元素', selector: '选择器' };
53
+ const r = el.getBoundingClientRect();
54
+ return {
55
+ x: Math.round(r.left + window.scrollX), // 整页内 CSS 像素
56
+ y: Math.round(r.top + window.scrollY),
57
+ w: Math.round(r.width),
58
+ h: Math.round(r.height),
59
+ url: location.href,
60
+ viewport: { w: window.innerWidth, h: window.innerHeight },
61
+ dpr: window.devicePixelRatio,
62
+ };
63
+ })()
64
+ ```
65
+
66
+ 上次截图若用了 `set viewport` 设视口,在 eval 里把 `window.innerWidth/Height` 作为 viewport 一并读出,确认截图宽高与之一致。
67
+
68
+ ## 统一标注脚本 annotate.js
69
+
70
+ 标注由 `annotate.js`(本 skill 目录内的零依赖 Node 脚本)完成。它把截图 + 坐标转成自包含标注 HTML(截图 base64 内嵌、SVG 箭头/框/文字精确放在换算后的坐标上),再用无头浏览器渲染为 PNG。
71
+
72
+ 脚本与 SKILL.md 位于同一 skill 目录(随 agent-rules 同步到各项目 `.claude/skills/screenshot-annotate/`)。调用时定位到该目录:
73
+
74
+ ```bash
75
+ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 或显式 .claude/skills/screenshot-annotate 目录
76
+ node "$SKILL_DIR/annotate.js" \
77
+ shot.png shot-annot.html \
78
+ --json '[{"x":412,"y":1240,"w":160,"h":48,"text":"新增的保存按钮","color":"#22c55e"}]' \
79
+ --label "✅ 修复后" --labelColor "#f43f5e" --url "https://api-test.xxx/gateway/dashboard"
80
+ ```
81
+
82
+ - 坐标默认视为**设备像素**(截图内坐标);`--viewport "cssW,cssH"` 传入时视为 CSS 坐标会自动换算
83
+ - `--label` 顶部标题条(适合「修复前/修复后」对比)、`--url` 在标题条显示 URL 满足「URL 可见」
84
+ - `text` 文本框自动排布在目标旁空闲侧,不遮挡内容;`w/h` 存在时画高亮圆角框指向其中心
85
+
86
+ ## 渲染标注 HTML 为 PNG(固定流程)
87
+
88
+ 用 `SKILL_DIR/annotate.js` 生成标注 HTML 后,用无头浏览器渲染为 PNG。
89
+
90
+ ```bash
91
+ NS="claude-截图标注"
92
+ # 1. 无头打开标注 HTML
93
+ agent-browser --cdp 9226 --namespace "$NS" tab new "file:///<上一步生成的绝对路径>/shot-annot.html"
94
+ # 2. 设视口为标注画布尺寸(annotate.js 会打印「画布尺寸 WxH」)
95
+ agent-browser --cdp 9226 --namespace "$NS" set viewport <W> <H>
96
+ # 3. 全页截图
97
+ agent-browser --cdp 9226 --namespace "$NS" screenshot --full shot-annot.png
98
+ # 4. 关闭标签页
99
+ agent-browser --cdp 9226 --namespace "$NS" tab close <tabId>
100
+ ```
101
+
102
+ 确认渲染图尺寸与画布一致、无黑边(`sips -g pixelWidth -g pixelHeight`),标注清晰后交付。
103
+
104
+ ## 校验
105
+
106
+ - 箭头尖端指向的元素,肉眼应与 eval 捕获到的目标一致(坐标没歪)
107
+ - 文本框不遮挡关键内容
108
+ - 标注入口处能确认 URL(标题条或截图内地址栏)
109
+ - 修改坐标/文字后重新跑脚本 + 渲染,不要在原图上手工补。
110
+
111
+ ## 重要规则
112
+
113
+ - ⚠️ 坐标必须有浏览器测量来源,禁止目测
114
+ - ⚠️ 每种「关键区域」都要箭头/框标注到位,禁止只标一处;多个改动点逐个覆盖
115
+ - ⚠️ 若截图是真实视口截出的,尽量一图一文;整页图定位大区域,局部区域用放大图单独标注
116
+ - 渲染用无头实例(不抢焦点);如需给用户看效果用截图
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * annotate.js — 生成「标注 HTML」(零依赖,Node 内置模块实现)
4
+ *
5
+ * 作用:把一张截图 + 若干标注坐标,生成一个自包含的 HTML 文件——
6
+ * 截图 base64 内嵌为底图,SVG 在精确换算后的坐标上画
7
+ * 「箭头 + 文本框 + 圆角高亮框 + 顶部标题条」。
8
+ * 标注文字/箭头由浏览器引擎渲染(中文完美、抗锯齿),
9
+ * 再由 agent-browser 用无头 CDP 把 HTML 截成最终标注图。
10
+ *
11
+ * 为什么这样设计:
12
+ * 标注不准的根因是「坐标单位换算错位 + 人工肉眼看图估位」。
13
+ * 本脚本强制走「元素 CSS 坐标(getBoundingClientRect) + 滚动量 → 截图设备像素」的换算,
14
+ * 换算结果直接画在 SVG 上,全程不经过人手目测。
15
+ *
16
+ * 坐标换算规则:
17
+ * - 默认:--json 里的 x/y/w/h 视为「截图设备像素坐标」(截图像素)。
18
+ * 画布里实际位置 = margin + 给定值。
19
+ * - 传 --viewport "cssW,cssH":x/y/w/h 视为「CSS 视口坐标」,
20
+ * 脚本先按 (截图宽/cssW)、(截图高/cssH) 换算成设备像素,再画。
21
+ * - 全页拼接截图时,元素相对整页的左/顶 = rect.left + scrollX / rect.top + scrollY,
22
+ * 把这两个和作为 x/y 传入(这就是整页里的设备像素位置,无需 --viewport)。
23
+ *
24
+ * 用法:
25
+ * node annotate.js <image> <outHtml> \
26
+ * --json '[{"x":412,"y":1240,"w":160,"h":48,"text":"新增的保存按钮","color":"#22c55e"}]' \
27
+ * --label "✅ 修复后" [--labelColor "#f43f5e"] [--viewport "1440,900"] [--margin-frac 0.05]
28
+ *
29
+ * <image> 输入截图(PNG/JPG/WebP…,尺寸由 IHDR / JPEG SOF / sips 探测)
30
+ * <outHtml> 输出的标注 HTML 路径
31
+ * --json 标注数组 JSON,每项:{x,y[,w,h][,text][,color][,stroke-width]}
32
+ * - x/y:目标点(默认设备像素坐标;传 --viewport 时为 CSS 坐标)
33
+ * - w/h:目标区域宽高(有则画高亮圆角框,箭头指向其中心)
34
+ * - text:文本框文字(浏览器渲染中文,自动排布在目标旁空闲侧)
35
+ * - color:主色(箭头/框/文本描边),缺省 #f43f5e
36
+ * --label 顶部标题条文字(适合「修复前/修复后」对比图),可省略
37
+ * --labelColor 标题条主题色,缺省 #f43f5e
38
+ * --viewport "cssW,cssH" 指定后坐标视为 CSS 视口坐标,自动换算
39
+ * --margin-frac 画布四周留白相对图片长边的比例(给箭头腾空间),缺省 0.05
40
+ * --url 可选:在标题条右侧显示页面 URL(用于「URL 可见」证据要求)
41
+ *
42
+ * 渲染为 PNG 的固定流程(配合 screenshot-annotate skill):
43
+ * 1. 生成标注 HTML: node annotate.js shot.png shot-annot.html --json '...' --label '...'
44
+ * 2. 无头打开并设视口为画布尺寸(画布比图片多四周留白):
45
+ * AGENT_BROWSER --cdp 9226 --namespace <ns> tab new "file://<abs>/shot-annot.html"
46
+ * AGENT_BROWSER --cdp 9226 --namespace <ns> set viewport <W> <H>
47
+ * 3. 截图: AGENT_BROWSER --cdp 9226 --namespace <ns> screenshot --full shot-annot.png
48
+ * 4. 关闭标签页: AGENT_BROWSER --cdp 9226 --namespace <ns> tab close <tabId>
49
+ * (W/H = annotate.js 打印的「画布尺寸」,即图片宽/高 + 2×margin)
50
+ * 本脚本与 SKILL.md 同目录(随 agent-rules 同步到各项目 .claude/skills/screenshot-annotate/)。
51
+ */
52
+ "use strict";
53
+
54
+ const fs = require("fs");
55
+ const path = require("path");
56
+ const { execFileSync } = require("child_process");
57
+
58
+ // ---------------------------------------------------------------- 参数解析
59
+ function parseArgs(argv) {
60
+ const positionals = [];
61
+ const keyed = {};
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (a.startsWith("--")) {
65
+ const key = a.slice(2);
66
+ const next = argv[i + 1];
67
+ if (next !== undefined && !next.startsWith("--")) {
68
+ keyed[key] = next;
69
+ i++;
70
+ } else {
71
+ keyed[key] = true;
72
+ }
73
+ } else {
74
+ positionals.push(a);
75
+ }
76
+ }
77
+ return { positionals, keyed };
78
+ }
79
+
80
+ function num(v, dflt) {
81
+ if (v === undefined || v === true || v === null || v === "") return dflt;
82
+ const n = Number(v);
83
+ return Number.isFinite(n) ? n : dflt;
84
+ }
85
+
86
+ // ---------------------------------------------------------------- 图片尺寸探测
87
+ function pngSize(buf) {
88
+ // 8 字节签名 + IHDR chunk(长度4 + 'IHDR' 4 + data 13)
89
+ if (
90
+ buf.length >= 24 &&
91
+ buf.readUInt32BE(0) === 0x89504e47 &&
92
+ buf.toString("ascii", 12, 16) === "IHDR"
93
+ ) {
94
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function jpegSize(buf) {
100
+ if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
101
+ let pos = 2;
102
+ while (pos + 9 < buf.length) {
103
+ if (buf[pos] !== 0xff) {
104
+ pos++;
105
+ continue;
106
+ }
107
+ const marker = buf[pos + 1];
108
+ // SOF0..15(除 DHT C4、JPG C8、DAC CC、RST0-7 D0-D7、DNL DC、SOS DA、DRI DD、APP0-EF E0-EF、COM FE)
109
+ if (
110
+ marker >= 0xc0 &&
111
+ marker <= 0xcf &&
112
+ marker !== 0xc4 &&
113
+ marker !== 0xc8 &&
114
+ marker !== 0xcc &&
115
+ (marker < 0xd0 || marker > 0xd7) &&
116
+ marker !== 0xda &&
117
+ marker !== 0xdb &&
118
+ marker !== 0xdc
119
+ ) {
120
+ const segLen = buf.readUInt16BE(pos + 2);
121
+ const height = buf.readUInt16BE(pos + 5);
122
+ const width = buf.readUInt16BE(pos + 7);
123
+ return { width, height };
124
+ }
125
+ const segLen = buf.readUInt16BE(pos + 2);
126
+ if (segLen < 2) return null;
127
+ pos += 2 + segLen;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function sipsSize(filePath) {
133
+ try {
134
+ const out = execFileSync("sips", ["-g", "pixelWidth", "-g", "pixelHeight", filePath], {
135
+ encoding: "utf-8",
136
+ });
137
+ const w = Number((out.match(/pixelWidth:\s*(\d+)/) || [])[1]);
138
+ const h = Number((out.match(/pixelHeight:\s*(\d+)/) || [])[1]);
139
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
140
+ return { width: w, height: h };
141
+ }
142
+ } catch (e) {
143
+ /* fallthrough */
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function detectSize(filePath, buf) {
149
+ return pngSize(buf) || jpegSize(buf) || sipsSize(filePath);
150
+ }
151
+
152
+ // ---------------------------------------------------------------- HTML 安全
153
+ function escapeHtml(s) {
154
+ return String(s)
155
+ .replace(/&/g, "&amp;")
156
+ .replace(/</g, "&lt;")
157
+ .replace(/>/g, "&gt;")
158
+ .replace(/"/g, "&quot;")
159
+ .replace(/'/g, "&#39;");
160
+ }
161
+ function extMime(filePath) {
162
+ const ext = path.extname(filePath).toLowerCase();
163
+ const map = {
164
+ ".png": "image/png",
165
+ ".jpg": "image/jpeg",
166
+ ".jpeg": "image/jpeg",
167
+ ".gif": "image/gif",
168
+ ".webp": "image/webp",
169
+ ".svg": "image/svg+xml",
170
+ ".bmp": "image/bmp",
171
+ };
172
+ return map[ext] || "image/png";
173
+ }
174
+
175
+ // ---------------------------------------------------------------- 布局工具(生成 SVG 常量)
176
+ // 文字排版:根据中文字符数近似估算宽度(一个汉字≈1.0em,拉丁≈0.55em)
177
+ function estimateTextMetrics(text, fontSize) {
178
+ const chars = [...text];
179
+ let w = 0;
180
+ for (const ch of chars) {
181
+ const code = ch.codePointAt(0);
182
+ const width = code > 0x2e80 ? fontSize : fontSize * 0.55;
183
+ w += width;
184
+ }
185
+ return { width: Math.ceil(w) + 2 * fontSize, height: Math.ceil(fontSize * 1.7) };
186
+ }
187
+
188
+ // ---------------------------------------------------------------- 生成标注 HTML
189
+ function buildHtml({
190
+ imagePath,
191
+ imageMime,
192
+ imageW,
193
+ imageH,
194
+ annotations,
195
+ label,
196
+ labelColor,
197
+ url,
198
+ marginFrac,
199
+ }) {
200
+ const margin = Math.max(24, Math.round(Math.max(imageW, imageH) * marginFrac));
201
+ const W = imageW + margin * 2;
202
+ const H = imageH + margin * 2;
203
+ const imgData = fs.readFileSync(imagePath).toString("base64");
204
+ const dataUri = `data:${imageMime};base64,${imgData}`;
205
+
206
+ // 预处理标注:换算坐标(CSS→设备像素若给 viewport)、排版文本
207
+ const placed = [];
208
+ for (const a of annotations) {
209
+ const color = a.color || "#f43f5e";
210
+ const lw = num(a["stroke-width"], Math.max(3, Math.round(W / 900)));
211
+ // 换算已在 main() 中完成(CSS 坐标 → 设备像素)
212
+ const x = num(a.x, 0);
213
+ const y = num(a.y, 0);
214
+ const w = num(a.w, 0);
215
+ const h = num(a.h, 0);
216
+ placed.push({ ...a, x, y, w, h, color, lw });
217
+ }
218
+
219
+ // 文本距离目标框
220
+ const gap = Math.max(30, Math.round(Math.min(imageW, imageH) * 0.03));
221
+
222
+ // 生成每条标注的 SVG 片段
223
+ const parts = [];
224
+ for (const a of placed) {
225
+ // 目标中心
226
+ const tx = a.x + a.w / 2;
227
+ const ty = a.y + a.h / 2;
228
+ const fs = Math.max(16, Math.round(imageW / 90));
229
+ const metrics = a.text ? estimateTextMetrics(a.text, fs) : { width: 0, height: 0 };
230
+ const boxW = Math.ceil((metrics.width || 0) + fs * 1.2);
231
+ const boxH = Math.ceil(metrics.height + fs * 0.6);
232
+ const pad = 8;
233
+ const bw = a.text ? boxW : 0;
234
+ const bh = a.text ? boxH : 0;
235
+
236
+ // 文本候选方位(上、下、左、右),取第一个不越界且不遮目标的
237
+ const cands = [
238
+ { bx: tx - bw / 2, by: ty - gap - bh, name: "up" },
239
+ { bx: tx - bw / 2, by: ty + gap, name: "down" },
240
+ { bx: tx - gap - bw, by: ty - bh / 2, name: "left" },
241
+ { bx: tx + gap, by: ty - bh / 2, name: "right" },
242
+ ];
243
+ let slot = null;
244
+ for (const c of cands) {
245
+ const inX = c.bx - pad > margin && c.bx + bw + pad < margin + imageW;
246
+ const inY = c.by - pad > margin && c.by + bh + pad < margin + imageH;
247
+ let overlap = false;
248
+ if (a.w > 0 && a.h > 0) {
249
+ overlap = rectsOverlap(c.bx, c.by, bw, bh, a.x, a.y, a.w, a.h);
250
+ }
251
+ if (inX && inY && !overlap) {
252
+ slot = c;
253
+ break;
254
+ }
255
+ }
256
+ if (!slot) slot = cands[0];
257
+
258
+ const hasTarget = a.w > 0 && a.h > 0;
259
+ const hasText = Boolean(a.text);
260
+
261
+ // 高亮目标框
262
+ if (hasTarget) {
263
+ const rx = Math.max(8, a.lw * 1.8);
264
+ parts.push(`
265
+ <rect x="${a.x}" y="${a.y}" width="${a.w}" height="${a.h}" rx="${rx}" ry="${rx}"
266
+ fill="${a.color}" fill-opacity="0.14" stroke="${a.color}" stroke-width="${a.lw}"
267
+ vector-effect="non-scaling-stroke" />`);
268
+ }
269
+
270
+ // 文本框
271
+ if (hasText) {
272
+ const rx = Math.max(6, a.lw * 1.5);
273
+ parts.push(`
274
+ <rect x="${slot.bx}" y="${slot.by}" width="${bw}" height="${bh}" rx="${rx}" ry="${rx}"
275
+ fill="#ffffff" fill-opacity="0.92" stroke="${a.color}" stroke-width="${Math.max(
276
+ 1,
277
+ Math.round(a.lw * 0.8),
278
+ )}"
279
+ vector-effect="non-scaling-stroke" />`);
280
+ parts.push(`
281
+ <text x="${slot.bx + bw / 2}" y="${slot.by + bh / 2}" font-family="system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif"
282
+ font-size="${fs}" font-weight="600" text-anchor="middle" dominant-baseline="central"
283
+ fill="${textContrast(a.color)}">${escapeHtml(a.text)}</text>`);
284
+ }
285
+
286
+ // 箭头:文本框中心 → 目标中心
287
+ const sx = slot.bx + bw / 2;
288
+ const sy = slot.by + bh / 2;
289
+ const dx = tx - sx;
290
+ const dy = ty - sy;
291
+ const len = Math.sqrt(dx * dx + dy * dy);
292
+ if (len > 1) {
293
+ // 起点避开文本框(沿方向外移半框)
294
+ const star = Math.min(1, Math.max(0, (bw * 0.5 + a.lw * 2) / len));
295
+ const ex = (star * dx) / len;
296
+ const ey = (star * dy) / len;
297
+ const sx2 = sx + ex * len;
298
+ const sy2 = sy + ey * len;
299
+ const arrowLen = Math.max(12, a.lw * 3.2);
300
+ const ang = Math.atan2(ty - sy, tx - sx);
301
+ const a1 = ang + Math.PI / 6;
302
+ const a2 = ang - Math.PI / 6;
303
+ parts.push(`
304
+ <line x1="${sx2}" y1="${sy2}" x2="${tx}" y2="${ty}" stroke="${a.color}"
305
+ stroke-width="${a.lw}" stroke-linecap="round" vector-effect="non-scaling-stroke" />`);
306
+ parts.push(`
307
+ <path d="M ${tx} ${ty} L ${tx - arrowLen * Math.cos(a1)} ${ty - arrowLen * Math.sin(a1)} L ${
308
+ tx - arrowLen * Math.cos(a2)
309
+ } ${ty - arrowLen * Math.sin(a2)} Z"
310
+ fill="${a.color}" />`);
311
+ }
312
+ }
313
+
314
+ // 标题条
315
+ let bar = "";
316
+ if (label) {
317
+ const barH = Math.max(40, Math.round(imageH * 0.05));
318
+ const fs = Math.max(20, Math.round(imageW / 55));
319
+ const lc = /^#[0-9a-fA-F]{6}$/.test(labelColor) ? labelColor : "#f43f5e";
320
+ const urlText = url ? escapeHtml(url) : "";
321
+ bar = `
322
+ <rect x="${margin}" y="${margin}" width="${imageW}" height="${barH}" rx="10" ry="10"
323
+ fill="${lc}" fill-opacity="0.9" />
324
+ <text x="${margin + imageW / 2}" y="${margin + barH / 2}" font-family="system-ui, sans-serif"
325
+ font-size="${fs}" font-weight="700" text-anchor="middle" dominant-baseline="central"
326
+ fill="#ffffff">${escapeHtml(label)}</text>
327
+ ${
328
+ urlText
329
+ ? `<text x="${margin + imageW - 14}" y="${margin + barH / 2}" font-family="ui-monospace, Menlo, monospace"
330
+ font-size="${Math.round(fs * 0.55)}" text-anchor="end" dominant-baseline="central"
331
+ fill="#ffffff" fill-opacity="0.92">${urlText}</text>`
332
+ : ""
333
+ }`;
334
+ }
335
+
336
+ return `<!doctype html>
337
+ <html>
338
+ <head>
339
+ <meta charset="utf-8" />
340
+ <title>标注</title>
341
+ <style>
342
+ html, body { margin:0; padding:0; overflow:hidden; background:#000; }
343
+ .stage { position:relative; width:${W}px; height:${H}px; }
344
+ .stage img { position:absolute; left:${margin}px; top:${margin}px; width:${imageW}px; height:${imageH}px; }
345
+ .stage svg { position:absolute; left:0; top:0; width:${W}px; height:${H}px; }
346
+ </style>
347
+ </head>
348
+ <body>
349
+ <div class="stage">
350
+ <img src="${dataUri}" />
351
+ <svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
352
+ ${bar}
353
+ ${parts.join("\n")}
354
+ </svg>
355
+ </div>
356
+ </body>
357
+ </html>`;
358
+
359
+ function rectsOverlap(x1, y1, w1, h1, x2, y2, w2, h2) {
360
+ return !(x1 + w1 < x2 || x2 + w2 < x1 || y1 + h1 < y2 || y2 + h2 < y1);
361
+ }
362
+ function textContrast(colorHex) {
363
+ const c = colorHex.replace("#", "");
364
+ const r = parseInt(c.slice(0, 2), 16) || 0;
365
+ const g = parseInt(c.slice(2, 4), 16) || 0;
366
+ const b = parseInt(c.slice(4, 6), 16) || 0;
367
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 140 ? "#1a1a1a" : "#ffffff";
368
+ }
369
+ }
370
+
371
+ // ---------------------------------------------------------------- 主流程
372
+ function main() {
373
+ const { positionals, keyed } = parseArgs(process.argv.slice(2));
374
+ const [imagePath, outHtml] = positionals;
375
+ if (!imagePath || !outHtml) {
376
+ console.error(
377
+ "用法: node annotate.js <image> <outHtml> [--json \"...\"] [--label \"...\"] [--labelColor \"#fff\"] [--viewport \"cssW,cssH\"] [--margin-frac N] [--url \"...\"]",
378
+ );
379
+ process.exit(1);
380
+ }
381
+
382
+ if (!fs.existsSync(imagePath)) {
383
+ console.error(`❌ 图片不存在: ${imagePath}`);
384
+ process.exit(1);
385
+ }
386
+ const buf = fs.readFileSync(imagePath);
387
+ const size = detectSize(imagePath, buf);
388
+ if (!size) {
389
+ console.error(`❌ 无法探测图片尺寸(需 IHDR / JPEG SOF / sips): ${imagePath}`);
390
+ process.exit(1);
391
+ }
392
+ const imageW = size.width;
393
+ const imageH = size.height;
394
+
395
+ let annotations = [];
396
+ if (keyed.json) {
397
+ try {
398
+ annotations = JSON.parse(keyed.json);
399
+ } catch (e) {
400
+ console.error("❌ --json 解析失败:", e.message);
401
+ process.exit(1);
402
+ }
403
+ if (!Array.isArray(annotations)) {
404
+ console.error("❌ --json 必须是标注数组");
405
+ process.exit(1);
406
+ }
407
+ }
408
+
409
+ // 坐标换算:CSS 坐标 → 设备像素
410
+ let viewport = null;
411
+ if (keyed.viewport && keyed.viewport !== true) {
412
+ const [vw, vh] = String(keyed.viewport).split(",").map(Number);
413
+ if (Number.isFinite(vw) && Number.isFinite(vh) && vw > 0 && vh > 0) {
414
+ viewport = { w: vw, h: vh };
415
+ }
416
+ }
417
+ const scaleX = viewport ? imageW / viewport.w : 1;
418
+ const scaleY = viewport ? imageH / viewport.h : 1;
419
+ annotations = annotations.map((a) => ({
420
+ ...a,
421
+ x: num(a.x, 0) * scaleX,
422
+ y: num(a.y, 0) * scaleY,
423
+ w: num(a.w, 0) * scaleX,
424
+ h: num(a.h, 0) * scaleY,
425
+ }));
426
+
427
+ const marginFrac = num(keyed["margin-frac"], 0.05);
428
+ const html = buildHtml({
429
+ imagePath,
430
+ imageMime: extMime(imagePath),
431
+ imageW,
432
+ imageH,
433
+ annotations,
434
+ label: keyed.label || "",
435
+ labelColor: keyed.labelColor || "#f43f5e",
436
+ url: keyed.url || "",
437
+ marginFrac,
438
+ });
439
+
440
+ fs.writeFileSync(outHtml, html);
441
+ const margin = Math.max(24, Math.round(Math.max(imageW, imageH) * marginFrac));
442
+ console.log(
443
+ `✅ 已生成标注 HTML → ${outHtml}`,
444
+ );
445
+ console.log(` 画布尺寸 ${imageW + margin * 2}×${imageH + margin * 2}(视口截图时用)`);
446
+ }
447
+
448
+ main();
@@ -32,7 +32,7 @@ description: >-
32
32
 
33
33
  | 改动类型 | 证据来源 | 具体动作 |
34
34
  |------|------|------|
35
- | 页面 / 界面改动 | 真实页面 | 打开真实页面,用真实数据走真实交互(点击、填写、提交、等待渲染),全页截图 + 箭头标注关键改动区域 |
35
+ | 页面 / 界面改动 | 真实页面 | 打开真实页面,用真实数据走真实交互(点击、填写、提交、等待渲染),全页截图 + 坐标换算后的箭头标注关键改动区域(/screenshot-annotate) |
36
36
  | Redis 相关 | Redis for VS Code 插件 | 查看运行时 Redis 的 key/value,A/B 对比改动前后的值(SET 已知值 → 触发业务动作 → 截图看值是否如预期) |
37
37
  | 数据库相关 | SQLTools 插件 | 查看运行时数据库的 row 数据,A/B 对比改动前后的值(插入/更新 → 触发业务动作 → 截图看落库是否如预期) |
38
38
  | 纯后端接口 / 定时任务无页面 | 运行时真实返回 | curl / 日志 / 查库 / 查 Redis,把结果渲染成可视化证据(暗色终端风格的请求/响应对比 HTML 截图,或 Redis/SQLTools 截图) |
@@ -53,7 +53,7 @@ description: >-
53
53
  2. **「为什么这样验证成立」原理说明卡**:先讲清楚 bug/功能差异的本质是哪一个动作,再论证「手动模拟该动作 = 真实场景」
54
54
  3. **A/B 对照表**:修复前 vs 修复后(或改动前 vs 改动后),代码行为 / 等价操作 / 观测结果三列并排
55
55
  4. **每步截图**:必须带箭头标注关键区域/验证点,图注写清「这张图证明了什么」,标注关键行/关键数据
56
- - 截图本身遵循「⚠️ 截图规范」:视口 3840px 宽、`fullPage` 全页、URL 可见、存到 `docs/` 对应子目录、文件名「编号 + 英文描述」。
56
+ - 截图本身遵循「⚠️ 截图规范」:真实视口 + `fullPage` 全页、URL 可见、存到 `docs/` 对应子目录、文件名「编号 + 英文描述」。截图上的箭头/框标注统一走 `/screenshot-annotate` skill(坐标精确换算,禁止肉眼估位)。
57
57
 
58
58
  ## 不写测试 ≠ 不查错
59
59