-

+

# Lime
@@ -10,6 +10,8 @@
AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.
+**简体中文** · [English](./README.en.md) · [文档](./docs/README.md) · [发布记录](./RELEASE_NOTES.md) · [问题反馈](https://github.com/limecloud/lime/issues)
+
@@ -23,6 +25,27 @@ AI content workspace for Chinese creators: desktop writing, research, prompt man
---
+
+目录
+
+- [Lime 是什么](#lime-是什么)
+- [你可以用 Lime 做什么](#你可以用-lime-做什么)
+- [几个创作者会遇到的真实时刻](#几个创作者会遇到的真实时刻)
+- [一次创作可以这样开始](#一次创作可以这样开始)
+- [核心工作流](#核心工作流)
+- [适合谁](#适合谁)
+- [如果你在找这些工具](#如果你在找这些工具)
+- [不太适合谁](#不太适合谁)
+- [快速开始](#快速开始)
+- [技术栈与平台](#技术栈与平台)
+- [常见问题](#常见问题)
+- [开源协议](#开源协议)
+- [免责声明](#免责声明)
+
+
+
+---
+
## Lime 是什么
Lime 是一个开源的 Tauri 桌面端 AI 内容工作台,面向中文创作者、品牌运营、研究型写作者和小团队,覆盖 AI 写作、选题研究、素材管理、提示词沉淀、知识库和多模型创作流程。
@@ -100,23 +123,23 @@ Lime 可以把稳定有效的做法沉淀成常用任务入口:下次新人写
---
-## 界面预览
+## 核心工作流
### 从一个任务开始
-
+
你可以从一句目标开始,把资料、模型、常用做法和最近结果放在同一个地方,不用先面对一整面复杂工具菜单。
### 在同一个创作空间里持续修改
-
+
生成、追问、修改、查找资料和整理结果都围绕当前任务展开。适合需要多轮打磨的文章、报告、脚本和方案。
### 使用自己的 AI 服务
-
+
Lime 本身不提供 AI 模型服务。你可以配置自己的 AI 服务商、服务商密钥和常用模型,让不同内容任务使用不同能力。
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index e57877a0a..84cb531e2 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,70 @@
+## Lime v1.33.0
+
+发布日期:`2026-05-11`
+递交范围:完整 dirty worktree,包含 tracked、deleted 与新增文件;`.lime/` 等本地运行态忽略目录不纳入发布提交。
+
+> 发布说明:上一版 release tag 为 `v1.32.0`。本版按用户要求升级到 `v1.33.0`,并把本轮待递交的全部源码、文档、脚本、测试与发布证据索引纳入 release notes。
+
+### 发布概览
+
+- 应用版本从 `1.32.0` 升级到 `1.33.0`,同步 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`、`packages/lime-cli-npm/package.json` 与 `@limecloud/lime-cli` 发布示例。
+- Agent QC 发布门禁补齐 scenario / GUI flow / Evidence Pack schema、qcloop job / status / preflight / export / release-summary / audit 脚本,以及 GitHub release workflow 的 P0 Evidence Pack 硬门禁。
+- Agent Runtime 与 Team / Workspace 主链补齐 approval sandbox、Claw chat ready streaming、tool surface、service skill entry、browser runtime、knowledge GUI 与 GUI owner 检查脚本,降低只靠浅层 smoke 误判的风险。
+- i18n 主链从 legacy DOM Patch 迁到 key-based resources:新增 `createI18n`、namespace loader、locale resources、格式化工具与覆盖测试,旧 patch 文件移入 `src/i18n/legacy-patch/` 并从 current 入口退场。
+- 设置 v2、侧边栏、Agent 输入栏、任务中心、Skills 页面与系统诊断页完成一批用户可见回归,覆盖账号、统计、用户中心、Provider、媒体能力、语音、快捷键、环境、渠道日志与工作区修复历史。
+- 对话功能 E2E 证据新增到 `output/dialogue-feature-e2e-20260510/`,覆盖 24 个对话 / 任务 / artifact / 搜索 / 多媒体场景截图、manifest 与审计 JSON。
+- 清理旧版本临时证据与旧实现残留:删除根目录旧 Playwright JSON、旧 qcloop 截图、旧 i18n patch 入口文件,避免 `v1.32.0` 以前的临时产物继续混入 release 范围。
+
+### 用户可见更新
+
+#### 1. Agent 对话、任务与工作台
+
+- Agent 输入栏、轻量预览、任务中心 tab、Team Workspace board、curated task reference selection 与 fast response model 增加更稳定的状态与测试覆盖。
+- Claw chat ready streaming smoke 固化为可复跑入口,覆盖 ready 状态、流式输出、cancel / provider fixed 场景与 GUI evidence 记录。
+- AppSidebar 会话 shelf 与会话格式化补齐测试,历史会话、队列状态和工作区入口展示更稳定。
+
+#### 2. 设置页与能力配置
+
+- 设置 v2 增强账号资料、统计、用户中心会话、Provider、媒体服务、图片 / 视频 / 语音能力、快捷键、开发者、环境和渠道日志页面。
+- Companion capability preferences、MediaPreferenceSection 与 Provider 页面补齐能力偏好和模型配置回归。
+- WorkspaceRepairHistoryCard、ChannelLogTailPanel 与渠道日志 filter 补齐可见状态和过滤测试。
+
+#### 3. 本地化与旧版本清理
+
+- current i18n resources 覆盖 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 的 `common`、`navigation`、`workspace`、`agent`、`settings`、`errors` namespace。
+- 新增缺失翻译检测、legacy patch metrics report 与 namespace load / locale / format / type 测试,避免新增用户可见文案回落到旧 DOM patch。
+- 旧 `I18nPatchProvider`、`dom-replacer`、`patches/*.json` 与 `text-map` 已从 current 位置删除,仅作为 `legacy-patch` 迁移目录保留。
+
+### 开发者与治理更新
+
+- Agent QC 文档新增 `docs/test/*.json`、`docs/tests/*`、completion audit、current blockers、evidence contract、qcloop operations、rollout plan 与 stale worker 分析,发布证据口径回到仓库内版本化事实源。
+- `npm run test:contracts` 增加 `agent-qc:check`,同时校验 Agent QC scenario manifest 与 GUI flow manifest。
+- `scripts/quality-task-planner`、`local-ci`、`verify-gui-smoke` 和各 smoke runner 同步 Agent QC / GUI 主路径任务选择。
+- `.github/workflows/release.yml` 和 nightly harness workflow 补齐 Agent QC 发布门禁,不再允许缺 P0 Evidence Pack 的 release note 进入绿色发布。
+- `legacySurfaceCatalog`、`agentCommandCatalog`、DevBridge dispatcher、mockPriorityCommands 与 tauri mock 同步 runtime / tool / QC 新边界。
+- Rust 发布门禁收口三处稳定性问题:MCP tool search 精确 inner tool name 优先于后缀命中,Fal queue 超时测试禁用本机代理并加宽 client timeout,Agent 流式策略测试改用注入式内存 `SessionStore`,避免全量并发测试写全局 SQLite 时出现 I/O 抖动。
+
+### 已知边界
+
+- 官方 Agent QC Evidence Pack 仍必须由真实 qcloop 8/8 P0 pass 后导出;历史 sidecar、source-tree smoke 或 `.lime/qc` 本地运行态文件不能替代发布证据。
+- `output/dialogue-feature-e2e-20260510/` 是本轮对话功能 E2E 证据,不等同于 installer artifact 验证。
+- release workflow 已具备硬门禁;GitHub Release artifact 仍由 tag 推送后的发布流水线生成。
+
+### 校验状态
+
+- `npm run verify:app-version`:通过,版本一致性为 `1.33.0`。
+- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。
+- `cargo test --manifest-path "src-tauri/Cargo.toml" --workspace --locked`:通过;使用 `CARGO_HOME=/tmp/lime-cargo-home-v133` 与 `CARGO_TARGET_DIR=src-tauri/target-release-v133-temp-home` 避开本机全局 Cargo 缓存损坏。
+- `npm run lint:rust`:通过;已修复 `lime-media-runtime` 大 Err variant clippy warning,并在后续 MCP / server / agent 测试稳定性修复后复跑通过。
+- `npm run lint`:通过。
+- `npm test`:通过,`54/54` 批退出码为 `0`。
+- `npm run test:contracts`:通过,覆盖 command / harness / modality / harness cleanup / agent-qc scenario 与 GUI flow manifest contract。
+- `git diff --check`:通过。
+
+---
+
+**完整变更**: `v1.32.0` -> `v1.33.0`
+
## Lime v1.32.0
发布日期:`2026-05-10`
diff --git a/docs/README.md b/docs/README.md
index 5ce6319ff..cdc5b26c0 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -37,6 +37,15 @@
- `develop/execution-tracker-p1-p2-roadmap.md`:统一执行追踪后续路线(P1/P2)
- `tech/harness/README.md`:Lime Harness Engineering 总入口
- `tech/harness/implementation-blueprint.md`:Lime Harness 分阶段实施蓝图
+- `tests/agent-ops-qc.md`:Agent 运营级测试体系,定义 qcloop 场景、Evidence Pack 与发布门禁
+- `tests/agent-qc-p0-scenarios.md`:Agent QC P0 场景执行手册,定义核心场景证据与失败沉淀规则
+- `tests/lime-agent-qc-rollout-plan.md`:Lime 样本产品的 Agent 运营级测试分阶段落地计划
+- `../scripts/agent-qc-report.mjs`:Agent QC 场景 manifest 报告与合同检查入口
+- `../scripts/agent-qc-gui-flow-report.mjs`:Agent QC GUI / Playwright MCP flow manifest 报告与合同检查入口
+- `../scripts/agent-qc-qcloop-job.mjs`:从 Agent QC manifest 生成 qcloop job payload 的入口
+- `../scripts/agent-qc-export-evidence.mjs`:qcloop job 到 Agent QC Evidence Pack 的导出入口
+- `../scripts/agent-qc-release-summary.mjs`:将 Agent QC Evidence Pack 与 Harness 报告汇总为 release note 质量证据
+- `../scripts/agent-qc-completion-audit.mjs`:Agent QC 整体目标完成度审计入口
- `aiprompts/query-loop.md`:运行时 Query Loop current 主链与提交边界
- `aiprompts/prompt-foundation.md`:基础 Prompt current 主链、system prompt 组装顺序与 current/compat 边界
- `aiprompts/task-agent-taxonomy.md`:Task / Agent / Coordinator current taxonomy 与边界归属
diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md
index 4ab2db11c..325456698 100644
--- a/docs/aiprompts/README.md
+++ b/docs/aiprompts/README.md
@@ -26,6 +26,9 @@
- `governance.md` - 新旧并存治理、迁移收口、禁止回流
- `harness-engine-governance.md` - Harness Engine 事实源、evidence pack、replay / analysis / review 治理规范
- `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁
+- `../tests/agent-ops-qc.md` - Agent 运营级测试体系、qcloop 场景、Evidence Pack 与发布证据门禁
+- `../tests/agent-qc-p0-scenarios.md` - Agent QC P0 场景执行手册、GUI/runtime 证据要求与失败沉淀规则
+- `../tests/lime-agent-qc-rollout-plan.md` - Lime 样本产品的 Agent 运营级测试落地计划
- `command-runtime.md` - `@` / `/` / 轻卡 / viewer / 功能方案包实施手册
- `skill-standard.md` - 统一技能标准、skill / adapter / runtime binding 边界
- `site-adapter-standard.md` - 站点适配器标准、来源导入边界、运行时收敛规则
diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md
index 54cfeea2e..22e97633f 100644
--- a/docs/aiprompts/commands.md
+++ b/docs/aiprompts/commands.md
@@ -249,7 +249,7 @@ Skill 执行链路同样遵循单一命令边界。当前前端入口为 `src/li
`Claw` 的纯文本封面命令也应沿同一条 current 主链收敛:
-- Agent 驱动的封面命令:`@封面` / `@cover` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `cover_task` 写入 `request_metadata.harness.cover_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(cover_generate)` 的系统提示;当前封面 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型在 `@封面` 首刀前先去搜索工具目录。后续默认 skill 必须先进入 `cover_generate` 的 current binding;若当前 binding family 被注册为 `typed local_cli`,则由 runtime 结构化组装 `Lime CLI` 封面命令(例如 `lime media cover generate` 或等价 cover task CLI),CLI 不可用时再回退 `lime_create_cover_generation_task`。无论选择哪种 executor,最终都只允许落到标准 `cover_generate` task file。
+- Agent 驱动的封面命令:`@封面` / `@cover` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `cover_task` 写入 `request_metadata.harness.cover_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs` 会把当前 turn 固定为 workbench chat mode,并注入只允许首刀优先调用 `Skill(cover_generate)` 的系统提示;当前封面 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Write / Edit / Bash / Glob / Grep / social_generate_cover_image / lime_create_image_generation_task` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型在 `@封面` 首刀前先去搜索工具目录、生成 HTML/SVG 假封面,或把封面退回普通 image task。后续默认 skill 必须先进入 `cover_generate` 的 current binding,并通过 `lime_create_cover_generation_task` 回写标准 `cover_generate` task file;旧的自由 Bash / CLI 拼接只允许停留在人工 ops 或 compat 场景,不能再作为模型首发路径。
`Claw` 的纯文本海报命令也应沿同一条 current 主链收敛:
diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md
index a1cb71e13..4bcdbf4a6 100644
--- a/docs/aiprompts/playwright-e2e.md
+++ b/docs/aiprompts/playwright-e2e.md
@@ -230,7 +230,7 @@ npm run verify:gui-smoke -- --include-knowledge-product-e2e --reuse-running
1. 在 `Claw` 对话框输入 `@封面 小红书 标题: 春日咖啡快闪 风格: 清新插画, 1:1 春日咖啡市集封面`
2. 确认聊天区先进入 skill 执行态,并能看到 `cover_generate` 相关工具轨迹,而不是前端静默直接创建任务
-3. 确认 `@封面` 命中了 `cover_generate` 的 current binding;如当前 binding family 是 `typed local_cli`,确认工具标题与结果摘要对应 runtime 结构化组装的 `Lime CLI` 封面执行链;如当前 binding family 是原生结构化 binding,则确认仍回写同一条 `cover_generate` task file。无论哪种,都不应要求模型先写 Bash;CLI 不可用时,才允许回退 `lime_create_cover_generation_task`
+3. 确认 `@封面` 命中了 `cover_generate` 的 current binding,并沿 `Skill(cover_generate) -> lime_create_cover_generation_task -> 标准 cover_generate task file` 主链提交任务;不应要求模型先写 Bash、生成 HTML/SVG,或退回普通图片任务
4. 等待任务回流后,确认同一条结果只展示真实 task file 状态,不会额外再插一条前端本地伪造“封面已生成”
5. 如当前界面已暴露右侧查看区或任务卡,确认其状态与聊天轻卡一致,且任务类型显示为 `cover_generate` / 封面任务
6. 刷新页面或切换会话再返回原话题,确认最近封面任务仍可从 `.lime/tasks` 恢复
diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md
index 862d51c9b..b1cbd5bd8 100644
--- a/docs/aiprompts/quality-workflow.md
+++ b/docs/aiprompts/quality-workflow.md
@@ -24,6 +24,12 @@
- `docs/aiprompts/command-runtime.md`
+如果改动涉及 Agent 运营级测试、qcloop 批量质检、Evidence Pack 或发布证据门禁,先补读:
+
+- `docs/tests/agent-ops-qc.md`
+- `docs/tests/agent-qc-p0-scenarios.md`
+- `docs/tests/lime-agent-qc-rollout-plan.md`
+
## 交付定义
对 Lime 来说,“代码通过检查” 不等于 “产品可以交付”。
@@ -34,7 +40,8 @@
2. **边界变更已同步** - 命令、桥接、配置、版本等结构性改动完成成组更新
3. **GUI 主路径可运行** - 涉及 GUI 壳、Bridge、Workspace、主页面路径时,最小冒烟通过
4. **用户可见回归已补齐** - 用户可见 UI 改动有稳定断言或既有 snapshot 回归
-5. **文档与锁文件不掉队** - 相关文档、schema、锁文件与实际实现保持一致
+5. **本地化事实源正确** - 新增或改动的用户可见产品文案进入 key-based i18n resources,不依赖 legacy DOM Patch 兜底
+6. **文档与锁文件不掉队** - 相关文档、schema、锁文件与实际实现保持一致
## 路线图任务防跑偏
@@ -127,6 +134,9 @@
### 3. 用户可见 UI 改动必须补稳定回归
+- 新增功能的按钮、标题、空态、toast、confirm、prompt、placeholder、aria/title 与错误提示必须走 current i18n:`useTranslation(ns)` / `Trans` + `src/i18n/resources//.json`
+- legacy DOM Patch 只允许作为迁移期兜底,不允许成为新功能或新文案的本地化事实源;确需临时例外时,必须写入对应路线图或执行计划并说明退出条件
+- 动态用户可见文案使用 i18next interpolation / plural / context;日期、数字、相对时间、列表和排序优先复用 `src/i18n/format.ts`
- 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言
- 如果目标区域已有 snapshot / 结构化快照机制,沿用现有机制
- 不要因为“只是 UI”就跳过回归
@@ -234,7 +244,7 @@ node scripts/check-generated-slop-report.mjs --input ""
同时,`scripts/report-generated-slop.mjs`、`scripts/check-generated-slop-report.mjs`、`scripts/harness-eval-history-record.mjs`、`scripts/harness-eval-trend-report.mjs`、`scripts/lib/generated-slop-report-core.mjs`、`scripts/lib/harness-dashboard-core.mjs` 这条 harness cleanup/report 主链,在 `verify:local` 的 smart 模式里默认也按 bridge/contracts 风险处理。
本地 `verify:local` 输出里如果看到 `bridge 校验(harness cleanup contract)`,说明命中的就是这条 cleanup/report 契约门禁,而不是普通 DevBridge 变更。
-CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge_reasons`,并写入 `GITHUB_STEP_SUMMARY`,用于区分这次是 `harness_cleanup_contract`、`bridge_runtime`,还是 `workflow_full_suite` / `fallback_full_suite` 这类全量触发。
+CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge_reasons`,并写入 `GITHUB_STEP_SUMMARY`,用于区分这次是 `agent_qc_contract`、`harness_cleanup_contract`、`bridge_runtime`,还是 `workflow_full_suite` / `fallback_full_suite` 这类全量触发。
结果摘要默认按 `Scope / Required Gates / Notes / Recommended Next Action / Failure` 分段,优先让人一眼看清“为什么触发”“哪些门禁必跑”“最终为什么失败”,以及失败后本地最应该先跑哪条命令。
如果命中的是 `harness_cleanup_contract`,推荐动作应优先指向 `npm run harness:cleanup-report:check`,而不是只给一条泛化的 bridge 校验建议。
@@ -526,6 +536,45 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge
- 不要把所有页面默认都推进到重型 E2E
- 先跑最小 smoke,再决定是否需要完整交互验证
+### Layer 5:Agent QC 运营证据
+
+入口:
+
+```bash
+npm run agent-qc:report
+npm run agent-qc:gui-flow:report
+npm run agent-qc:check
+npm run agent-qc:qcloop-job -- --risk P0 --output "./.lime/qc/qcloop-p0-job.json" --check
+npm run agent-qc:export-evidence -- --job-id "" --output "./.lime/qc/agent-qc-evidence.json" --check
+npm run agent-qc:release-summary -- --evidence "./.lime/qc/agent-qc-evidence.json" --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" --require-risk P0 --tag "" --output "./.lime/qc/release-agent-qc.md" --check
+npm run agent-qc:audit
+```
+
+事实源:
+
+- `docs/tests/agent-ops-qc.md`
+- `docs/tests/agent-qc-p0-scenarios.md`
+- `docs/tests/lime-agent-qc-rollout-plan.md`
+- `docs/test/agent-qc-scenarios.manifest.json`
+- `docs/test/agent-qc-evidence.schema.json`
+- `docs/test/agent-qc-gui-flows.manifest.json`
+
+作用:
+
+- 把 Lime 的 Agent Runtime、GUI、行为评测和发布门禁收敛成可审计场景清单
+- 让 qcloop / CI / release workflow 共享同一份 evidence contract
+- 防止测试标准自身漂移,例如 scenario 引用了不存在的 npm script
+
+注意:
+
+- `agent-qc:check` 已进入 `npm run test:contracts`,改动 Agent QC manifest、GUI flow manifest 或 schema 时不能按普通 docs-only 跳过
+- Agent QC 不替代 `verify:local`、`verify:gui-smoke` 或 `harness:eval`;它负责把这些入口编排成运营级证据链
+- `agent-qc:qcloop-job` 只从 manifest 生成 qcloop payload,不启动 qcloop、不提交任务
+- `agent-qc:export-evidence` 只转换 qcloop job 结果,不会替你跑测试;如果 qcloop item 仍是 `pending/running`,导出的 verdict 必须是 `blocked`
+- `agent-qc:release-summary -- --check` 是 release note / 发布门禁前的证据聚合;缺 Evidence Pack、Evidence Pack 非 `pass`,或未覆盖全部 P0 scenario id 时应阻断发布
+- `.github/workflows/release.yml` 默认强制 `agent_qc_evidence_path` 通过 `agent-qc-release-summary --check` 和 P0 scenario 覆盖校验,否则不创建 release
+- `agent-qc:audit` 是完成度审计;真实 qcloop evidence、真实 GUI evidence 或 release hard gate 缺失时必须保持 `incomplete`
+
## 改动类型与最低门槛
| 改动类型 | 至少运行 | 额外要求 |
diff --git a/docs/images/readme-feature-provider-en.png b/docs/images/readme-feature-provider-en.png
new file mode 100644
index 000000000..5133e7e73
Binary files /dev/null and b/docs/images/readme-feature-provider-en.png differ
diff --git a/docs/images/readme-feature-provider.png b/docs/images/readme-feature-provider.png
new file mode 100644
index 000000000..efaaa83c3
Binary files /dev/null and b/docs/images/readme-feature-provider.png differ
diff --git a/docs/images/readme-feature-start-en.png b/docs/images/readme-feature-start-en.png
new file mode 100644
index 000000000..71c8f357a
Binary files /dev/null and b/docs/images/readme-feature-start-en.png differ
diff --git a/docs/images/readme-feature-start.png b/docs/images/readme-feature-start.png
new file mode 100644
index 000000000..ed49d3b2c
Binary files /dev/null and b/docs/images/readme-feature-start.png differ
diff --git a/docs/images/readme-feature-workspace-en.png b/docs/images/readme-feature-workspace-en.png
new file mode 100644
index 000000000..25e8878ad
Binary files /dev/null and b/docs/images/readme-feature-workspace-en.png differ
diff --git a/docs/images/readme-feature-workspace.png b/docs/images/readme-feature-workspace.png
new file mode 100644
index 000000000..570c9d5f5
Binary files /dev/null and b/docs/images/readme-feature-workspace.png differ
diff --git a/docs/images/readme-hero-en.png b/docs/images/readme-hero-en.png
new file mode 100644
index 000000000..7ba150ed3
Binary files /dev/null and b/docs/images/readme-hero-en.png differ
diff --git a/docs/images/readme-hero.png b/docs/images/readme-hero.png
new file mode 100644
index 000000000..27cc17910
Binary files /dev/null and b/docs/images/readme-hero.png differ
diff --git a/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg b/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg
index 083f7b929..5e4c32ead 100644
--- a/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg
+++ b/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg
@@ -2,134 +2,125 @@
-
-
+
+
-
-
+
+
+
+
+
+
-
-
-
+
+
+
- Lime 首响慢:真正卡在哪里?
- 结论:前端已经能很快进入“思考中”,剩下最难的是 Provider 首 token 返回;本轮对齐的是“不要让流式输出拖慢整棵消息树”。
+ Lime 首字 Token 慢点复查图
+ 2026-05-10 真实 GUI 复测:前端首响已经足够快,真正拖慢 TTFT 的是 WebSearch allowed 误入 FullRuntime 后的 MCP / 工具面预热。
-
- 一条消息从点击发送到可见输出,会经过 4 段
+
+ 点击发送到首字可见的实测链路
-
- 0 - 1s
- 前端首响
- 草稿卡、监听绑定、运行状态、
- “思考中”占位快速出现。
+
+ 50ms
+ 首页 pending
+ 草稿壳和占位态出现
+ assistantDraftPaint 88ms
-
+
-
- 3s - 13s+
- Provider 等待 / 思考
- 排队、路由、模型首 token 生成。
- 这是当前最难被前端直接消除的段。
+
+ 298ms
+ 运行态到达
+ firstEvent 297ms
+ submitAccepted 258ms
-
+
-
- ~172ms
- 首 token 到可见
- 前端收到第一个正文 delta 后,
- 很快 flush 到页面。
+
+ 6372ms
+ 提交准备阻塞
+ context7 MCP 启动约 3.6s
+ playwright MCP 启动约 2.5s
+ 旧逻辑:allowed search 也触发 FullRuntime
-
+
-
- 后续输出
- 小批次稳定显示
- 96ms 合并、换行即时、
- 积压 120 字即时追赶。
+
+ 3553ms
+ 模型 / policy 首字
+ headers 456ms
+ first provider msg 1139ms
-
- 这就是为什么你会感觉“卡住”:页面已进入处理态,但模型还没吐第一个正文 token;如果每个小 delta 都刷新完整消息树,后续还会抖。
+
-
- 参考对象为什么体感快?
+
+ 首正文到可见仅 130ms
-
- Codex
- 1. OutputItemAdded 先告诉 UI:
- “一个输出项开始了”。
- 2. OutputTextDelta 立即进流。
- 3. TUI 不逐字符重排历史:
- 换行边界提交,积压后 catch-up。
+ 总结果:firstThinkingDelta 9024ms,firstTextDelta 10656ms,firstTextPaint 10786ms。前端 paint 不是主瓶颈。
-
- Claude Code
- 1. stream_request_start 先切状态。
- 2. thinking / text / tool 各自 spinner。
- 3. streamingText 单独热路径,
- messages 走 deferred。
- 4. 只显示换行完成的流式文本。
+
+ 这次补查到的真实慢点
-
- Lime 这次已对齐的部分
- 状态先到,首字即时;后续正文改成 96ms 小批次 + 换行即时 + 120 字积压追赶。
- 目标是避免 MessageList 被每个小 delta 高频拖动重算。
+
+ 1. WebSearch allowed 被当成重型执行入口
+ 只是“允许联网”,不等于本轮必须启动完整 MCP 工具面。
-
- 现在的困难边界
+
+ 2. FullRuntime 无差别预热 enabled_lime MCP
+ 简单短问也会串行启动 context7 / playwright,直接吃掉 6s+。
-
- 前端能继续修
-
- 已做
- • 立即显示“思考中”
- • 默认隐藏 raw reasoning
- • 输出节奏更稳,不抖动
- • 过程文件卡与完成态一致
+
+ 3. 搜索开启时前端 Prompt 退出紧凑模式
+ 首轮输入体积变大,进一步放大 provider 首 token 等待。
-
- 前端不能硬修
-
- 仍困难
- • Provider 排队 / 限流
- • 模型首 token 生成时间
- • 模型权限 / 白名单错误
- • 快速路由与显示模型不一致
+
+ 本轮优化后的路由原则
-
-
+
+ Allowed Search:仍走 FastChat
+ WebSearch / WebFetch 原生工具保留,模型按需搜索,但不启动 MCP。
-
- 下一刀最有价值
- 把“Provider / 模型 / 路由层为什么还没吐首字”做成用户可理解诊断:
- 显示真实请求模型、慢等待原因、可自动回退选项,而不是在前端伪造进度。
+
+ Required Search:只保留搜索约束,不预热 MCP
+ 明确 required 时可等待 WebSearch preflight,但 context7 / playwright 不应前置。
- 图中样例来自 2026-05-09 本地 Playwright 复测:assistantDraftPaint 75ms,firstRuntimeStatus 927ms,firstTextDelta 13285ms,firstTextPaint 172ms。
+
+ Full Context:有真实技能 / 项目 / 浏览器需求才预热
+ service skill、图片任务、项目上下文、多代理等仍保持重型 runtime。
+
+
+ 慢点排序与预期收益
+ 优先级 1:MCP / tool surface startup 约 6.37s,本轮通过 search-only 快路径移除;优先级 2:provider / policy 首字约 3.55s,后续继续看模型路由、Prompt 体积和缓存;优先级 3:前端首字 paint 约 0.13s,暂非瓶颈。
+ 数据源:2026-05-10 Lime Playwright 真实复测 + Tauri TTFT 日志。优化提交后需继续用同一句短问复测 submit preparation 与 firstTextDelta。
+
+
+ 已复查:前端快,后端预热慢
diff --git a/docs/roadmap/agentui/lime-agentui-standard-alignment.md b/docs/roadmap/agentui/lime-agentui-standard-alignment.md
index 7e858adbe..ab8dea190 100644
--- a/docs/roadmap/agentui/lime-agentui-standard-alignment.md
+++ b/docs/roadmap/agentui/lime-agentui-standard-alignment.md
@@ -1,6 +1,6 @@
# Lime AgentUI 标准对齐与缺口追踪
-> 状态:v0.6.0 标准对齐追踪
+> 状态:v0.6.0 标准对齐完成;后续只跟踪 future boundary 与 v0.5 尾项增强
> 更新时间:2026-05-10
> 标准版本:Agent UI `v0.6.0`,commit `dcf4bc5`(`Release Agent UI v0.6.0 team workbench`)
> 范围:对齐 `/Users/coso/Documents/dev/ai/limecloud/agentui` v0.6.0、Claude Code、Codex、Vercel AI SDK、assistant-ui、LangGraph、OpenAI ChatKit/Apps SDK 等调研结论,并回挂 Lime 当前实现缺口。
@@ -12,7 +12,7 @@ Lime AgentUI 的主目标不是只修复 thinking 样式,而是把对话工作
事实源声明:
```text
-Agent runtime event / subagent runtime event / automation job event
+Agent runtime event / subagent runtime event / automation job event / remote task event
-> Agent UI envelope projection
-> conversationProjectionStore.agentUi
-> Conversation / Inline Process / Task Capsule / Artifact / Evidence / Diagnostics / Team Workbench UI
@@ -127,11 +127,11 @@ Agent UI 标准已经从“thinking/progressive rendering 局部规则”扩展
| `execution run` 作为 coordinator / task taxonomy | `dead` | `ExecutionTracker` 只能做观测层,不定义分工、调度或 parent/child 编排。 | 新设计不得把 `agent_runs` 当成 Team Workbench coordinator。 |
| Remote heartbeat 作为 terminal completion | `dead` | remote teammate 需要 remote truth / task status / artifact update,不得用 idle tick 猜完成。 | future remote adapter 必须保留 remote task id 和真实 terminal state。 |
-## 6. 未对齐清单
+## 6. 后续增强 / future boundary 清单
-当前 Lime 不是底层 runtime 能力缺失,而是标准 envelope、event class、surface 消费没有完全覆盖 v0.6.0。
+当前 Lime 的 v0.6.0 projection baseline 已完成;本节保留的是后续增强 / future boundary,不再作为 v0.6.0 完成阻塞项。
-| 优先级 | 缺口 | 影响 | 期望收口 |
+| 后续优先级 | 后续项 | 影响 | 期望收口 |
| --- | --- | --- | --- |
| P0 | `runtimeEntity` 显式字段 / 类型 | 已可机械证明 `agent_turn/subagent_turn/automation_job` 进入 Agent UI envelope;`work_item` 已有 team formation -> work board baseline;`external_task` 已有 remote task projection helper。 | 继续禁止新建第四类 runtime taxonomy;remote helper 只能消费真实 remote source。 |
| P0 | team queue / parallelism facts 进入标准 envelope | Team UI 与 Agent UI projection 已共享 team/provider queue facts。 | 继续补 surface 详情消费,避免各 UI 局部重算。 |
@@ -150,10 +150,10 @@ Agent UI 标准已经从“thinking/progressive rendering 局部规则”扩展
按对整体目标完成度的增量排序;若隔壁进程正在改 v0.6 Team Workbench / controls 文件,本进程只选择已确认非重叠的低冲突 projection 接线,不抢 Rust 高冲突实现文件或同一批 Team UI patch。
-1. **深化 Team Workbench 专门视图**:summary consumer、scoped lane selector、native collapsible surface detail、view model、可见 `工作台操作视图`、action target 定位、requested fixes work item + regression outcome 展示、local child session action routing、`teammate_transcript` source / Transcript Zoom / live activity + 历史 activity snapshot preview,以及 related teammate projection chain 已接入;非本地 target route taxonomy 与可见状态已补(含 work_board work item 未接入态、requested fix submitted_work_item、reassignment source fact 已定位态与 seeded_reassignment),直接 board/team API 写回已审计为 future command boundary;下一刀优先收敛统一门禁;原生 A2A artifact content 已登记为 future product boundary,后台无确认策略和更完整 teammate drilldown 排后。
-2. **补 Review lane 余量**:review request 已有 actual source,surface detail 已展示 reviewer、risk、checklist、followup、regression、requested fixes 与 regression requirement preview;review 保存后的 `verification_summary` failure/recovered facts 已进入 review payload 与 requested-fix work item;`team_control_projection(action=reassign)` 已能表达 reassignment source,TaskUpdate owner_change 已作为真实 board source 投影到 work_board;pending requested fix 已支持显式点击后提交 runtime turn;无 prompt 的直接 board/team API 写回已降级为 future command boundary;下一刀优先推进统一门禁收敛;原生 A2A artifact content 已登记为 future product boundary,或更独立的 specialist handoff protocol callback。
+1. **深化 Team Workbench 专门视图**:summary consumer、scoped lane selector、native collapsible surface detail、view model、可见 `工作台操作视图`、action target 定位、requested fixes work item + regression outcome 展示、local child session action routing、`teammate_transcript` source / Transcript Zoom / live activity + 历史 activity snapshot preview,以及 related teammate projection chain 已接入;非本地 target route taxonomy 与可见状态已补(含 work_board work item 未接入态、requested fix submitted_work_item、reassignment source fact 已定位态与 seeded_reassignment),直接 board/team API 写回已审计为 future command boundary;统一门禁已于 2026-05-10 fresh green;原生 A2A artifact content 已登记为 future product boundary,后台无确认策略和更完整 teammate drilldown 排后。
+2. **补 Review lane 余量**:review request 已有 actual source,surface detail 已展示 reviewer、risk、checklist、followup、regression、requested fixes 与 regression requirement preview;review 保存后的 `verification_summary` failure/recovered facts 已进入 review payload 与 requested-fix work item;`team_control_projection(action=reassign)` 已能表达 reassignment source,TaskUpdate owner_change 已作为真实 board source 投影到 work_board;pending requested fix 已支持显式点击后提交 runtime turn;无 prompt 的直接 board/team API 写回已降级为 future command boundary;统一门禁已收敛,后续只在新增 current board/team command 或 specialist handoff protocol 后继续接入;原生 A2A artifact content 已登记为 future product boundary。
3. **补其它 automation 消费入口**:settings automation 与 capability drafts managed automation 已接持续刷新;后续如 skills automation 列表需要展示 background teammate,也接 `recordAutomationJobsAgentUiProjection`,不新增 runtime taxonomy。
-4. **remote teammate / A2A source 接线**:gateway actual source、remote task status/input/auth/artifact refs baseline 已有;真正原生 A2A ingress、remote artifact store/content 回流与远端控制 callback 已登记为 future product boundary;下一刀优先等待统一门禁收敛,不用假 remote surface。
+4. **remote teammate / A2A source 接线**:gateway actual source、remote task status/input/auth/artifact refs baseline 已有;真正原生 A2A ingress、remote artifact store/content 回流与远端控制 callback 已登记为 future product boundary,不用假 remote surface。
5. **v0.5 尾项回补**:Context/memory 细分 source event、其他 provider tool input streaming、HITL/action 历史重放归档,排在 v0.6 Team Workbench 主缺口之后。
## 8. 2026-05-09 v0.6.0 再审计记录
@@ -198,15 +198,45 @@ Agent UI 标准已经从“thinking/progressive rendering 局部规则”扩展
- 已补 Review request actual source:`HarnessStatusPanel` 导出人工审核模板时,除了 `review.requested` evidence event,还会记录 `team_control_projection(control=request_review)`,以 `review_relative_root` 作为 review work item,落到 `review_lane` 并保留 `sessionId/threadId` scope。
- 已补 Remote teammate actual source baseline:`remoteTaskAgentUiProjection.ts` 提供 `remote_task_projection`,将结构化 remote task 映射为 `agent.changed/task.changed(surface=remote_teammate, runtimeEntity=external_task)`,保留 Agent Card、remote task id、input/auth、artifact refs,并且只在真实 terminal status 下输出 `worker.notification`,避免 heartbeat 猜完成;input/auth need 会额外输出 `action.required(surface=remote_teammate, control=answer)`,让远端待输入 / 待鉴权成为结构化 HITL fact;当 `remote_task` provenance 提供 `event/taskStatus/status/state/phase` 或嵌套 `task/a2aTask` artifact updates 时也会进入同一 projection,terminal `AgentRun.status` 仍优先压过 stale remote/input/auth source;`agentUiEventProjection.ts` 内的旧 remote teammate builder 已改为兼容 wrapper,统一委托该 helper。2026-05-10 已把 `gateway_channel_*` / gateway crate 接到 current source:渠道 runtime 写入 `source_metadata.remote_task`,`agent_runs.metadata` 保留 provenance,`useRemoteTaskExecutionRunProjection` 再按当前 session 投影到 Team Workbench;剩余原生 A2A ingress 与 remote artifact store/content 回流已登记为 future product boundary。
- 已补 active stream overlay 收口:`text_delta` 继续走低频 overlay 实时显示,`final_done` 后回填到 message content/contentParts;失败态保留已有 tool/process contentParts 并追加失败正文,避免“无最终正文”错误把工具过程清空。
-- 曾完成统一质量门禁复验:`npm run verify:local` 通过,覆盖 app-version、lint、typecheck、Vitest smart 52 批、contracts、Rust `cargo test --manifest-path src-tauri/Cargo.toml` 与 GUI smoke;但 2026-05-10 最新核验显示旧 gate 进程已 stale 且 DevBridge 3030 不可用,当前只能作为历史 green 证据,不能作为最终 complete 证据。
+- 已完成 2026-05-10 final gate fresh 复验:`npm run verify:local` 统一门禁通过,覆盖 app-version、lint、typecheck、Vitest smart 52 批、contracts、Rust 全量与 GUI smoke;此前旧 stale gate / DevBridge 不可用问题已由 fresh unified gate 证据替代,不再阻塞 v0.6.0 标准对齐完成判定。
-## 8.1 2026-05-10 Board/team 直接写回命令边界审计
+## 8.1 2026-05-10 final gate green
+
+- 统一门禁:`npm run verify:local` fresh 通过;local-ci smart 模式在无 source 改动下执行全量兜底,覆盖一致性校验、前端校验、bridge/contracts、GUI smoke 与 Rust 校验。
+- 前端与契约门禁:`npm run verify:app-version` 通过(版本 `1.32.0`),`npm run lint` 通过,`npm run typecheck` 通过,`npm test` 通过(Vitest smart 52 批),`npm run test:contracts` 通过,`npm run check:agent-runtime-clients` 通过,`npm run harness:doc-freshness` clean。
+- Rust 门禁:`npm run verify:local` 内的 `cargo test --manifest-path src-tauri/Cargo.toml` 通过;修复本地 `src-tauri/target` 构建缓存后,单独复核 `cargo test --manifest-path "src-tauri/Cargo.toml"` 也通过:`lime` root unit tests 1295 passed,`src/main.rs` 0 passed,integration tests 2 passed,真实联网测试 2 ignored。
+- GUI 门禁:`npm run verify:local` 内的 `npm run verify:gui-smoke` 通过;覆盖 DevBridge health、前端壳、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page、knowledge-gui、design-canvas,并完成本轮 smoke Chrome profile 清理。
+- 完成判定:Agent UI v0.6.0 projection / Team Workbench / review lane / work_board / worker notification / remote teammate baseline 已达到可交付门槛。`direct board/team write-back`、`native A2A ingress`、`remote artifact store/content bytes`、`remote control callback`、无人工确认后台执行策略与更深 teammate drilldown 均保持 future product / command boundary,不作为 v0.6.0 完成阻塞项。
+
+## 8.2 2026-05-10 Board/team 直接写回命令边界审计
- 已审计 current 命令边界:`src/lib/api`、`src-tauri/src/app/runner.rs`、`agentCommandCatalog.json`、`mockPriorityCommands.ts` 与 `defaultMocks` 均没有可复用的 board/team 直接写回命令;`TaskCreate/TaskList/TaskGet/TaskUpdate` 当前是 aster runtime tool / inventory,不是 Tauri bridge command。
- 已确认真实可写事实源仍是 `TaskUpdateTool`:它通过 `resolve_task_board_state` / `persist_task_board_state` 读写 session task board,并在 owner 变化时输出 `ownerChange` / `owner_change` metadata;Agent UI 只消费该结构化 metadata 派生 `work_board` assign/reassign。
- 已明确不新增临时 UI 本地写回或平行 Tauri command。若未来要做无 prompt direct board/team write-back,必须先新增 current command 或 shared task board service,并同步前端网关、Rust 注册、治理目录册、DevBridge priority mock 与 default mock,同时解决 runtime `shared_task_list_storage` 与 session `extension_data` 的一致性。
- 因此 v0.6 标准对齐口径调整为:`work_board` / reassignment 的合规 baseline 是 `Team Workbench selector -> TaskUpdate prompt/runtime turn -> TaskUpdateTool owner_change source -> Agent UI projection`;无 prompt 直接写回归入 future product command boundary,不再作为本轮 projection 对齐阻塞项。
+## 8.3 2026-05-10 Playwright E2E 与真实 child context 复验
+
+- 已补浏览器 DevBridge subagent control 命令族:`agent_runtime_spawn_subagent`、`agent_runtime_send_subagent_input`、`agent_runtime_wait_subagents`、`agent_runtime_resume_subagent`、`agent_runtime_close_subagent` 已接入 dispatcher 与 HTTP cooldown bypass,契约仍落在现有 `agent_runtime_*` current 主链,不新增 Tauri command。
+- 已补 session store limited history 空会话探测:`get_runtime_session_detail_with_history_page` 不再因为 persisted empty fast path 跳过 runtime overlay / subagent context;`agent_runtime_get_session(parent, { historyLimit })` 能返回 `child_subagent_sessions`,空 child detail 也能返回 `subagent_parent_context`。
+- Playwright E2E 复验已覆盖:普通会话 tab 不显示 `AgentUI N` 内部 badge;Team Workbench `Agent UI v0.6 / 10 events`、10 个 surface、三类 lane、`工作台操作视图`、`Surface 专门视图`、Harness AgentUI 投影、真实 child context 与 console `0 error / 0 warning` 均有证据。
+- 证据索引:`docs/exec-plans/agentui-playwright-e2e-2026-05-10.md`;截图与 JSON 位于 `docs/exec-plans/evidence/agentui-e2e-2026-05-10/09-session-store-subagent-context.json`、`09-team-workbench-real-child-context-after-session-store-fix.png`、`10-transcript-open-detail-real-child-focus-context.png`、`10-console-errors-final-real-child-focus.txt`。
+- 复验命令:`cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-agent should_probe -- --nocapture`、`cargo test --manifest-path "src-tauri/Cargo.toml" dev_bridge --lib -- --nocapture`、相关 Vitest / ESLint、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke -- --reuse-running` 均通过。
+- 已补 child focus `返回主助手` 点击稳定性:embedded Team board sticky header 提升到 `z-40`,返回按钮增加独立可点击层级与 `data-testid`;Playwright harness 证据位于 `docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/01-child-focus-return-parent-before-click.png`、`02-return-parent-after-click.png`、`02-return-parent-hit-target.json`、`02-console-errors-return-parent.txt`,点击前命中目标为 `BUTTON`,点击后返回按钮消失,console `0 error / 0 warning`。
+- 剩余项继续降级为 E2E 深度增强:让真实 child turn 产出最小 activity preview、避免 requested-fix action 在测试中误触发真实 runtime turn、在 DevBridge `3030` 恢复后用真实 child session 再复跑同一返回路径;这些不阻塞 v0.6.0 标准 baseline 完成判定。
+
+## 8.4 2026-05-10 产品 / 后端 / UI-UX 复测补充
+
+- DevBridge 恢复后已补真实主路径复测:`npm run bridge:health -- --timeout-ms 30000` 通过,`npm run verify:gui-smoke -- --reuse-running --timeout-ms 120000` 通过,覆盖 workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page、knowledge-gui、design-canvas。
+- 契约与类型门禁已补当前轮次复核:`npm run test:contracts` 通过,`npm run typecheck` 通过。
+- 项目资料 PRD v3 产品闭环已补真实 E2E:`npm run knowledge:product-e2e -- --timeout-ms 120000` 通过,覆盖首页、状态说明、确认资料、选择创作资料、Agent 结果保存到项目资料、整理新资料,`consoleErrors=0`;仅 memory / hint 读取保留浏览器 mock 噪音,不阻塞项目资料主链。
+- Playwright MCP 真实 DevBridge 页面证据已归档:`docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/04-home-real-bridge.png`、`05-project-knowledge-real-bridge.png`、`06-claw-new-task-real-bridge.png`、`07-settings-ai-provider-real-bridge.png`、`08-skills-real-bridge.png`;对应 console 文件均为 `Errors: 0, Warnings: 0`。
+- 早期 `03-home-bridge-unavailable.png` / `03-console-errors-home-bridge-unavailable.txt` 保留为环境阻塞证据,说明 DevBridge 未监听时首页会产生 bridge 连接错误;该问题已由后续真实 bridge green gate 替代,不再作为当前完成判定阻塞。
+- 已补运营级测试审计增量:`npm run agent-qc:check` 通过,manifest `12` 场景 / `8` 个 P0,`issueCount=0`;`npm run agent-qc:qcloop-job -- --risk P0 --check --format json` 可生成 `8` 个 P0 item payload;真实 qcloop job `1778390726823769000` 已跑完并导出 `.lime/qc/agent-qc-evidence.json`,结果为 `status=fail`、`8` 个 P0 中 `2` pass / `6` fail;`npm run harness:eval` 与 `npm run harness:eval:trend` 通过,但 trend 仅有 1 个样本,不能作为长期退化完成证据。
+- 已补 Skill Forge / sandbox 边界点检:`node scripts/prompt-to-artifact-smoke.mjs --timeout-ms 120000 --json` 通过,覆盖 Capability Draft create -> verify -> register -> discovery -> workspace binding readiness,结果为 `ready_for_manual_enable` / `manual_runtime_enable`;`cd src-tauri && cargo test test_build_workspace_shell_allow_pattern -- --nocapture` 通过 3 tests,覆盖 workspace shell allowlist / strict mode 拒绝高风险命令。
+- 已补 Claw 流式 / 中断 / 恢复手工 Playwright transcript:`docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/11-claw-streaming-*.png`、`11-claw-streaming-console-2026-05-10.txt`、`11-claw-streaming-runtime-session.json`、`11-claw-streaming-thread-read.json`、`11-claw-streaming-summary.json`。结论不是 pass:UI stop 可点击且 follow-up 最终 `恢复成功`,但被停止的长 turn `a9220dae-54d1-48bc-a83a-fe6b33a034dc` 仍以 `completed` 落盘并完整包含 80 条输出,follow-up 曾短暂 `queuedTurnsCount=1`;这把 `claw-chat-ready-streaming` 明确归类为 runtime cancel 语义阻塞,而不是单纯证据不足。
+- 完成度审计结论:本文件的 v0.6.0 Agent UI baseline 与本轮产品主路径可交付;active goal 仍不能标记 complete,因为 qcloop Evidence Pack 仍非 pass,且 release package artifact、Claw runtime cancel 语义、approval runtime transcript、Skill Forge runtime enable + SkillTool 执行 transcript 与真实远端 A2A / artifact content 仍未被完整证据覆盖。
+
## 9. 既有实施记录摘要
- 已新增标准 adapter:`agentUiEventProjection.ts` 负责 source event -> Agent UI envelope,覆盖 text、reasoning、runtime、tool、action、artifact、context、queue、subagent、evidence helper 与 timeline item。
diff --git a/docs/roadmap/i18n/prd.md b/docs/roadmap/i18n/prd.md
new file mode 100644
index 000000000..5cf9dea8f
--- /dev/null
+++ b/docs/roadmap/i18n/prd.md
@@ -0,0 +1,846 @@
+# Lime 全球本地化 PRD
+
+> 状态:proposed current roadmap
+> 更新时间:2026-05-11
+> 负责人视角:Lime 桌面端 GUI / Agent 运行时 / Browser Runtime 的全球本地化能力层
+> 目标:把当前 DOM Patch 翻译补丁升级为 key-based、可验证、可持续扩展的全球本地化体系
+> 非目标:本文不是具体实施任务清单;实施前仍需拆分到 `docs/exec-plans/` 并按 Lime 质量工作流验证
+
+## 1. 背景
+
+Lime 当前已经具备早期中英切换能力,但能力形态仍是“把渲染后的中文文本替换成英文”的 Patch Layer。这个机制适合快速补齐英文预览,不适合长期全球化。
+
+全球本地化不只是翻译 UI 文案,而是同时覆盖:
+
+1. UI 文案与组件状态
+2. 日期、数字、相对时间、排序、复数与变量
+3. RTL / LTR 方向、`html lang`、桌面端首屏闪烁控制
+4. Agent 默认回复语言与用户创作内容目标语言
+5. Browser Runtime 的 `Accept-Language`、locale、timezone 等站点环境
+6. Rust / Tauri / Bridge 错误的用户可见表达
+7. Chrome extension、发布材料、文档与自动翻译流程
+
+因此,Lime 的目标不是“把中文替换成英文”,而是建立一个可治理的本地化事实源,让新功能默认具备全球化能力。
+
+## 2. 调研依据
+
+本轮调研同时使用了本地代码检索、Context7 文档查询与 WebSearch 官方资料复核,避免只基于单一项目经验做方案设计:
+
+- 本地代码:复核 Lime 当前 `src/i18n/`、配置 schema、Browser Runtime 环境语言实现,并对比 `/Users/coso/Documents/dev/js/lobehub` 与 `/Users/coso/Documents/dev/rust/CodexMonitor`。
+- Context7:查询 `i18next`、`react-i18next`、`Vite` 的 current 文档,重点验证 fallback、namespace、`Trans`、Suspense 与 `import.meta.glob` 的实现约束。
+- WebSearch:只采用官方或上游资料,包括 i18next、react-i18next、Vite、MDN、W3C、Tauri 与 i18next 插件仓库。
+
+### 2.1 Lime 当前事实
+
+本轮复核了以下本地事实源:
+
+- `package.json` 已有 `i18next`、`react-i18next`、`dayjs`,但没有 `i18next-browser-languagedetector`、`i18next-resources-to-backend`、`rtl-detect`。
+- `src/i18n/README.md` 明确当前单一前端 i18n fact source 是 Patch Layer。
+- `src/i18n/config.ts` 初始化了 i18next,但当前只作为兼容层,`lng` 与 `fallbackLng` 均为 `zh`。
+- `src/i18n/legacy-patch/text-map.ts` 的语言模型只有 `"zh" | "en"`,资源落在 `legacy-patch/patches/zh.json` 与 `legacy-patch/patches/en.json`。
+- `src/i18n/legacy-patch/I18nPatchProvider.tsx` 通过 `MutationObserver` 监听动态 DOM,调用 `replaceTextInDOM` / `replaceTextInNode` 替换文本。
+- `src/i18n/legacy-patch/dom-replacer.ts` 使用 `TreeWalker` 扫描 text node,只处理含中文文本,跳过 `input`、`textarea`、`contenteditable`、`.ProseMirror`,并记录耗时指标。
+- `src/lib/api/appConfigTypes.ts` 与 `src-tauri/crates/core/src/config/types.rs` 均已有 `language: string`,Rust 默认值仍是 `zh`,可作为首期 UI locale 持久化入口。
+- `src/lib/api/appConfig.ts` 已有 `getConfig()` / `saveConfig()`,首期切换语言不需要新增 Tauri 命令。
+- `package.json` 暴露 `detect-translations*` 脚本;本轮已补 `scripts/detect-missing-translations.ts`,用于校验各 locale 的 namespace/key 结构一致性。
+- `src-tauri/src/services/browser_environment_service.rs` 已把 Browser Runtime 的 `locale`、`accept_language`、`timezone_id` 作为站点环境的一部分,并由 `browser_launch_language()` 推导 Chrome 启动语言;这应与 Lime UI locale 分离。
+
+结论:Patch Layer 可作为迁移期兼容层保留,但不能继续作为 current 主路径。
+
+### 2.2 LobeHub 参考
+
+本轮复核 `/Users/coso/Documents/dev/js/lobehub`:
+
+- 依赖组合:`i18next`、`react-i18next`、`i18next-browser-languagedetector`、`i18next-resources-to-backend`、`rtl-detect`、`dayjs`。
+- `.i18nrc.js` 以 `locales/en-US` 为入口,使用 `@lobehub/i18n-cli` 自动生成多语言 JSON,并配置 `temperature: 0`、`jsonMode`、Markdown 翻译。
+- `locales//.json` 资源结构清晰,每个 locale 下约 46 个 namespace。
+- `src/locales/resources.ts` 集中维护 locale registry、`normalizeLocale()`、本地语言显示名与支持列表。
+- `src/locales/create.ts` 使用 `LanguageDetector`、`resourcesToBackend`、`initReactI18next`,预加载核心 namespace,`react.useSuspense = false`,语言变化时同步 `document.documentElement.dir`。
+- `src/utils/i18n/loadI18nNamespaceModule.vite.ts` 使用 `import.meta.glob` 动态加载 default / locale namespace;desktop 版本用 `{ eager: true }` 内联所有资源,降低桌面运行时懒加载不确定性。
+- 设置里区分 UI language 与 response language;用户可以界面使用一种语言,同时让 Agent 用另一种语言回复。
+- i18n workflow 包含 default locale 生成、diff 清理、unused key 扫描、自动翻译 PR。
+
+对 Lime 的可借鉴点:namespace 资源结构、locale registry、locale normalize、核心 namespace 预加载、desktop eager loader、UI 语言和 AI 回复语言分离、自动翻译与 unused key 治理。
+
+不建议照搬的点:Next.js middleware、路由 locale、SEO sitemap、Ant Design locale、一次性支持 18 种语言。Lime 是 Tauri 桌面产品,首期应更克制。
+
+### 2.3 CodexMonitor 参考
+
+本轮复核 `/Users/coso/Documents/dev/rust/CodexMonitor`:
+
+- `package.json` 没有正式 i18n / react-i18next 依赖。
+- UI 中存在大量英文硬编码,说明桌面 GUI 如果早期不做 key-based i18n,后期迁移成本会快速上升。
+- `src/utils/time.ts`、`src/features/home/homeFormatters.ts` 已使用 `Intl.RelativeTimeFormat`、`Intl.DateTimeFormat`、`Intl.NumberFormat`,这是值得 Lime 借鉴的格式化方向。
+- Rust 侧存在 terminal locale、dictation preferred language 等能力,提醒 Lime 需要区分“UI 语言”“输入/识别语言”“终端/进程 locale”“AI 输出语言”。
+
+对 Lime 的启示:不要把所有 language 字段混成一个;日期数字等格式化应走统一 locale wrapper,不要靠翻译 JSON 手写时间单位。
+
+### 2.4 官方资料与外部参考
+
+本轮使用 Context7 与 WebSearch 查阅了官方/上游资料:
+
+- i18next fallback:`https://www.i18next.com/principles/fallback`
+- i18next namespace:`https://www.i18next.com/principles/namespaces`
+- i18next interpolation / plural / context:`https://www.i18next.com/translation-function/interpolation`、`https://www.i18next.com/translation-function/plurals`、`https://www.i18next.com/translation-function/context`
+- react-i18next `useTranslation`:`https://react.i18next.com/latest/usetranslation-hook`
+- react-i18next `Trans`:`https://react.i18next.com/latest/trans-component`
+- i18next TypeScript:`https://www.i18next.com/overview/typescript`
+- Vite glob import:`https://vite.dev/guide/features.html#glob-import`
+- MDN Intl:`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl`
+- MDN `lang` / `dir`:`https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang`、`https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/dir`
+- W3C language tag 选择:`https://www.w3.org/International/questions/qa-choosing-language-tags`
+- Tauri v2 Store:`https://v2.tauri.app/plugin/store/`
+- i18next resources backend:`https://github.com/i18next/i18next-resources-to-backend`
+- i18next browser language detector:`https://github.com/i18next/i18next-browser-languageDetector`
+
+官方资料对本 PRD 的直接结论:
+
+1. i18next 原生支持 `fallbackLng` 的具体语言、泛化语言与 fallback 序列,适合 `zh-CN -> zh -> fallback` 这类退化链。
+2. namespace 是长期维护大型应用文案的基本边界,应使用 `ns` 参数访问公共 namespace,而不是把所有 key 放进一个大 JSON。
+3. interpolation、plural、context 是变量、复数、性别/语境差异的标准能力,DOM Patch 无法可靠覆盖这些场景。
+4. react-i18next 的 `useTranslation(ns)` 与 `Trans` 分别覆盖普通文案和富文本/React 组件插入,应成为新 UI 的默认入口。
+5. TypeScript `CustomTypeOptions` 可以把 resources 绑定到 key 类型,降低漏 key 与拼错 key 风险。
+6. Vite `import.meta.glob` 可用于 namespace lazy load;桌面端可用 eager load 换取启动确定性。
+7. `Intl.*` 是日期、数字、相对时间的基础设施,应封装成 Lime 统一 format API。
+8. `html lang` 与 `dir` 需要随 UI locale 同步;RTL 支持不是简单翻译 JSON。
+
+### 2.5 补充调研矩阵
+
+| 来源 | 复核点 | 对 Lime 的方案约束 |
+| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| Context7 / i18next | `fallbackLng` 支持具体语言、方言退化、fallback 序列与 namespace fallback;key fallback 虽可用但不推荐长期以自然语言 key 管理。 | Lime 使用稳定 dotted key,`fallbackLng` 固定回 `zh-CN`,`fallbackNS` 回 `common`;不把中文原文当 current key。 |
+| Context7 / i18next | namespace 用于按语义、页面或 feature 拆分资源,避免单文件过大并支持懒加载。 | Lime 首期保留 `common`、`navigation`、`settings`、`workspace`、`agent`、`errors` 核心 namespace,业务域后续再拆。 |
+| Context7 / i18next | interpolation、plural、context 是变量、数量和语境差异的标准能力。 | 动态文案必须走 `t(key, params)`、plural 与 context;禁止继续用字符串拼接或 DOM Patch 处理变量语序。 |
+| Context7 / react-i18next | `useTranslation(ns)` 的 `t` 绑定 namespace;`Trans` 适合富文本和 React 组件插入;未就绪时会触发 Suspense,关闭 Suspense 后要自行处理 ready。 | 桌面端默认 `react.useSuspense = false`,核心 namespace eager preload;普通组件用 `useTranslation(ns)`,带链接/强调/代码片段的文案用 `Trans`。 |
+| Context7 / Vite | `import.meta.glob` 默认 lazy dynamic import;`{ eager: true }` 会构建期直接导入所有匹配模块;JSON 可直接 import。 | Lime 桌面主路径优先 eager preload 核心资源,业务 namespace 可 lazy;若 GUI smoke 发现首屏抖动,切到 desktop eager loader。 |
+| WebSearch / MDN Intl | `Intl` 提供语言敏感的比较、数字、日期、相对时间、列表等格式化能力,并使用 BCP 47 locale negotiation。 | 新增 `format.ts` 统一封装,不在 JSON 里手写时间单位;排序与时间数字展示显式传 UI locale。 |
+| WebSearch / MDN + W3C | `lang` / `dir` 是 HTML 级语义;语言标签应基于 BCP 47,避免自造短码。 | UI locale 采用 `zh-CN` / `en-US` / `zh-TW` 等 BCP 47 风格,旧 `zh` / `en` 只读兼容;切换语言同步 `documentElement.lang` 与 `dir`。 |
+| LobeHub 本地代码 | `.i18nrc.js` 以 `en-US` 为 entry,配置多 locale 输出、glossary、Markdown 翻译、JSON mode;`src/locales/create.ts` 组合 LanguageDetector、resources backend、核心资源预加载和 `dir` 同步。 | Lime 借鉴 workflow 与架构边界,但不照搬 source locale、Next.js 路由、SEO 与一次性 18 语言;首期保守支持 5 个 locale + `auto`。 |
+| LobeHub 本地代码 | `loadI18nNamespaceModule.vite.ts` lazy,`loadI18nNamespaceModule.desktop.ts` eager,均提供 fallback 到 default namespace。 | Lime 后续可拆出 `loadNamespace.ts`,保留 web/dev 与 desktop 两种加载策略,确保 Tauri 首屏确定性。 |
+| CodexMonitor 本地代码 | 没有正式 i18n 依赖,UI 有大量硬编码英文;但 `src/utils/time.ts` 使用 `Intl.RelativeTimeFormat`,Rust terminal 与 dictation 另有 locale / preferred language。 | Lime 不能把 UI language、终端 locale、ASR language、Agent response language 混成一个字段;格式化能力应统一封装。 |
+
+### 2.6 对比结论
+
+1. **LobeHub 是正向成熟样板**:它证明 `i18next + react-i18next + resources backend + locale registry + 自动翻译 workflow` 能支撑大规模 AI 产品,但 Lime 应按桌面 GUI 的启动稳定性和当前中文事实源做裁剪。
+2. **CodexMonitor 是边界提醒**:没有早期 key-based i18n 时,UI 硬编码会快速扩散;同时 terminal、dictation、code highlight 这类 “language” 不是 UI locale,必须分字段治理。
+3. **Lime 的正确路线是双轨收口**:current 主路径转向 key-based resources,legacy Patch Layer 只做迁移期兜底,并用命中率和 coverage 逐步退出。
+4. **首期不追求语言数量,而追求机制闭环**:先让 locale registry、资源加载、设置持久化、fallback、格式化和 GUI smoke 跑通,再扩大 locale 与自动翻译。
+
+### 2.7 本轮工具复核与取舍
+
+为了避免“参考了成熟项目,但没有落到 Lime 桌面产品边界”的问题,本轮把外部资料转成以下决策约束:
+
+| 复核方式 | 关键证据 | Lime 取舍 |
+| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
+| Context7 / `/websites/i18next` | i18next 官方资料确认 `fallbackLng`、`fallbackNS`、namespace、interpolation、plural、context 与 TypeScript 资源绑定均是框架原生能力。 | current 主路径采用稳定 key + namespace;中文原文只作为 `zh-CN` source value,不再作为长期 key。 |
+| Context7 / `/i18next/react-i18next` | `useTranslation(ns)` 适合普通组件文案,`Trans` 适合富文本和 React 组件插入;关闭 Suspense 后需要预加载或自行处理 ready。 | 桌面端默认 `useSuspense: false`,核心 namespace eager 资源内联,避免 Tauri 首屏 loading 闪烁。 |
+| Context7 / `/vitejs/vite` | `import.meta.glob` 默认 lazy dynamic import,`{ eager: true, import: "default" }` 可构建期直接导入 JSON default export。 | P0 直接静态导入核心 resources;P3 再抽 `loadNamespace.ts`,按核心 eager、业务 lazy 拆分。 |
+| WebSearch / MDN + W3C | `Intl`、`html lang`、`dir` 与 BCP 47 language tag 是浏览器标准能力。 | `Config.language` 新写入值使用 `zh-CN` / `en-US` 等 BCP 47 风格;切换 UI locale 同步 `documentElement.lang` / `dir`。 |
+| WebSearch / Tauri Store | Tauri v2 Store 可作为轻量持久化插件。 | Lime 已有跨 Rust / 前端的 `get_config` / `save_config` 配置事实源,P0 不新增 Store,避免平行配置源。 |
+| WebSearch / i18next detector 与 resources backend | detector 与 backend 适合 Web 场景自动探测和懒加载资源。 | Lime P0 先不新增依赖;`auto` 通过 registry normalize 解析系统/浏览器 locale,resources 先随包内联。 |
+| LobeHub 本地复核 | 当前样板有 18 个 locale、`en-US` 约 46 个 namespace,并组合 detector、resources backend、RTL、自动翻译 workflow。 | 只借鉴架构和治理;不照搬 Next.js 路由、SEO、Ant Design locale,也不首期铺 18 种语言。 |
+| CodexMonitor 本地复核 | 未引入正式 i18n 依赖;存在 `Intl.RelativeTimeFormat`、terminal `LANG` / `LC_*`、dictation preferred language 等多种 language-like 字段。 | Lime 必须把 UI locale、Agent response language、Browser environment language、终端/语音语言拆开治理。 |
+
+由此确定 P0 的最小闭环:不追求翻译覆盖率最大化,而是先建立 locale registry、key-based resources、旧值兼容、设置持久化、`lang` / `dir` 同步和 GUI smoke 验收口径。
+
+2026-05-10 追加复核:Context7 重新确认 `/websites/i18next`、`/i18next/react-i18next`、`/vitejs/vite` 的 current 文档与上述结论一致;WebSearch 复核的官方资料仍指向 i18next / react-i18next / Vite / MDN / W3C / Tauri 官方或上游来源,未发现需要推翻 P0/P1 技术路线的更新。
+
+### 2.8 追加调研证据与方案修正
+
+本轮继续使用 Context7、WebSearch、本地 LobeHub 与 CodexMonitor 复核,新增以下更细约束:
+
+| 来源 | 新增证据 | 对 Lime 的修正 |
+| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Context7 / i18next TypeScript | i18next v25.4 起加强 `enableSelector`;官方说明 v26 计划默认启用 selector,v27 有弃用 string-based 类型支持的倾向;同时大资源类型检查可能带来 OOM。 | P1 继续用稳定 dotted string key 降低迁移成本;P3 评估 selector API 与 `@i18next-selector/codemod`,但不要在资源量未收敛前强推全仓 selector。 |
+| WebSearch / i18next 官方 | 官方把 `i18next-cli` 标为推荐工具,覆盖 key extraction、hardcoded string lint、locale sync 与 type generation。 | P3 工具链候选从“自研或 LobeHub CLI”扩展为“三段式”:短期保留 `detect-translations`;中期优先评估官方 `i18next-cli` 做抽取 / lint / 类型;AI 自动翻译可再评估 `@lobehub/i18n-cli` 或自研脚本。 |
+| Context7 / react-i18next | `` 适合 React/HTML 节点插入,但它本身只做插值,不负责重新渲染或加载翻译;普通文本仍应优先用 `t`。 | 富文本组件必须与 `useTranslation(ns)` 绑定同一个 `t`;不要把 `` 当成全局 provider,也不要为了普通按钮文案滥用 ``。 |
+| Context7 / Vite | `import.meta.glob` 默认 lazy dynamic import;`eager + import: "default"` 可直接拿 JSON default export;glob 参数必须是字面量,不能用变量拼接。 | `loadNamespace.ts` 的 glob pattern 必须保持静态字面量;业务 namespace 若 lazy,应通过 registry 查表而不是动态拼 import path。 |
+| WebSearch / MDN + W3C | `lang` 使用单个 BCP 47 language tag;`dir` 是语义方向属性,`ltr` / `rtl` / `auto` 与 CSS `direction` 不是同一层决策;W3C 建议只在需要区分时增加 region/script 子标签。 | Lime 支持列表使用规范大小写的 BCP 47 tag;首期 `ja-JP` / `ko-KR` 是产品选择而非技术必要,后续扩 locale 要说明 region 子标签理由。 |
+| WebSearch / Tauri Store | Tauri Store 是官方持久化 key-value 插件,但需要新增依赖、权限和 async save/load 处理。 | P0 不引入 Store,继续使用已有 `get_config` / `save_config` 作为配置事实源,避免出现第二套 language preference。 |
+| LobeHub 本地代码 | LobeHub 现在有 18 个 locale、46 个 JSON namespace;`createI18nNext` 使用 bundled fallback resources、`partialBundledLanguages`、`initAsync` 控制与后台 `reloadResources`;Vite 配置把 `i18n-*` chunk 单独命名,大型 namespace 拆成 per-locale chunk。 | Lime 不首期照搬 18 语言;但需要把“首屏核心资源内联”和“后续 namespace chunk 化”写成 P2/P3 性能任务,避免资源扩大后拖慢 Tauri 启动。 |
+| LobeHub 本地代码 | desktop loader 使用 eager `import.meta.glob`,vite loader 使用 lazy glob,并在缺失 locale namespace 时回退 default namespace。 | Lime 当前 bundled loader 先走 eager 核心资源;未来如拆 desktop/web loader,应保留 fallback 监控与测试,不允许 namespace missing 变成白屏。 |
+| CodexMonitor 本地代码 | `package.json` 没有 i18n 依赖;前端和 Rust 侧存在大量英文硬编码错误 / UI 文案;`src/utils/time.ts` 使用 `Intl.RelativeTimeFormat(undefined)`,但 short relative time 仍手写 `m/h/d/w/mo/y`。 | Lime 的 `format.ts` 必须显式传 UI locale,并禁止重新出现手写相对时间单位;Rust 新错误应逐步转成 `code + params + fallbackMessage`。 |
+| CodexMonitor 本地代码 | `dictationPreferredLanguage` / `preferredLanguage` 从设置传到 Whisper 转写;`composerFenceLanguageTags`、代码高亮 language、`localeCompare` 排序和 TestFlight `--locale` 也都是 language-like 场景。 | PRD 明确新增 “ASR / dictation language” 与 “发布材料 locale” 两个边界;这些字段不应复用 UI `Config.language`。 |
+
+补充资料链接:
+
+- i18next TypeScript / selector:`https://www.i18next.com/overview/typescript`
+- i18next 官方抽取工具:`https://www.i18next.com/how-to/extracting-translations`
+- i18next supported frameworks / official CLI:`https://www.i18next.com/overview/supported-frameworks`
+- react-i18next `Trans`:`https://react.i18next.com/latest/trans-component`
+- Vite glob import:`https://vite.dev/guide/features.html#glob-import`
+- MDN `lang`:`https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/lang`
+- MDN `dir`:`https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir`
+- W3C language tag:`https://www.w3.org/International/questions/qa-choosing-language-tags`
+- Tauri Store:`https://v2.tauri.app/plugin/store/`
+
+### 2.9 最终复核记录
+
+> 复核时间:2026-05-10
+> 口径:本节只固化本轮调研证据,不替代后续 `docs/exec-plans/` 的实施计划。
+
+本轮再次使用 Context7、WebSearch 与本地代码复核,确认当前 PRD 仍应坚持 “key-based current 主路径 + legacy Patch 迁移期兜底”:
+
+1. Context7 选用的官方资料 ID 为 `/websites/i18next`、`/i18next/react-i18next`、`/vitejs/vite`,分别复核 `fallbackLng` / `fallbackNS`、namespace、interpolation、plural、context、`CustomTypeOptions`、`enableSelector`、`useTranslation(ns)`、`Trans`、Suspense 与 `import.meta.glob`。结论是 Lime P0/P1 应继续使用稳定 dotted string key,等资源规模与类型内存风险可控后再评估 selector API。
+2. WebSearch 只采用官方或上游资料:i18next extraction / TypeScript、react-i18next `Trans` / `useTranslation`、Vite glob import、MDN `Intl` / `lang` / `dir`、W3C BCP 47 语言标签、Tauri v2 Store。结论是 P0 不新增 Store 或 detector/backend 依赖,优先复用已有 `Config.language` 与包内 resources,避免出现第二套语言偏好事实源。
+3. LobeHub 本地复核确认 `/Users/coso/Documents/dev/js/lobehub/.i18nrc.js` 当前维护 18 个 output locale,`/Users/coso/Documents/dev/js/lobehub/locales/en-US` 下有 46 个 namespace;`src/locales/create.ts` 使用 bundled fallback resources、`LanguageDetector`、`resourcesToBackend`、`partialBundledLanguages`、`initAsync` 与 `documentElement.dir` 同步;`loadI18nNamespaceModule.vite.ts` 使用 lazy glob,`loadI18nNamespaceModule.desktop.ts` 使用 eager glob。Lime 借鉴 loader / workflow / normalize 思路,但不照搬 Next.js、SEO、Ant Design locale 与 18 语言首发范围。
+4. CodexMonitor 本地复核确认 `/Users/coso/Documents/dev/rust/CodexMonitor/package.json` 没有正式 i18n 依赖;`src/utils/time.ts` 与 `src/features/home/homeFormatters.ts` 同时存在 `Intl.*` 与手写短时间单位;`src-tauri/src/terminal.rs` 独立处理 `LANG` / `LC_*`;`src/features/settings/components/sections/SettingsDictationSection.tsx` 与 `src-tauri/src/dictation/real.rs` 存在 dictation preferred language。对 Lime 的约束是:UI locale、Agent response language、Browser environment language、terminal locale、ASR language、代码高亮 language 必须分字段治理,不能共用 `Config.language`。
+5. 本轮未发现需要推翻首期技术路线的外部资料更新;需要新增到路线图的只是 P3 工具链取舍:短期保留 `detect-translations`,中期优先评估官方 `i18next-cli` 做 extraction、hardcoded string lint、locale sync 与 type generation,AI 自动翻译再作为独立 workflow 接入。
+
+### 2.10 协作复核补充
+
+> 复核时间:2026-05-10
+> 口径:本节记录并行协作期间的只读复核结果,只补证据,不扩大实现范围。
+
+本轮在检测到并行 `npm run verify:local` 进程仍在运行后,只做低风险读证与 PRD 补充:
+
+1. Context7 复核 `/websites/i18next`:官方资料仍建议用 namespace、`fallbackLng`、`fallbackNS`、interpolation、plural、context 与 `CustomTypeOptions` 组织大型应用本地化;官方 extraction 文档推荐 `i18next-cli` 承担 key extraction、code lint、locale sync 与 type generation。对 Lime 的落点不变:P0/P1 保持稳定 dotted key 与自有 `detect-translations`,P3 再评估官方 CLI 接入。
+2. Context7 复核 `/i18next/react-i18next`:`useTranslation(ns)` 是普通组件文案主入口;`Trans` 只用于带 React 节点、链接、强调、代码片段或复杂插值的富文本;关闭 Suspense 后必须通过预加载或 `ready` 防止加载期闪烁。对 Lime 的落点不变:桌面端核心 namespace eager,普通按钮/标题/placeholder 不滥用 `Trans`。
+3. Context7 复核 `/vitejs/vite`:`import.meta.glob` 默认 lazy dynamic import,`{ eager: true, import: "default" }` 可直接拿默认导出;glob 参数必须是字面量,不能由变量拼接。对 Lime 的落点不变:`loadNamespace.ts` 使用静态 glob / registry 查表,不在运行时拼资源路径。
+4. WebSearch 复核官方/上游资料:i18next TypeScript 与 extraction、Vite glob import、MDN `lang` / `dir` / `Intl`、W3C language tag 与 Tauri Store 的结论未变化;P0 不引入第二套 Store 或 browser detector 事实源,仍复用现有 `Config.language` 与包内 resources。
+5. LobeHub 本地只读复核:`/Users/coso/Documents/dev/js/lobehub/package.json` 当前使用 `i18next ^25.8.0`、`react-i18next ^16.5.3`、`i18next-browser-languagedetector ^8.2.0`、`i18next-resources-to-backend ^1.2.1`、`rtl-detect ^1.1.2`、`@lobehub/i18n-cli ^1.26.0`;`locales` 下有 18 个 locale 目录,`locales/en-US` 下有 46 个 JSON namespace;仍适合作为成熟 workflow 与 loader 样板,而不是首期语言规模样板。
+6. CodexMonitor 本地只读复核:`/Users/coso/Documents/dev/rust/CodexMonitor/package.json` 仍无 `i18next` / `react-i18next` / `@formatjs/intl` / `dayjs`;代码中同时存在 `Intl.RelativeTimeFormat`、`localeCompare`、terminal `LANG` / `LC_*`、dictation preferred language、code block language 等 language-like 场景。对 Lime 的落点不变:格式化走统一 `format.ts`,UI locale、ASR language、terminal locale 与代码高亮 language 必须拆字段治理。
+7. Lime 当前读证:`package.json` 已有 `i18next ^25.7.3`、`react-i18next ^16.5.1`、`dayjs ^1.11.19`,未引入 detector/backend/RTL 依赖;`src/i18n/resources` 当前已有 `zh-CN`、`en-US`、`zh-TW`、`ja-JP`、`ko-KR` 五个 locale,每个 locale 均有 `common`、`navigation`、`settings`、`workspace`、`agent`、`errors` 六个核心 namespace。
+
+## 3. 问题诊断
+
+### 3.1 Patch Layer 的上限
+
+当前 Patch Layer 的优势是侵入小、见效快,但长期问题明显:
+
+- 只能替换已渲染文本,无法稳定处理变量、复数、富文本、组件插槽和运行时错误。
+- 依赖 DOM 扫描与 `MutationObserver`,页面越复杂,性能和时序风险越高。
+- 资源 key 是中文原文,文案微调会导致 key 失效,无法稳定引用。
+- 只能表达 `zh/en`,无法承载 `zh-CN`、`zh-TW`、`ja-JP`、`ko-KR`、RTL 等真实 locale。
+- 不能参与类型检查、静态扫描、unused key 清理与 CI 覆盖率。
+- 容易把“用户界面语言”“AI 回复语言”“浏览器环境语言”“内容产物语言”混成一个字段。
+
+### 3.2 现有配置语义过窄
+
+Rust 注释仍把 `Config.language` 描述为 `"zh" 或 "en"`,但全球本地化需要 BCP 47 风格 locale,例如 `zh-CN`、`en-US`、`ja-JP`。首期应做兼容 normalize:
+
+| 旧值 | 新规范值 |
+| ---- | --------------------------------- |
+| `zh` | `zh-CN` |
+| `en` | `en-US` |
+| 空值 | `auto` 或 `zh-CN`,按阶段策略决定 |
+
+### 3.3 语言概念混用风险
+
+Lime 已有多个 language-like 场景:
+
+- UI locale:控制 Lime 界面语言。
+- Response language:控制 Agent 默认回复语言。
+- Content target language:控制 Artifact / 文章 / 翻译 / media task 的产物语言。
+- Browser environment locale:控制远程站点看到的 `Accept-Language`、timezone、locale。
+- ASR / dictation language:控制语音识别语言。
+- Code block language:只是语法高亮标签,不是自然语言。
+
+PRD 必须明确这些概念的边界,避免把一个全局 `language` 字段扩成所有场景的隐式事实源。
+
+## 4. 产品目标
+
+### 4.1 总目标
+
+建立 Lime 全球本地化能力层,使新功能默认可翻译、可校验、可持续增加 locale,并让桌面端 GUI 在语言切换后保持稳定可用。
+
+### 4.2 首期语言范围
+
+首期推荐支持:
+
+| Locale | 显示名 | 用途 |
+| ------- | -------- | ------------------------------------------ |
+| `auto` | 跟随系统 | 默认推荐项,按系统/浏览器 locale normalize |
+| `zh-CN` | 简体中文 | Source locale 与默认 fallback |
+| `en-US` | English | 第一海外主语言 |
+| `zh-TW` | 繁體中文 | 中文变体与繁体排版验证 |
+| `ja-JP` | 日本語 | CJK 非中文验证 |
+| `ko-KR` | 한국어 | CJK 非中文验证 |
+
+后续候选:`fr-FR`、`de-DE`、`es-ES`、`pt-BR`、`vi-VN`、`ar`、`fa-IR`。其中 `ar` / `fa-IR` 必须等 RTL 基础设施完成后再进入主支持列表。
+
+### 4.3 用户价值
+
+- 海外用户首次启动能看到可理解的核心路径,而不是只看到中文。
+- 中文用户可以保持中文 UI,同时让 Agent 输出英文、日文或韩文。
+- 创作/翻译任务能显式选择目标语言,不受 UI 语言影响。
+- Browser Runtime 可以继续模拟目标市场环境,不被 UI 语言污染。
+- 新增页面的本地化质量可被脚本和测试机械约束。
+
+## 5. 非目标
+
+1. 不做 Web SEO、locale route、sitemap 或 URL 级语言切换。
+2. 不一次性迁移全部历史页面和角落文案。
+3. 不继续扩大 `src/i18n/legacy-patch/patches/*.json` 作为 current 主机制。
+4. 不在首期引入完整翻译管理平台;自动翻译先以 repo 内 JSON、脚本与 PR workflow 为事实源。
+5. 不把 Browser Runtime 的 `Accept-Language` 绑定到 Lime UI locale。
+6. 不要求 Rust 后端立即清空所有中文错误字符串,但新增 current 主路径应返回 code + params。
+
+## 6. 目标架构
+
+### 6.1 目录结构
+
+建议目标结构:
+
+```text
+src/i18n/
+├── createI18n.ts
+├── format.ts
+├── loadNamespace.ts
+├── locales.ts
+├── provider.tsx
+├── resources/
+│ ├── zh-CN/
+│ │ ├── common.json
+│ │ ├── navigation.json
+│ │ ├── settings.json
+│ │ ├── workspace.json
+│ │ ├── agent.json
+│ │ ├── browser.json
+│ │ ├── artifact.json
+│ │ ├── knowledge.json
+│ │ ├── sceneapp.json
+│ │ └── errors.json
+│ ├── en-US/
+│ ├── zh-TW/
+│ ├── ja-JP/
+│ └── ko-KR/
+├── legacy-patch/
+│ ├── I18nPatchProvider.tsx
+│ ├── dom-replacer.ts
+│ └── patches/
+└── types.d.ts
+```
+
+迁移原则:
+
+- `resources/zh-CN` 是 source locale。
+- 新 UI 只允许使用 `useTranslation(ns)` / `Trans` / `t()`,不新增 Patch key。
+- `legacy-patch` 只覆盖未迁移历史 UI,并且需要退出条件。
+- namespace 内推荐继续使用扁平 dotted key,并设置 `keySeparator: false`,避免深层对象重排带来的翻译 diff 噪音。
+
+### 6.2 Locale registry
+
+`src/i18n/locales.ts` 负责:
+
+- `SUPPORTED_LOCALES`
+- `SOURCE_LOCALE = "zh-CN"`
+- `FALLBACK_LOCALE = "zh-CN"`
+- `localeOptions`
+- `normalizeLocale(input?: string): Locale`
+- `resolveAutoLocale(systemLocale?: string): Locale`
+- `isRtlLocale(locale): boolean`
+- 兼容旧值 `zh` / `en`
+
+normalize 规则:
+
+1. 空值或 `auto`:读取系统/浏览器 locale,无法识别时落到 `zh-CN`。
+2. `zh`、`zh-Hans`、`cn`:归一到 `zh-CN`。
+3. `zh-Hant`、`zh-TW`、`zh-HK`:首期归一到 `zh-TW`。
+4. `en`、`en-*`:首期归一到 `en-US`。
+5. `ja`、`ko`:分别归一到 `ja-JP`、`ko-KR`。
+6. 未支持 locale:落到 `zh-CN`,并记录 debug warning。
+
+### 6.3 i18next 初始化
+
+`createI18n.ts` 负责创建单例或 scoped instance:
+
+- `fallbackLng: "zh-CN"`
+- `defaultNS: "common"`
+- `ns` 不一次性声明所有 namespace,按核心和业务分层加载。
+- `react.useSuspense = false`,避免桌面端切换语言时出现大面积 loading。
+- 核心 namespace eager preload:`common`、`navigation`、`settings`、`workspace`、`agent`、`errors`。
+- 非核心 namespace lazy load:`sceneapp`、`knowledge`、`artifact`、`browser`、`media`、`voice` 等。
+- `languageChanged` 时同步:
+ - `document.documentElement.lang`
+ - `document.documentElement.dir`
+ - `dayjs.locale` 或统一 date adapter
+ - format cache invalidation
+
+### 6.4 Namespace loading
+
+`loadNamespace.ts` 采用 Vite `import.meta.glob`:
+
+- Web/dev 模式可 lazy load:`import.meta.glob("./resources/*/*.json")`
+- 桌面稳定模式可 eager load 核心资源,避免 Tauri 首屏懒加载抖动。
+- namespace 缺失时必须回落到 `zh-CN`,并输出可测试的 warning。
+- 资源加载失败不能让 App 白屏,最多显示 source locale 文案。
+
+### 6.5 类型安全
+
+`types.d.ts` 使用 i18next `CustomTypeOptions` 绑定 resources:
+
+- `defaultNS: "common"`
+- migrated namespaces 进入类型检查。
+- 允许迁移期对未迁移 namespace 使用宽类型,但 current 主路径不应长期停留宽类型。
+- P1 不强制启用 i18next selector API;P3 再结合资源规模、TypeScript 内存表现与官方 `@i18next-selector/codemod` 评估从 string key 迁到 selector key。
+- 类型绑定只覆盖已经迁移且 key 稳定的 namespace,避免把还在快速变动的 legacy 文案提前固化成长期类型债。
+
+### 6.6 统一格式化 API
+
+`format.ts` 提供统一封装:
+
+- `formatDate(value, options)` -> `Intl.DateTimeFormat(locale, options)`
+- `formatNumber(value, options)` -> `Intl.NumberFormat(locale, options)`
+- `formatRelativeTime(value, unit, options)` -> `Intl.RelativeTimeFormat(locale, options)`
+- `formatList(values, options)` -> `Intl.ListFormat(locale, options)`,如运行环境支持
+- `localeCompare(left, right, options)` -> `left.localeCompare(right, locale, options)`
+
+规则:
+
+- 不在翻译 JSON 里手写“分钟前 / days ago”这类时间单位。
+- 不直接使用 `undefined` locale,除非明确是 `auto` 语义。
+- 所有用户可见列表排序需要显式考虑 locale。
+
+## 7. 产品边界
+
+### 7.1 UI language
+
+定义:控制 Lime GUI 文案、方向、日期数字展示和组件可读性。
+
+事实源:首期复用 `Config.language`,但值迁移到 BCP 47 风格 locale。
+
+设置入口:设置页语言选择器,推荐选项为 `auto`、`zh-CN`、`en-US`、`zh-TW`、`ja-JP`、`ko-KR`。
+
+实现约束:
+
+- 不新增 Tauri 命令,复用 `get_config` / `save_config`。
+- `zh` / `en` 只作为读兼容,不作为新写入值。
+- 切换语言后无需重启;首期可接受局部组件重新渲染。
+- 设置页属于表单型设置页,应遵守 Lime 现有轻盈、清晰、专业的视觉语言,不新增营销式 hero 或高饱和背景。
+
+### 7.2 AI response language
+
+定义:控制 Agent 默认回复语言,不控制 UI。
+
+推荐事实源:首期可新增到 `workspace_preferences.response_language` 或等价用户偏好字段;落点需在实施设计里结合现有 Agent config 最小化 schema 扩散。
+
+产品规则:
+
+- 默认 `auto`:跟随用户最近输入语言或 UI locale,具体算法需在实现文档中定义。
+- 用户可以设置固定值,例如 `en-US`、`ja-JP`。
+- Agent request metadata 应携带 response language,模型系统提示应显式说明。
+- 不能把 UI locale 当成唯一回复语言事实源。
+
+### 7.3 Content target language
+
+定义:控制某次创作、翻译、Artifact、media task 的产物语言。
+
+规则:
+
+- 必须是任务级参数或文档级元数据。
+- 不默认写回 UI language。
+- SceneApp / Artifact / media task 中已有 `target_language`、`language` 字段时,应统一命名和说明,避免一处表示 UI,一处表示产物。
+
+### 7.4 Browser environment language
+
+定义:控制浏览器访问外站时暴露给网站的 locale、`Accept-Language`、timezone 等环境。
+
+规则:
+
+- 继续由 Browser Environment preset 管理。
+- 不随 UI language 自动改变。
+- 设置文案中要明确“站点环境语言”和“Lime 界面语言”不是一个概念。
+
+### 7.5 Rust / Tauri 用户可见错误
+
+目标方向:Rust 返回结构化错误,前端翻译最终用户文案。
+
+推荐形态:
+
+```ts
+type LocalizedErrorPayload = {
+ code: string;
+ params?: Record;
+ fallbackMessage?: string;
+};
+```
+
+迁移原则:
+
+- 新 current 主路径优先返回 `code + params + fallbackMessage`。
+- 前端 `errors` namespace 负责展示。
+- 历史中文错误不在首期全量改造,但新增命令不得继续扩大中文长文错误面。
+- 涉及 Tauri 命令边界时必须遵守四侧同步:前端调用、Rust 注册、治理目录册、mock。
+
+## 8. 迁移路线
+
+### P0:骨架与兼容层
+
+目标:建立 key-based i18n 的最小可运行骨架,不破坏现有页面。
+
+交付:
+
+1. 新增 locale registry 与 normalize。
+2. 新增 `createI18n`、provider、核心 resources。
+3. 将现有 Patch Layer 移到 `legacy-patch` 或明确标注 legacy boundary。
+4. `Config.language` 读取兼容 `zh` / `en`,写入新 locale。
+5. `document.documentElement.lang` 与 `dir` 同步。
+6. 修复或替换失效的 `detect-translations*` 脚本入口。
+
+验收:
+
+- App 能以 `zh-CN` / `en-US` 启动。
+- 未迁移页面仍可由 Patch Layer 兜底。
+- 新 provider 不造成首屏白屏或明显闪烁。
+- 单测覆盖 normalize 与 fallback。
+
+### P1:设置页与主导航迁移
+
+目标:让用户真实切换 UI language,并覆盖主路径首屏。
+
+交付:
+
+1. 设置页语言选择器迁移到 key-based i18n。
+2. 侧栏、顶部栏、主导航、Workspace shell、空态、基础按钮迁移。
+3. `common`、`navigation`、`settings`、`workspace`、`errors` namespace 建立 source + en-US。
+4. 新增 UI 回归测试,覆盖语言选择、持久化、关键文案。
+
+验收:
+
+- 切换语言后设置页、侧栏、Workspace shell 立即更新。
+- 重启后保留选择。
+- GUI smoke 覆盖默认 workspace 准备态。
+
+### P2:Agent / Artifact / Browser / Knowledge 主路径
+
+目标:把 Lime 的核心产品能力从“能看懂界面”推进到“能按语言工作”。
+
+交付:
+
+1. Agent Chat 主路径迁移 `agent` namespace。
+2. 增加 AI response language 设置与 request metadata 注入。
+3. Artifact / 文档 / 文章 / 翻译类任务明确 content target language。
+4. Browser Environment 设置页文案明确 `Accept-Language` 与 UI language 的差异。
+5. Knowledge / SceneApp / Browser / Artifact 主要入口迁移对应 namespace。
+6. 用户可见 toast / error 进入 `errors` namespace。
+
+验收:
+
+- UI 中文、Agent 英文回复的组合可用。
+- UI 英文、Browser preset 为日区/美区的组合不互相污染。
+- Artifact 目标语言不因 UI 切换而改变。
+- 关键错误能通过 error code 翻译展示。
+
+### P3:自动化与治理
+
+目标:让新增 locale、检查漏翻、清理废 key 变成可重复流程。
+
+交付:
+
+1. 参考 LobeHub 建立 `scripts/i18n/*`:
+ - source locale 导出
+ - missing key 检查
+ - unused key 分析
+ - protected dynamic key pattern
+ - 翻译覆盖率报告
+2. 评估官方 `i18next-cli` 作为抽取 / hardcoded string lint / locale sync / type generation 的默认候选;AI 自动翻译部分再对比 `@lobehub/i18n-cli` 与 Lime 自研脚本,首选能最少改动并可 CI 运行的方案。
+3. 建立 glossary:产品名、功能名、Agent 术语、Browser Runtime 术语、SceneApp 术语。
+4. 自动翻译只创建 PR,不直接覆盖 source locale。
+5. PR 模板要求标注新增/变更文案的 namespace。
+6. 资源规模扩大后补 bundle 体积与 chunk 策略报告;参考 LobeHub 把核心 namespace 内联、重 namespace 独立切块,避免桌面首屏被非核心 locale 资源拖慢。
+
+验收:
+
+- `npm run i18n:check` 能在本地发现漏 key 与无效 locale。
+- `npm run verify:local` 或质量选择器能覆盖 i18n 结构风险。
+- 翻译 PR 可审阅、可回滚、不会覆盖人工修订。
+
+### P4:扩展与发布材料
+
+目标:把 i18n 能力从桌面主 App 扩展到生态与发布链路。
+
+交付:
+
+1. Chrome extension 评估是否迁移到 `_locales/messages.json` 标准结构。
+2. 发布说明、官网文档、帮助文档进入独立翻译 workflow。
+3. 引入 RTL locale 前完成布局审计、截图回归与 Playwright smoke。
+4. 多平台 installer / app metadata 本地化评估。
+
+验收:
+
+- extension 与桌面 App 的术语一致。
+- RTL 不破坏设置页、侧栏、Workspace、弹窗主路径。
+- 发布材料至少覆盖 `zh-CN` / `en-US`。
+
+## 9. 质量门禁
+
+### 9.1 新代码规则
+
+- 新 UI 文案必须进入 namespace resources。
+- 禁止在 current 主路径继续新增 `legacy-patch/patches/*.json` key。
+- 允许中文注释继续遵守仓库规则,但用户可见文案不得散落在组件里。
+- 动态文案使用 interpolation,不使用字符串拼接。
+- 富文本或带链接文案使用 `Trans`。
+- 数量相关文案使用 plural,不手写 `count === 1` 的英文分支。
+- 错误展示走 `errors` namespace。
+
+### 9.2 验证入口
+
+按 Lime 质量工作流:
+
+| 改动类型 | 最低验证 |
+| ---------------------------------------- | ------------------------------------------------------------------ |
+| 仅改 PRD / 文档 | 读回检查即可 |
+| i18n resource / 前端普通迁移 | `npm run verify:local` + i18n 定向测试 |
+| 设置页 / 主导航 / Workspace 可见改动 | `npm run verify:local` + 稳定 UI 回归 + `npm run verify:gui-smoke` |
+| Tauri config schema / 命令 / Bridge 变化 | `npm run verify:local` + `npm run test:contracts` |
+| Rust 错误结构变化 | Rust 定向测试 + 前端错误展示回归 + contracts |
+| RTL 或主路径交互变化 | GUI smoke 后进入 Playwright 续测 |
+
+### 9.3 关键回归场景
+
+1. 首次启动:`auto` 能解析到受支持 locale,无法解析则 fallback。
+2. 设置页切换:语言立即生效、持久化、重启后保留。
+3. Fallback:某 locale 缺少 key 时显示 `zh-CN`,不白屏。
+4. `Trans`:带链接/代码/强调的文案在各语言正常渲染。
+5. Interpolation:变量顺序在英文、日文、韩文中可调整。
+6. Plural:英文复数和中文无复数都能正确展示。
+7. Format:日期、数字、相对时间随 UI locale 变化。
+8. Agent:UI locale 与 response language 分离。
+9. Browser:UI locale 与 `Accept-Language` preset 分离。
+10. Patch fallback:未迁移历史页面不阻塞主路径。
+
+## 10. 指标
+
+### 10.1 产品指标
+
+- 首期核心路径 key-based 覆盖率:P1 达到 80%,P2 达到 95%。
+- 设置页语言切换成功率:本地 smoke 100%。
+- 首屏语言闪烁:核心 namespace 预加载后不出现明显中文/英文闪回。
+- 用户可见 hard-coded 文案新增数:current 主路径为 0。
+
+### 10.2 工程指标
+
+- resources 中 `zh-CN` / `en-US` 缺 key 数为 0。
+- i18n check 可在本地与 CI 复用。
+- Patch Layer 命中量逐阶段下降,并可在 P3 后输出报告。
+- 自动翻译 PR 不覆盖人工修改。
+
+## 11. 风险与决策
+
+### 11.1 Source locale 选择
+
+推荐 `zh-CN` 作为 source locale。
+
+理由:Lime 当前产品、文档、注释和用户主路径以中文为事实源;强行改成 `en-US` 会扩大迁移范围。LobeHub 使用 `en-US` 是可参考的成熟形态,但不应机械照搬。
+
+风险:从中文自动翻译到多语言时,需要更强 glossary 与人工 review。
+
+### 11.2 Lazy vs eager load
+
+推荐混合策略:核心 namespace eager,业务 namespace lazy。桌面端如果出现运行时加载抖动,可参考 LobeHub desktop loader 使用 `{ eager: true }`。
+
+### 11.3 Patch Layer 退出条件
+
+Patch Layer 不能无限期作为事实源。建议退出条件:
+
+- P2 后禁止新增 patch key。
+- P3 后输出 Patch 命中量报告。
+- 核心路径迁移完成后,仅允许 legacy 页面临时保留。
+- 当剩余 Patch key 低于约 10% 且无 current 主路径依赖时,拆除 DOM replacer。
+
+### 11.4 自动翻译质量
+
+自动翻译只能降低初稿成本,不能替代 review。必须维护 glossary 与 protected key pattern,并通过 PR 审核进入主分支。
+
+### 11.5 配置迁移
+
+`Config.language` 已存在,首期不新增命令。若新增 response language 字段,必须同步 Rust schema、前端类型、默认 mock、配置文档和验证入口。
+
+## 12. 里程碑建议
+
+| 阶段 | 目标 | 预计收益 |
+| ---- | -------------------------- | ------------------------ |
+| P0 | 建骨架,保兼容 | 新旧 i18n 并存但边界清晰 |
+| P1 | 设置与 GUI shell | 用户能真实切换语言 |
+| P2 | Agent / Browser / Artifact | 本地化进入核心产品能力 |
+| P3 | 自动化治理 | 新增语言可持续、可校验 |
+| P4 | 扩展与发布材料 | 全球发布闭环 |
+
+## 13. 第一刀建议
+
+第一刀不要直接翻译全仓,也不要先接自动翻译。建议先做最小 current skeleton:
+
+1. 建 `src/i18n/locales.ts`,完成 locale registry 与 normalize 单测。
+2. 建 `src/i18n/resources/zh-CN` 与 `en-US` 的核心 namespace 最小集。
+3. 建 `createI18n.ts` / provider,替换根部 i18next 初始化,但继续包住 legacy Patch Layer。
+4. 设置页新增 UI language 选择,复用 `getConfig` / `saveConfig`。
+5. 补 `settings` 与 `navigation` 的稳定回归,再跑 `npm run verify:local` 与 `npm run verify:gui-smoke`。
+
+这一刀的价值是把事实源从 DOM Patch 拉回 key-based resources,同时不要求一次性迁移全仓。
+
+## 14. 当前第一刀落地状态
+
+> 记录日期:2026-05-10
+> 口径:本节只记录 P0/P1 起步骨架,不代表全仓本地化完成。
+
+已落地:
+
+1. `src/i18n/locales.ts` 已建立 locale registry,支持 `auto`、`zh-CN`、`en-US`、`zh-TW`、`ja-JP`、`ko-KR`,并兼容旧值 `zh` / `en`。
+2. `src/i18n/createI18n.ts` 已把 i18next current 主路径初始化到 key-based resources,核心 namespace 包含 `common`、`navigation`、`settings`、`workspace`、`agent`、`errors`。
+3. `src/i18n/loadNamespace.ts` 已使用 Vite `import.meta.glob` 建立 bundled core namespace loader,并由 `createI18n.ts` 消费。
+4. `src/i18n/format.ts` 已建立日期、数字、相对时间、列表与 locale-sensitive sort 的统一封装。
+5. `src/i18n/resources//*.json` 已建立核心资源骨架;`settings` 与基础 `common` key 可支撑设置页和语言选择首期迁移。
+6. `src/i18n/config.ts` 与 `src/i18n/withI18nPatch.tsx` 已接入 current i18next,同时保留 DOM Patch 作为 legacy fallback。
+7. 设置页外观分组与侧边栏语言选择已改为写入 BCP 47 locale,并同步 legacy Patch 的 `"zh" | "en"` 兼容语言。
+8. 设置页 About 区块已从自然语言 key 迁移到 `settings.about.*` namespace key,并补齐 5 个 locale 资源。
+9. 设置页 Profile 区块已迁移到 `settings.profile.*` namespace key,字段说明、状态、头像提示、偏好标签和资料使用说明均进入 5 个 locale 资源。
+10. 设置页 Stats 区块已迁移到 `settings.stats.*` namespace key,时间范围、概览、模型排行、趋势图和活跃日历文案均进入 5 个 locale 资源,并开始使用 `format.ts` 的 locale-aware 日期格式化。
+11. `Config.language` 默认值与注释已从旧 `"zh" / "en"` 语义迁移到 BCP 47 / `auto` 语义;Rust 默认 UI locale 改为 `zh-CN`,前端类型和 mock 默认配置同步说明,legacy `"zh" / "en"` 仅保留为读兼容。
+12. 设置页 `_layout` 共享壳层已迁移到 `settings.layout.*` namespace key,顶部回首页按钮、所有 lazy fallback 文案和 not-found 占位文案均进入 5 个 locale 资源。
+13. `.gitignore` 已放行 `docs/roadmap/i18n/*.md`,确保本 PRD 可作为版本化工件进入仓库。
+14. Patch Layer 已物理搬入 `src/i18n/legacy-patch/`,根层只保留 current i18next、resources、format、locale registry 与 bootstrap;旧 DOM Patch 路径正式归类为迁移期 compatibility/deprecated surface。
+15. `src/lib/governance/legacySurfaceCatalog.json` 已把旧根层 Patch 文件纳入 `i18n-dynamic-template-legacy-surface` dead-candidate 守卫,防止 `@/i18n/I18nPatchProvider`、`@/i18n/text-map` 与根层 `patches/*` 回流。
+16. 侧边栏主导航、底部系统入口、导航折叠 / 展开、搜索任务、插件扩展分组与语言二级菜单的可见文案已迁入 `navigation` namespace;切换到 `en-US` 后主导航会显示 `New Task` / `Project Knowledge` 等 key-based 文案,不再依赖 DOM Patch。
+17. 侧栏搜索弹窗与会话列表起步文案已迁入 `navigation` namespace,覆盖搜索输入 / 空态 / 加载 / 更多按钮、新建对话、最近 / 归档分组、会话操作菜单、多选 toolbar、收藏 badge 与操作菜单 aria/title 文案。
+18. 会话列表与搜索结果的空标题兜底、更新时间 meta、归档 meta 已改为 current i18n + `Intl.RelativeTimeFormat` / `Intl.DateTimeFormat`,英文界面会显示 `Untitled conversation`、`2m ago`、`Archived 3h ago` 等 locale-aware 文案,不再由 `sidebarSessionFormatting.ts` 手写中文单位。
+19. 会话重命名 prompt、删除 confirm、重命名 / 删除 toast 已迁入 `navigation` namespace;英文界面删除空标题会话时会展示 `Delete "Untitled conversation"? This cannot be undone.`,并继续复用同一套会话标题兜底。
+20. 账号菜单的未登录开源卡片、云端状态、连接云端、用户中心、模型设置、关于、退出登录、云端登录 / 用户中心 toast 已迁入 `navigation` namespace;英文界面未登录账号菜单会显示 `Local ready`、`Open Source Use`、`Connect Lime Cloud` 等 key-based 文案。
+21. `src/i18n/types.d.ts` 已接入 i18next `CustomTypeOptions`,把 `zh-CN` source resource 绑定到 `common`、`navigation`、`settings`、`workspace`、`agent`、`errors` namespace,并保持 `keySeparator: false` 的 flat dotted key 策略;`src/i18n/__tests__/types.test.ts` 用 `@ts-expect-error` 覆盖缺失 key,确保 `tsc` 能发现未登记 key。
+22. `src/i18n/legacy-patch/dom-replacer.ts` 已补充 Patch 命中量指标基础 API,记录每次 DOM Patch 的 language、root kind、耗时、替换节点数和命中文本段数,并提供 `getI18nPatchMetricsReport()` / `resetI18nPatchMetrics()`,作为 P3 Patch 退出报告的数据来源;`I18nPatchProvider` 的语言切换计数也改为复用同一指标入口。
+23. `scripts/i18n-patch-metrics-report.mjs` 与 `scripts/lib/i18n-patch-metrics-report-core.mjs` 已把导出的 Patch runtime metrics 转成稳定 text / JSON 报告,支持 `--check` 与 `maxMatchedSegments` / `maxReplacedNodes` / `maxRuns` 门限,`package.json` 已新增 `npm run i18n:patch-report` 与 `npm run i18n:patch-report:json`。
+24. `npm run verify:gui-smoke` 已把 `smoke:knowledge-gui` 接成 Patch metrics 真实 GUI 样本导出入口,默认写入 `.lime/i18n/patch-metrics.json` 并生成 `.lime/i18n/patch-metrics-report.json`;调试无关 GUI smoke 失败时可用 `--skip-i18n-patch-metrics` 跳过。
+25. 设置页 Home 首页已迁移到 `settings.home.*` namespace key,覆盖首页 hero、统计 chip、常用入口、桌宠入口、current 入口卡、分组说明与 item 状态文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。
+26. 设置页 Developer Lab 合并页已迁移到 `settings.developerLab.*` namespace key,覆盖合并页标题、说明、开发者工具 / 实验功能 tab 文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。
+27. 设置页 Skills 高级入口说明已迁移到 `settings.agent.skills.*` namespace key,覆盖高级技能入口标题、Tips aria / 内容和问题反馈链接文案,并补齐 5 个 locale 资源;`SkillsPage` 主体仍按独立 Skills 工作台后续迁移,不在本条完成范围内。
+28. 设置页 Hotkeys 已迁移到 `settings.hotkeys.*` namespace key,覆盖页面 hero、状态 chip、错误态、分区摘要、快捷键条目、scope/source/condition 元信息和 `hotkeyCatalog` 共享状态文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。
+29. 设置页图片 / 视频服务模型已迁移到 `settings.mediaGeneration.*` namespace key,覆盖 ImageGen / VideoGen 页面标题、说明、Provider 不可用提示、保存结果、空态、回退策略与共享 `MediaPreferenceSection` 的 Tips aria、恢复默认和自动选择文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。
+30. 设置页 Service Models 总页 `agent/media-services` 已迁移到 `settings.mediaServices.*` namespace key,覆盖服务模型总览、8 个服务模型分区、当前行为说明、服务模型空态、自定义提示词、AI 图片默认数量与保存结果文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。
+31. 设置页 Voice 区块 `agent/voice` 已迁移到 `settings.voice.*` namespace key,覆盖语音输入、Fn / 翻译模式快捷键状态、本地 SenseVoice 模型下载与测试转写、语音处理、语音服务模型和所有保存 / 失败提示文案,并补齐 5 个 locale 资源;测试通过稳定 `useTranslation("settings")` mock 保持 11 个交互回归。
+32. 设置页 Providers 内的 `CompanionCapabilityPreferencesCard` 已迁移到 `settings.providers.companion.preference.*` namespace key,覆盖桌宠能力偏好标题说明、当前主链摘要、桌宠通用模型选择、回退策略、空态、重置按钮和保存 / 失败提示文案,并补齐 5 个 locale 资源;测试通过稳定 `useTranslation("settings")` mock 保持独立卡片回归。
+33. 设置页 Providers 主体入口已开始迁移到 `settings.providers.*` namespace key,当前覆盖 workspace 顶部切换器的服务商设置 / 云端服务 / 桌宠管理标签与摘要、云端品牌 fallback、登录页 / 用户中心打开成功和失败反馈文案。
+34. 设置页 Providers 的 Companion Bridge 诊断区已迁移到 `settings.providers.companion.bridge.*` namespace key,覆盖桌宠连接状态、能力声明、桥接摘要、接入检查、同步建议、脱敏预览、安装引导、刷新 / 开启 / 同步反馈和 Tips aria 文案,并把最近同步时间改为复用 locale-aware `formatDate()`;当前 5 个 locale 均补齐 112 个 bridge key。
+35. 设置页 Account / User Center Session 已迁移到 `settings.userCenterSession.*` namespace key,覆盖账户资料 hero、登录状态 chip、云端服务未配置 / 恢复态、会话摘要、账号中心资料维护说明、Google 登录、浏览器授权同步说明、备用邮箱验证码 / 账号密码登录表单和所有输入占位 / 操作按钮文案,并补齐 5 个 locale 资源。
+36. 设置页 System / Developer 已迁移到 `settings.developer.*` namespace key,覆盖开发者页标题、调试状态 chip、工作台 / 组件调试开关、诊断日志操作、懒加载 fallback、服务型技能目录、站点脚本目录与 Workspace 自愈记录入口文案;本条只迁移开发者页壳层,懒加载子工具自身文案按独立组件后续迁移。
+37. 设置页 System / shared `WorkspaceRepairHistoryCard` 已迁移到 `settings.system.workspaceRepair.*` namespace key,覆盖默认标题说明、记录计数、刷新 / 清空 / 复制操作、空态、来源标签、复制成功 / 失败 / 权限提示和 locale-aware 时间展示;底层 `workspaceHealthTelemetry` 摘要构造仍保持原有诊断事实源,不在本条扩大迁移。
+38. 根 `AGENTS.md` 与 `docs/aiprompts/quality-workflow.md` 已新增 current i18n 规则:后续新功能的按钮、标题、空态、toast、confirm、prompt、placeholder、aria/title 与错误提示必须进入 key-based resources,legacy DOM Patch 只允许作为迁移期兜底,不得作为新功能本地化事实源。
+39. 设置页 System / Channels 的 `ChannelLogTailPanel` 已迁移到 `settings.channels.logTail.*` namespace key,覆盖日志 Tail 标题说明、暂停 / 继续、复制视图、清空日志、过滤模式、自定义正则、复制 / 清空反馈、确认弹窗、加载 / 空态与错误提示,并把日志时间展示改为 locale-aware;底层 channels runtime / log API 保持原事实源,不在本条扩大命令边界。
+40. 设置页 System / Channels 的 `ChannelsDebugWorkbench` 外层工作台壳已迁移到 `settings.channels.workbench.*` namespace key,覆盖日志与检查标题说明、当前查看范围、收口说明、网关 / 日志子页入口、网关与隧道提示、当前摘要、日志排查提示、运行分区 tab、底部未保存栏和保存结果文案;内部网关表单、运行控制与微信兼容扫码排障等长表单文案仍按后续独立小刀迁移。
+41. 设置页 System / Channels 的 `GatewayTunnelPanel` 网关与隧道表单已迁移到 `settings.channels.gatewayTunnel.*` namespace key,覆盖公共隧道标题说明、表单字段、placeholder、ngrok 预留选项、操作按钮、cloudflared 系统安装危险确认、执行中状态和最近结果空态;底层 `gatewayTunnel*` runtime 命令和系统安装确认流程保持原行为,不在本条扩大命令边界。
+42. 设置页 System / Web Search 已迁移到 `settings.webSearch.*` namespace key,覆盖页面 hero、搜索链路、Provider 凭证、MSE 聚合、图片搜索、观测面板、密钥显示 / 隐藏、外链操作、toast / error、sticky footer、tip aria 与动态状态文案;本条只迁移 Web Search 设置页 UI 文案,底层搜索 provider / app config 命令边界保持原事实源。
+43. 设置页 System / Developer 的目录联调懒加载工具已迁移到 `settings.developer.serviceSkillCatalog.*` 与 `settings.developer.siteAdapterCatalog.*` namespace key,覆盖服务型技能目录、站点脚本目录、外部 YAML 导入、Bootstrap Payload 调试、动态状态、示例 placeholder、aria 与操作反馈;本条只迁移 Developer 页的 catalog 联调子工具,底层 crash diagnostic 文案仍按后续共享诊断面收口。
+44. 设置页 System / shared `ClipboardPermissionGuideCard` 已迁移到 `settings.system.clipboardPermission.*` namespace key,覆盖 macOS / Windows / Linux / generic 四类剪贴板权限指引、打开系统设置按钮和打开失败提示;底层 crash diagnostic 复制失败消息、导出提示词和诊断摘要已在条目 48 收口到 `errors.crashDiagnostic.*`。
+45. 全局 `CrashRecoveryPanel` 恢复模式外壳已迁移到 `errors.crashRecovery.*` namespace key,覆盖恢复模式标题说明、最近错误、模块资源加载失败提示、诊断复制 / 导出 / 清理 / 下载目录操作、workspace 路径修复反馈和强制刷新资源入口;底层 `crashDiagnostic.ts` 构造的诊断正文、复制失败归一化消息和下载目录 fallback 已在条目 48 收口到 `errors.crashDiagnostic.*`。
+46. 设置页 System / Channels 的 Telegram / Feishu Gateway 运行控制已迁移到 `settings.channels.gatewayRuntime.*` namespace key,覆盖运行控制标题说明、账号 ID、轮询超时、状态查询 / 启动 / 停止 / 重启按钮、执行中状态、成功 / 失败反馈、stop warning 与最近结果空态;底层 `gatewayChannel*` runtime 命令保持原事实源,不在本条扩大命令边界。
+47. 设置页 System / Channels 的微信 Gateway 运行控制与兼容扫码排障已迁移到 `settings.channels.wechatRuntime.*` namespace key,覆盖微信运行控制标题说明、账号 / Base URL / Bot Type / 登录参数字段、列出账号 / 生成二维码 / 等待登录 / 删除账号操作、危险确认、二维码状态、账号目录、运行状态、轮询时间与最近结果空态;底层 `wechatChannel*` / `gatewayChannel*` runtime 命令保持原事实源,不在本条扩大命令边界。
+48. 共享 `src/lib/crashDiagnostic.ts` 底层诊断文案已迁移到 `errors.crashDiagnostic.*` namespace key,覆盖诊断采集说明、AI 诊断提示词正文、自动摘要标签、复制失败归一化消息、剪贴板权限指引和下载目录 fallback;诊断 payload 字段名、runtime 命令与日志采集事实源保持原结构,不在本条扩大协议边界。
+49. 设置页 System / Chrome Relay 的独立连接引导窗口 `BrowserConnectorGuideWindow` 已迁移到 `settings.chromeRelay.guide.*` namespace key,覆盖扩展安装引导、CDP 直连引导、状态标签、复制 / 打开 / 同步操作、错误反馈、剪贴板提示和源码目录警告;底层浏览器连接器、扩展安装、远程调试和开窗命令保持原事实源,不在本条扩大命令边界。
+50. 设置页 General / Memory 的首屏 hero、记忆总开关与偏好画像问卷已迁移到 `settings.memory.*` namespace key,覆盖状态 chip、画像完成度、来源命中摘要、保存操作、问卷题目、选项标签与选择状态;本条只覆盖记忆页首屏与画像问卷,来源链、memdir 写入、自动索引与工作记忆长尾区块仍按后续独立小刀迁移。
+51. 设置页 System / Chrome Relay 主设置页核心首屏已迁移到 `settings.chromeRelay.main.*` namespace key,覆盖浏览器列表、系统环境、Chrome 连接能力、扩展连接 / CDP 直连入口、连接器安装状态、常用操作按钮、核心 toast / error、剪贴板反馈和高级工具入口;本条只覆盖主设置页核心浏览器列表与共享操作反馈,高级工具内的 Profile / Bridge / Backend / Debug 长尾面板仍按后续独立小刀迁移。
+52. 设置页 System / Chrome Relay 主设置页高级工具起步区已迁移到 `settings.chromeRelay.main.*` namespace key,覆盖高级控制壳层、Overview 卡片、Profile 会话面板、使用建议、实时调试面板、tab 标签、后端类型 label / description 与相关操作文案;本条仍未覆盖高级工具里的连接方式卡、系统连接器、扩展桥接详情、后端策略详情和浏览器动作配置长尾。
+53. 设置页 System / Automation 的当前焦点条与概览焦点卡已迁移到 `settings.automation.focus.*` namespace key,覆盖“现在先继续这条”标题 / badge、空态、加载态、最近结果、下一步摘要和查看结果 / 治理 / 详情操作文案;本条只覆盖 Automation 当前焦点条与概览焦点卡,调度器、运行历史、Job Dialog、Details Dialog 与 HealthPanel 长尾仍按后续独立小刀迁移。
+54. 设置页 System / Chrome Relay 主设置页高级工具连接方式卡已迁移到 `settings.chromeRelay.main.connectionMethod.*` namespace key,覆盖连接方式标题说明、浏览器扩展 / CDP 直连卡片、推荐 / 待完成 / 已就绪 / 待接入状态、扩展页与远程调试快捷入口、复制配置和断开扩展操作文案;本条只覆盖高级工具顶部连接方式卡,系统连接器、扩展桥接详情、后端策略详情和浏览器动作配置长尾仍按后续独立小刀迁移。
+55. 设置页 System / Automation 的 `AutomationHealthPanel` 已迁移到 `settings.automation.health.*` namespace key,覆盖风险提醒标题说明、轮询状态、累计执行、汇总 pill、最近 / 下次轮询、风险任务失败重试、冷却 / 更新时间、状态枚举和空态;时间展示已从硬编码 `toLocaleString("zh-CN")` 收口到 `src/i18n/format.ts` 的 `formatDate()`。
+56. 设置页 System / Chrome Relay 主设置页高级工具系统连接器卡已迁移到 `settings.chromeRelay.main.systemConnector.*` namespace key,覆盖系统连接器说明、启用计数、平台 / 授权 / 启用状态标签、能力列表前缀与切换开关 aria;底层 system connector 的 label / description / capabilities 仍保持 runtime 返回事实源,本条不扩大浏览器连接器命令边界。
+57. 设置页 System / Chrome Relay 主设置页高级工具浏览器动作配置面板已迁移到 `settings.chromeRelay.main.browserAction.*` namespace key,覆盖动作配置标题说明、读取 / 写入分组标题与能力开关 aria;底层 action capability 的 label / description 仍保持 runtime 返回事实源,本条不扩大浏览器动作命令边界。
+58. 设置页 System / Automation 主页面壳层与调度器设置已迁移到 `settings.automation.main.*` / `settings.automation.scheduler.*` namespace key,覆盖页面 hero、加载失败态、刷新 / 新建 / 设置 / 打开入口、顶部状态 pill、tabs、调度器设置卡、调度器保存 toast 与主加载错误 toast;本条不覆盖开始模板、任务列表、运行历史、Job Dialog 与 Details Dialog 长尾。
+59. 设置页 System / Chrome Relay 主设置页高级工具后端策略详情面板已迁移到 `settings.chromeRelay.main.backendPolicy.*` namespace key,覆盖后端策略标题说明、默认测试目标、自动回退、优先级、测试 / 保存 / 刷新按钮、当前可用性、能力等待态、native-host 配置状态与平台支持标签;底层 backend status reason / capabilities 仍保持 runtime 返回事实源,本条不扩大浏览器后端策略命令边界。
+60. 设置页 System / Chrome Relay 主设置页高级工具扩展桥接详情面板已迁移到 `settings.chromeRelay.main.bridge.*` namespace key,覆盖扩展桥接标题说明、桥接服务状态、observer 接入状态、扩展接入信息、复制配置、端点缺失、observer 最近页面 / 未连接 / 空态提示、扩展测试与刷新扩展状态按钮;底层 WebSocket 端点、Bridge Key、client id 与页面标题仍保持 runtime 返回事实源,本条不扩大扩展桥接命令边界。
+61. 设置页 System / Automation 主页面开始模板与任务列表已迁移到 `settings.automation.tasks.*` namespace key,覆盖开始这条卡片、旧浏览器流程下线提醒、三类模板卡与预填默认值、任务列表标题说明、表头、描述兜底、技能流程摘要、状态 badge、下次 / 最近执行、运行 / 详情操作、空态以及创建 / 更新 / 删除 / 立即运行反馈;本条不覆盖运行历史、Job Dialog 与 Details Dialog 长尾。
+62. 设置页 System / Automation 运行历史区已迁移到 `settings.automation.history.*` namespace key,覆盖最近运行标题、刷新按钮、run id / session / 完成时间元信息、技能流程运行上下文、参数摘要、补充要求、输出投递状态、失败原因与空态;Details Dialog 其余头部、输出契约、当前起手内容和 Scene App 结果区仍按后续独立小刀迁移。
+63. Skills 工作台顶部入口、active scaffold 续用条、搜索输入、搜索空态分流与错误横幅已迁移到 `agent` namespace 的 `skills.workspace.*` key,覆盖页头标题 / 副标题、刷新 / 查看全部操作、续用 / 回到生成按钮、搜索 placeholder、推荐 / 本地 Skills 错误提示,以及“结果模板无匹配但右侧有可用 Skill”的空态说明;本条只覆盖 `SkillsWorkspacePage` 的顶部入口与搜索错误起步区,推荐卡片、最近 / 本地 Skills 卡片、能力草稿面板、启动弹窗与 toast 长尾仍按后续独立小刀迁移。
+64. Skills 工作台主体 chrome 已继续迁移到 `agent` namespace 的 `skills.workspace.*` key,覆盖推荐区标题 / 副标题、最近判断横幅、推荐 badge、沿用结果、进入生成、分组详情标题 / 返回 / 空态、分类标题 / 入口、最近侧栏、能力草案 / 已注册能力折叠标题、本地 Skills 侧栏标题 / 调整 / 刚沉淀 / 继续操作、空态以及调整 Skills 弹窗标题与 Tips;本条仍不覆盖推荐卡片内部 runtime 动态摘要、最近 / 本地 Skills 卡片的 runtime 数据、能力草稿面板内部文案、启动弹窗与 toast 长尾。
+65. Skills 工作台事件反馈与生成入口提示已迁移到 `agent` namespace 的 `skills.workspace.*` key,覆盖刷新成功 / 失败 toast、本地 Skill 回到生成 entry banner、Workspace Skill runtime enable 失败与授权 banner、Managed Job 草案创建失败 / 成功 / unsupported mode 文案、Skill scaffold 创建成功 / 默认标题、最近判断 launcher prefill hint,以及结果模板回到生成 entry banner;本条仍不覆盖 `buildWorkspaceRuntimeEnablePrompt()` 这类模型执行提示正文、推荐卡片内部 runtime 动态摘要、能力草稿面板内部和启动弹窗内部文案。
+66. Skills 工作台 helper 动态摘要已继续迁移到 `agent` namespace 的 `skills.workspace.*` key,覆盖能力草稿缺本地目录错误、本地 Skill 最近目标摘要、分类 starter summary、最近判断行动按钮,以及结果基线 compact summary 的 source title / highlight / count 拼接;本条仍不覆盖 `buildWorkspaceRuntimeEnablePrompt()` 这类模型执行提示正文、外部 presentation helper 返回的 runtime 数据、能力草稿面板内部和启动弹窗内部文案。
+67. Skills 工作台已安装 Skill presentation helper 已支持调用方注入本地化兜底文案,并在 `SkillsWorkspacePage` 内接入 `agent` namespace copy,覆盖默认 promise、required inputs、output hint 与“需要 / 交付”前缀;存量非 i18n 调用方仍保持旧中文兜底,避免扩大到 Agent 输入能力选择器,本条只收口 Skills 工作台可见路径。
+
+68. 设置页 System / Automation 的 `AutomationJobDialog` 已迁移到 `settings.automation.jobDialog.*` namespace key,覆盖新建 / 编辑弹窗标题说明、summary pill、基础表单字段、调度提示、旧浏览器流程下线快照、权限模式、输出投递契约、目标地址 placeholder、投递说明、校验错误和底部保存操作;本条只覆盖 Job Dialog,Details Dialog 头部、输出契约、当前起手内容和 Scene App 结果区仍按后续独立小刀迁移。
+
+69. 设置页 System / Automation 的 `AutomationJobDetailsDialog` 长尾已迁移到 `settings.automation.details.*` namespace key,覆盖详情弹窗头部、summary pill、基础元信息、旧浏览器流程下线提示、技能流程上下文、Scene App 回流摘要、输出契约、最近投递结果、当前起手内容、运行历史里的本地时间格式与权限 / 调度 / 投递动态 label;`AutomationJobDetailsDialog.test.tsx` 已补稳定 `useTranslation` mock,避免测试受全局 locale 漂移影响。
+70. 设置页 General / Memory 的来源链状态与策略长尾已迁移到 `settings.memory.source.*` / `settings.memory.layers.*` / `settings.memory.action.refresh` namespace key,覆盖记忆命中层可用性、来源链状态总览、来源链策略、运行时 AGENTS 模板与 `.gitignore` 反馈、来源链命中详情、来源分类和相对更新时间;本条只覆盖来源链与分层状态区,`memdir` 写入、自动索引卡片、校验 / 初始化 / 整理反馈仍按后续独立小刀迁移。
+71. Skills 工作台 ServiceSkill presentation helper 已支持调用方注入本地化 copy,并在 `SkillsWorkspacePage` 内接入 `agent` namespace 的 `skills.workspace.serviceSkill.*` key,覆盖 ServiceSkill 类型、runner label / description、action label、required inputs 摘要、output destination、readiness dependency 与 fact list 省略格式;同时补齐已安装 Skill helper 之前只靠 `defaultValue` 兜底的 `skills.workspace.installedSkill.defaultPromise` / `fallbackRequiredInputs` / `fallbackOutputHint` 资源 key。存量 Agent Chat / Home 等非 Skills 工作台调用方不传 copy 时仍保持旧中文兜底,避免本轮扩大迁移范围。
+
+本轮验证记录:
+
+1. `npm test -- "src/components/settings-v2/system/about/index.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/format.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/withI18nPatch.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "src/components/settings-v2/hooks/useSettingsCategory.test.tsx"` 通过,覆盖 9 个文件、82 个用例。
+2. `npm run lint` 通过。
+3. `npm run typecheck` 通过。
+4. `npm run verify:gui-smoke -- --reuse-running --timeout-ms 300000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Knowledge GUI 与 design canvas smoke。
+5. `npm run detect-translations -- --verbose` 通过,确认 `src/i18n/resources` 下 5 个 locale、6 个 namespace、51 个 source key 结构一致。
+6. `npx eslint "scripts/detect-missing-translations.ts" "scripts/detect-missing-translations.test.ts" --max-warnings 0` 通过,补足仓库默认 `npm run lint` 只扫描 `src` 的脚本校验缺口。
+7. `npm test -- "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts"` 通过,覆盖 bundled resources、namespace 存在性与 fallback。
+8. 2026-05-10 续测:`npm run detect-translations -- --verbose` 通过;`npm test -- "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts" "scripts/detect-missing-translations.test.ts"` 通过,覆盖 4 个文件、13 个用例。
+9. 2026-05-10 续测:`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 曾因当时 `127.0.0.1:3030/health` 无可复用 DevBridge listener 未完成;恢复 headless Tauri / DevBridge 后已在后续记录中复跑通过。
+10. 2026-05-10 Profile 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、126 个 source key 结构一致;`npm test -- "src/components/settings-v2/account/profile/index.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 3 个文件、11 个用例。
+11. 2026-05-10 Profile 迁移续测:`npx eslint "src/components/settings-v2/account/profile/index.tsx" "src/components/settings-v2/account/profile/index.test.tsx" --max-warnings 0` 通过;`npm run lint` 通过;`npm run typecheck` 通过。
+12. 2026-05-10 Profile 迁移续测:`npm test -- "src/components/settings-v2/account/profile/index.test.tsx" "src/components/settings-v2/system/about/index.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts"` 通过,覆盖 8 个文件、85 个用例。
+13. 2026-05-10 Stats 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、202 个 source key 结构一致;`npm test -- "src/components/settings-v2/account/stats/index.test.tsx"` 通过,覆盖 3 个用例。
+14. 2026-05-10 Stats 迁移续测:`npm test -- "src/components/settings-v2/account/profile/index.test.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "src/components/settings-v2/system/about/index.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts"` 通过,覆盖 9 个文件、88 个用例;`npm run lint` 通过;`npm run typecheck` 通过。
+15. 2026-05-10 GUI smoke 复跑:`curl http://127.0.0.1:3030/health` 返回 `status=ok`;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。
+16. 2026-05-10 全量本地验证尝试:`npm run verify:local` 已启动并通过 `verify:app-version`、`lint`、`typecheck` 以及前 17 个 smart vitest 批次;第 18 批在非 i18n 文件 `src/lib/base-setup/seededServiceSkillPackage.test.ts` 失败,原因是当前工作区 seeded service skill 目录项数量从 16 变为 17,而测试仍断言 16。该失败属于已有非本轮 i18n 脏改动口径,未在本轮修复。
+17. 2026-05-10 Config language 迁移续测:`cd src-tauri && cargo test -p lime-core config::` 通过,覆盖 106 个 config 相关测试;`npm test -- "src/i18n/withI18nPatch.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "src/components/settings-v2/account/profile/index.test.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "scripts/detect-missing-translations.test.ts"` 通过,覆盖 6 个文件、74 个用例;`npm run detect-translations -- --verbose`、`npm run lint`、`npm run typecheck` 通过。
+18. 2026-05-10 Settings layout 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、219 个 source key 结构一致;`npm test -- "src/components/settings-v2/_layout/index.test.tsx"` 通过,覆盖 9 个用例;`npx eslint "src/components/settings-v2/_layout/index.tsx" "src/components/settings-v2/_layout/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。
+19. 2026-05-10 Settings 主路径组合续测:`npm test -- "src/components/settings-v2/_layout/index.test.tsx" "src/components/settings-v2/account/profile/index.test.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "src/components/settings-v2/system/about/index.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "src/i18n/withI18nPatch.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts"` 通过,覆盖 11 个文件、98 个用例。
+20. 2026-05-10 Settings layout 迁移后 GUI smoke:`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。
+21. 2026-05-10 Legacy Patch 物理迁移续测:`npm test -- "src/i18n/withI18nPatch.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/config-validation.test.ts" "src/i18n/__tests__/edge-cases.test.ts" "src/i18n/__tests__/translation-coverage.test.ts"` 通过,覆盖 6 个文件、90 个通过用例、1 个跳过用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、219 个 source key 结构一致;`npm run lint` 与 `npm run typecheck` 通过。
+22. 2026-05-10 Legacy Patch 物理迁移后 GUI smoke:`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。
+23. 2026-05-10 Legacy Patch 物理迁移后全量本地验证尝试:`npm run verify:local` 已通过 `verify:app-version`、`lint`、`typecheck` 以及前 17 个 smart vitest 批次;第 18 批仍在非 i18n 文件 `src/lib/base-setup/seededServiceSkillPackage.test.ts` 失败,原因仍是 seeded service skill 目录项数量从 16 变为 17,而测试断言仍为 16。
+24. 2026-05-10 Legacy Patch 根层回流守卫续测:`npm test -- "src/lib/governance/legacySurfaceCatalog.test.ts"` 通过,覆盖 142 个用例;`npm run governance:legacy-report` 通过,摘要为边界违规 0、分类漂移候选 0、零引用候选 0;更新守卫后再次执行 `npm run lint` 通过。
+25. 2026-05-10 Navigation namespace 迁移续测:`npm test -- "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 2 个文件、57 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、239 个 source key 结构一致;`npm run typecheck` 与 `npm run lint` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。
+26. 2026-05-10 Navigation 搜索 / 会话列表续测:`npm test -- "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 2 个文件、57 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、273 个 source key 结构一致;`npm run lint` 与 `npm run typecheck` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。
+27. 2026-05-10 Navigation 会话 meta / prompt / confirm / toast 续测:`npm test -- "src/components/app-sidebar/sidebarSessionFormatting.test.ts" "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 3 个文件、61 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、281 个 source key 结构一致;`npm run lint`、`npm run typecheck` 与 `git diff --check` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。
+28. 2026-05-10 Navigation 账号菜单续测:`npm test -- "src/components/AppSidebar.test.tsx" "src/components/app-sidebar/sidebarSessionFormatting.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 3 个文件、62 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、307 个 source key 结构一致;`npm run lint`、`npm run typecheck` 与 `git diff --check` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。
+29. 2026-05-10 最终调研复核与 PRD 2.9 写入后续测:`git diff --check -- "docs/roadmap/i18n/prd.md"` 通过;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、307 个 source key 结构一致;`npm test -- "src/components/AppSidebar.test.tsx" "src/components/app-sidebar/sidebarSessionFormatting.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 3 个文件、62 个用例;`npm run lint`、`npm run typecheck` 与 `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过。
+30. 2026-05-10 i18next 类型绑定续测:`npm test -- "src/i18n/__tests__/types.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 2 个文件、5 个用例;`npm run typecheck` 通过,确认缺失 key 的 `@ts-expect-error` 仍被类型系统识别;`npm run lint`、`npm run detect-translations -- --verbose` 与 `git diff --check -- "src/i18n/types.d.ts" "src/i18n/__tests__/types.test.ts"` 通过。
+31. 2026-05-10 Legacy Patch 命中量指标续测:`npm test -- "src/i18n/__tests__/legacyPatchMetrics.test.ts" "src/i18n/__tests__/edge-cases.test.ts" "src/i18n/__tests__/types.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 4 个文件、16 个通过用例、1 个跳过用例;`npm run typecheck`、`npm run lint`、`npm run detect-translations -- --verbose` 与 `git diff --check` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过。
+32. 2026-05-10 Legacy Patch metrics 离线报告续测:`npm test -- "scripts/lib/i18n-patch-metrics-report-core.test.ts" "src/i18n/__tests__/legacyPatchMetrics.test.ts" "src/i18n/__tests__/edge-cases.test.ts"` 通过,覆盖 3 个文件、14 个通过用例、1 个跳过用例;临时 metrics JSON 执行 `node scripts/i18n-patch-metrics-report.mjs --format json` 通过并返回 `status=no-hit` / `retirementCandidate=true`;`npm run lint` 与 `git diff --check -- "scripts/lib/i18n-patch-metrics-report-core.mjs" "scripts/lib/i18n-patch-metrics-report-core.test.ts" "scripts/i18n-patch-metrics-report.mjs" "package.json"` 通过。
+33. 2026-05-10 GUI smoke metrics 导出链续测:`node --check "scripts/knowledge-gui-smoke.mjs"` 与 `node --check "scripts/verify-gui-smoke.mjs"` 通过;`npm test -- "scripts/lib/i18n-patch-metrics-report-core.test.ts"` 通过,覆盖 1 个文件、3 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、355 个 source key 结构一致;`git diff --check -- "scripts/knowledge-gui-smoke.mjs" "scripts/verify-gui-smoke.mjs"` 通过。
+34. 2026-05-10 Settings Home 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、355 个 source key 结构一致;`npm test -- "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npm run typecheck`、`npm run lint`、`git diff --check` 与 `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过。
+35. 2026-05-10 Developer Lab 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、359 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/developer-lab/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npm run typecheck`、`npm run lint`、定向 `git diff --check` 与 `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,GUI smoke 同步导出 Patch metrics 且状态仍为 `no-hit`。
+36. 2026-05-10 Settings Skills 高级入口迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、363 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/skills/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npm run typecheck`、`npm run lint` 与 `git diff --check -- "src/components/settings-v2/agent/skills/index.tsx" "src/components/settings-v2/agent/skills/index.test.tsx" "src/i18n/resources/zh-CN/settings.json" "src/i18n/resources/en-US/settings.json" "src/i18n/resources/ja-JP/settings.json" "src/i18n/resources/ko-KR/settings.json" "src/i18n/resources/zh-TW/settings.json"` 通过。
+37. 2026-05-10 Settings Hotkeys 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、476 个 source key 结构一致;`npm test -- "src/components/settings-v2/general/hotkeys/index.test.tsx" "src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、12 个用例;`npm run typecheck`、`npm run lint`、定向 `git diff --check` 与 `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,GUI smoke 同步导出 Patch metrics 且状态仍为 `no-hit`。
+38. 2026-05-10 Settings 图片 / 视频服务模型迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、499 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/image-gen/index.test.tsx" "src/components/settings-v2/agent/video-gen/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、12 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。
+39. 2026-05-10 Settings 图片 / 视频服务模型迁移后 GUI smoke 复跑:`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 连续两次在 `smoke:claw-chat-ready-streaming` 阶段失败;前置 `bridge:health`、workspace ready、Browser Runtime、site adapters、Agent service skill entry 与 runtime tool surface 已通过,失败证据指向 Agent streaming smoke 断言 `streamGrowthObserved=false` / provider-follow 断言不满足,属于当前运行时 smoke 阻塞项,不是 Settings i18n 文案迁移的定向回归依据;本轮未修复该无关阻塞。
+40. 2026-05-10 Settings Service Models 总页迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、543 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/media-services/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。由于记录 39 的 `smoke:claw-chat-ready-streaming` 仍是当前运行时 smoke 阻塞项,本轮未重复消耗 GUI smoke,待该无关 blocker 收口后再复跑设置页主路径 GUI smoke。
+41. 2026-05-10 Settings Voice 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、661 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/voice/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、16 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。GUI smoke 仍受记录 39 的 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Voice 文案迁移阻塞项。
+42. 2026-05-10 Settings Providers 桌宠能力偏好卡迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、684 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。本条只覆盖 Providers 页内独立桌宠偏好卡,`agent/providers/index.tsx` 主体仍待后续迁移。
+43. 2026-05-10 Settings Providers 顶部入口与云端反馈迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、696 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/providers/index.test.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、24 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。本条只覆盖 Providers 顶部 workspace 切换器与云端打开反馈,Companion Bridge 诊断区仍需继续迁移。
+44. 2026-05-10 Settings Providers Companion Bridge 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、808 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/providers/index.test.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、24 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过;`npm run verify:local` 已通过 `verify:app-version`、`lint`、`typecheck` 和前 20 个 smart vitest 批次,随后仍在非 i18n 文件 `src/lib/base-setup/seededServiceSkillPackage.test.ts` 因 16 / 17 seeded service skill 数量断言失败,本轮未修复该无关 blocker。
+45. 2026-05-10 Settings User Center Session 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、891 个 source key 结构一致;`npm test -- "src/components/settings-v2/account/user-center-session/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。
+46. 2026-05-10 Settings Developer 壳层迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、940 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/developer/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、19 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。
+47. 2026-05-10 Settings Workspace 自愈记录共享卡片与新增文案规则续测:`npm test -- "src/components/settings-v2/system/shared/WorkspaceRepairHistoryCard.test.tsx" "src/components/settings-v2/agent/skills/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、9 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、940 个 source key 结构一致;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。
+48. 2026-05-10 Settings Environment 页迁移续测:`src/components/settings-v2/system/environment/index.tsx` 已接入 `useTranslation("settings")`,覆盖页面 hero、Shell 导入状态、显式覆盖表单、合并规则、使用提示、生效预览、来源标签、按钮、tip aria 与空态;新增 `settings.environment.*` 共 107 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1070 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/environment/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run lint` 与定向 `git diff --check` 通过。该记录中曾受并行进程新增的 `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx` `Timeout` 类型不匹配影响,后续已在记录 49 随 Channels Log Tail 本刀复跑 `npm run typecheck` 收口;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,未把该无关失败作为 Environment 文案迁移阻塞项。
+49. 2026-05-10 Settings Channels Log Tail 迁移续测:`src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx` 已接入 `useTranslation("settings")`,覆盖日志 Tail 标题说明、暂停 / 继续、复制 / 清空、过滤 / 自定义正则、copy / clear / error / confirm、loading / empty 与 locale-aware 时间;新增 `settings.channels.logTail.*` 并同步 5 个 locale。`npm test -- "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、14 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1070 个 source key 结构一致;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Channels Log Tail 文案迁移阻塞项。
+50. 2026-05-10 Settings Experimental 页迁移续测:`src/components/settings-v2/system/experimental/index.tsx` 已接入 `useTranslation("settings")`,覆盖实验功能 hero、Tool Calling 2.0、截图对话、macOS 屏幕录制权限提示、WebMCP 预留、崩溃上报与诊断、剪贴板权限指引、更新提醒、Workspace 自愈记录入口、按钮、aria、toast / error / export message 与 deferred fallback;新增 `settings.experimental.*` 共 82 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1152 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/experimental/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run lint` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮尝试时遇到多个并行 typecheck 进程长时间占用,已终止本轮进程,未把它记为通过证据;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Experimental 文案迁移阻塞项。
+51. 2026-05-10 Settings Channels Workbench 壳层迁移续测:`src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx` 已接入 `useTranslation("settings")`,覆盖工作台标题说明、范围提示、网关 / 日志入口、摘要、日志排查提示、运行 tab、未保存栏和保存结果;新增 `settings.channels.workbench.*` 并同步 5 个 locale。`npm test -- "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、15 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1196 个 source key 结构一致;`npm run lint` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮单独尝试超过 11 分钟无诊断输出,且当时隔壁进程正在跑 `verify:local` / Rust check / 另一条 typecheck,为避免抢占资源已终止本轮进程;本条不把 typecheck 记为通过证据。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Channels Workbench 壳层文案迁移阻塞项。
+52. 2026-05-10 Settings Web Search 页迁移续测:`src/components/settings-v2/system/web-search/index.tsx` 已接入 `useTranslation("settings")`,覆盖页面 hero、tabs、搜索链路、Provider 凭证、MSE 聚合、图片搜索、观测面板、密钥显示 / 隐藏、外链申请 / 文档入口、动态状态、保存 / 失败反馈和 sticky footer;新增 `settings.webSearch.*` 共 113 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1309 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/web-search/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、18 个用例;`npm run lint` 通过。`npm run typecheck` 本轮发现隔壁进程已有运行中的 typecheck,为避免和并行任务抢占资源,暂未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Web Search 文案迁移阻塞项。
+53. 2026-05-10 Settings Channels Gateway Tunnel 迁移续测:`GatewayTunnelPanel` 已接入 `useTranslation("settings")`,覆盖公共隧道标题说明、Provider / mode / host / port / public URL / Cloudflare 字段、Run Token / Credentials File / Feishu account、cloudflared 检测 / 安装 / 探测 / 创建 / 启停 / 同步回调按钮、危险确认、执行中状态和最近结果空态;新增 `settings.channels.gatewayTunnel.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1339 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、16 个用例;`npx eslint "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx" "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.ts" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 仍有隔壁运行中的 typecheck / verify:local,本轮未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Gateway Tunnel 文案迁移阻塞项。
+54. 2026-05-10 Settings Developer 目录联调懒加载工具迁移续测:`src/components/settings-v2/system/developer/ServiceSkillCatalogTools.tsx` 与 `src/components/settings-v2/system/developer/SiteAdapterCatalogTools.tsx` 已接入 `useTranslation("settings")`,覆盖服务型技能目录摘要、站点脚本目录摘要、Bootstrap Payload 调试、外部 YAML 导入、示例 placeholder、按钮、aria、动态计数与成功 / 失败反馈;新增 `settings.developer.serviceSkillCatalog.*` / `settings.developer.siteAdapterCatalog.*` 共 79 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1436 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/developer/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、19 个用例;`npx eslint "src/components/settings-v2/system/developer/ServiceSkillCatalogTools.tsx" "src/components/settings-v2/system/developer/SiteAdapterCatalogTools.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮仍由隔壁 `verify:local` / typecheck 进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为目录联调文案迁移阻塞项。
+55. 2026-05-10 Settings Clipboard 权限指引共享卡迁移续测:`src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx` 已接入 `useTranslation("settings")`,覆盖 macOS / Windows / Linux / generic 剪贴板权限指引、打开系统设置按钮和失败提示;新增 `settings.system.clipboardPermission.*` 共 19 个 key并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1455 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx" "src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.test.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮仍由隔壁 `verify:local` / typecheck 进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为剪贴板权限指引文案迁移阻塞项。
+56. 2026-05-10 Crash Recovery 恢复模式外壳迁移续测:`src/components/layout/CrashRecoveryPanel.tsx` 已接入 `useTranslation("errors")`,覆盖恢复模式标题说明、最近错误、模块资源加载失败提示、诊断复制 / JSON / 导出 / 清理 / 下载目录操作、workspace 路径修复反馈和强制刷新资源入口;新增 `errors.crashRecovery.*` 共 35 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1490 个 source key 结构一致;`npm test -- "src/components/layout/CrashRecoveryPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/layout/CrashRecoveryPanel.tsx" "src/components/layout/CrashRecoveryPanel.test.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮仍由隔壁 `verify:local` / typecheck 进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为恢复模式外壳文案迁移阻塞项。
+57. 2026-05-10 Settings Channels Telegram / Feishu Gateway Runtime 迁移续测:`TelegramGatewayDebugPanel` 与 `FeishuGatewayDebugPanel` 已接入 `useTranslation("settings")`,覆盖运行控制标题说明、账号 ID、轮询超时、状态查询 / 启动 / 停止 / 重启按钮、执行中状态、成功 / 失败反馈、stop warning 与最近结果空态;新增 `settings.channels.gatewayRuntime.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1455 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx" "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.ts" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮未作为通过证据;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Gateway Runtime 文案迁移阻塞项。
+58. 2026-05-10 Settings Channels 微信 Gateway Runtime 迁移续测:`WechatGatewayDebugPanel` 与 `QrCodePreview` 已接入 `useTranslation("settings")`,覆盖微信运行控制、兼容扫码排障、二维码状态、账号目录、危险删除确认、动态状态、失败反馈和 locale-aware 时间;新增 `settings.channels.wechatRuntime.*` 共 68 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1622 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、18 个用例;`npx eslint "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx" "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.ts" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮未作为通过证据;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为微信 Gateway Runtime 文案迁移阻塞项。
+59. 2026-05-10 Crash Diagnostic 底层诊断文案迁移续测:`src/lib/crashDiagnostic.ts` 已通过 `errors.crashDiagnostic.*` 读取 current locale 下的 key-based resource,覆盖自动摘要、诊断提示词、采集说明、复制失败、剪贴板权限指引与下载目录 fallback;新增 crash diagnostic key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1622 个 source key 结构一致;`npm test -- "src/lib/crashDiagnostic.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、25 个用例;`npx eslint "src/lib/crashDiagnostic.ts" "src/lib/crashDiagnostic.test.ts" --max-warnings 0` 与 `npm run typecheck` 通过;定向 `git diff --check` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为底层诊断文案迁移阻塞项。
+60. 2026-05-10 Settings Chrome Relay 独立连接引导窗口迁移续测:`src/components/settings-v2/system/chrome-relay/guide-window.tsx` 已接入 `useTranslation("settings")`,覆盖扩展安装引导、CDP 直连引导、状态标签、复制 / 打开 / 同步操作、错误反馈、剪贴板提示和源码目录警告;新增 `settings.chromeRelay.guide.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1746 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/guide-window.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、13 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/guide-window.tsx" "src/components/settings-v2/system/chrome-relay/guide-window.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 引导窗口文案迁移阻塞项。
+61. 2026-05-10 Settings Memory 首屏与偏好画像迁移续测:`src/components/settings-v2/general/memory/index.tsx` 已接入 `useTranslation("settings")`,覆盖首屏 hero、记忆总开关、保存操作、状态摘要、偏好画像问卷题目与选项;补齐并行写入后缺失的 `zh-TW` / `ja-JP` / `ko-KR` `settings.memory.*` keys。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1749 个 source key 结构一致;`npm test -- "src/components/settings-v2/general/memory/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/general/memory/index.tsx" "src/components/settings-v2/general/memory/index.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Memory 首屏文案迁移阻塞项。
+62. 2026-05-10 Settings Chrome Relay 主设置页核心首屏迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 已接入 `useTranslation("settings")`,覆盖核心浏览器列表、系统环境、Google Chrome 能力说明、扩展连接 / CDP 直连入口、连接器安装状态、浏览器协助 / 连接引导 / 剪贴板 / 连接器开关等共享操作反馈;新增 `settings.chromeRelay.main.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1846 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮已有隔壁进程在跑,为避免抢占资源未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 核心首屏文案迁移阻塞项。
+63. 2026-05-10 Settings Chrome Relay 高级工具起步区迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖高级控制壳层、Overview 卡片、Profile 会话面板、使用建议、实时调试面板、tab 标签以及后端类型 label / description;新增 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1914 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。第一次测试曾因 `getBackendLabel` 在初始化前被 `testBackendAction` dependency array 读取而失败,已移动 helper 定义顺序并复跑通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为高级工具起步区文案迁移阻塞项。
+64. 2026-05-10 Settings Automation 当前焦点条与概览焦点卡迁移续测:`src/components/settings-v2/system/automation/AutomationJobFocusStrip.tsx` 与 `AutomationOverviewFocusCard.tsx` 已接入 `useTranslation("settings")`,覆盖当前焦点标题 / badge、空态、加载态、最近结果、下一步摘要和查看结果 / 治理 / 详情操作文案;新增 `settings.automation.focus.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1930 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/AutomationJobFocusStrip.test.tsx" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、26 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobFocusStrip.tsx" "src/components/settings-v2/system/automation/AutomationJobFocusStrip.test.tsx" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.tsx" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮仍由隔壁进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Automation 焦点组件文案迁移阻塞项。
+65. 2026-05-10 Settings Chrome Relay 高级工具连接方式卡迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖高级工具连接方式卡中的扩展 / CDP 状态、快捷入口、复制配置与断开扩展操作文案;新增 `settings.chromeRelay.main.connectionMethod.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1974 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮已有隔壁 `verify:local` 进程在跑,为避免抢占资源未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 连接方式卡文案迁移阻塞项。
+66. 2026-05-10 Settings Automation Health Panel 迁移续测:`src/components/settings-v2/system/automation/AutomationHealthPanel.tsx` 已接入 `useTranslation("settings")`,覆盖风险提醒标题说明、轮询状态、累计执行、汇总 pill、最近 / 下次轮询、风险任务失败重试、冷却 / 更新时间、状态枚举和空态;新增 `settings.automation.health.*` 并同步 5 个 locale,时间展示改为复用 `formatDate()`。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1954 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationHealthPanel.tsx" "src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 未作为本轮通过证据;为避免和隔壁进程抢占资源,本轮未重复启动 `npm run verify:local` / 全量 `cargo test`。
+67. 2026-05-11 Settings Chrome Relay 高级工具系统连接器卡迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖系统连接器卡片说明、启用计数、状态标签、能力前缀和切换 aria,并补充系统连接器切换回归;新增 `settings.chromeRelay.main.systemConnector.*` key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1984 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、18 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 系统连接器卡文案迁移阻塞项。
+68. 2026-05-11 Settings Chrome Relay 高级工具浏览器动作配置迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖浏览器动作配置标题说明、读取 / 写入分组和能力开关 aria,并补充动作配置标题 / 分组断言;新增 `settings.chromeRelay.main.browserAction.*` key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1989 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、18 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 浏览器动作配置文案迁移阻塞项。
+69. 2026-05-11 Settings Automation 主页面壳层与调度器设置迁移续测:`src/components/settings-v2/system/automation/index.tsx` 已接入 `useTranslation("settings")`,覆盖 Automation 页面 hero、加载失败态、刷新 / 新建 / 设置 / 打开入口、顶部状态 pill、tabs、调度器设置卡、调度器保存 toast 与主加载错误 toast;新增 `settings.automation.main.*` 与 `settings.automation.scheduler.*` 共 57 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2046 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/settings-v2/system/automation/index.tsx" "src/components/settings-v2/system/automation/index.test.tsx" --max-warnings 0` 与定向 `git diff --check -- "src/components/settings-v2/system/automation/index.tsx"` 通过。`npm run typecheck` 与 `npm run verify:local` 未作为本轮通过证据;仍避免触碰隔壁正在推进的 Chrome Relay / Skills 主线。
+70. 2026-05-11 Settings Chrome Relay 高级工具后端策略详情迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖后端策略详情面板的策略配置、自动回退、优先级、后端测试、当前可用性、capabilities waiting、native-host 与平台支持文案,并补充后端策略页渲染回归;新增 `settings.chromeRelay.main.backendPolicy.*` key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2071 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、19 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 后端策略详情文案迁移阻塞项。
+71. 2026-05-11 Settings Chrome Relay 高级工具扩展桥接详情迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖扩展桥接详情面板的服务状态、observer 状态、接入信息、复制配置、空态提示、observer 最近页面、测试扩展与刷新状态文案,并补充扩展桥接页渲染回归;新增 `settings.chromeRelay.main.bridge.*` key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2149 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮由隔壁 `verify:local` 占用未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 扩展桥接详情文案迁移阻塞项。
+72. 2026-05-11 Settings Automation 主页面开始模板与任务列表迁移续测:`src/components/settings-v2/system/automation/index.tsx` 继续收口 `settings.automation.tasks.*`,覆盖开始模板卡、任务列表、旧浏览器流程下线提醒、任务行技能流程摘要、任务状态与运行操作,以及创建 / 更新 / 删除 / 立即运行反馈;新增 `settings.automation.tasks.*` 共 60 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2131 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/settings-v2/system/automation/index.tsx" "src/components/settings-v2/system/automation/index.test.tsx" --max-warnings 0` 与定向 `git diff --check -- "src/components/settings-v2/system/automation/index.tsx"` 通过。`npm run typecheck` 与 `npm run verify:local` 仍未作为本轮通过证据;本轮未触碰 Chrome Relay / Skills 文件。
+73. 2026-05-11 Settings Automation 运行历史区迁移续测:`src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx` 已接入 `useTranslation("settings")`,覆盖最近运行标题、刷新按钮、run id / session / 完成时间元信息、技能流程运行上下文、参数摘要、补充要求、输出投递状态、失败原因与空态;新增 `settings.automation.history.*` 共 13 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2162 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx" "src/components/settings-v2/system/automation/index.test.tsx" --max-warnings 0` 与定向 `git diff --check -- "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx"` 通过。`npm run typecheck` 与 `npm run verify:local` 未作为本轮通过证据;本轮未触碰 Chrome Relay / Skills 文件。
+74. 2026-05-11 Skills 工作台顶部入口与搜索错误区迁移续测:`src/components/skills/SkillsWorkspacePage.tsx` 已接入 `useTranslation("agent")`,覆盖页头标题 / 副标题、刷新 / 查看全部、active scaffold 续用条、搜索 placeholder、推荐 / 本地 Skills 错误横幅,以及搜索命中右侧 Skills 时的结果模板 / 分组空态分流;新增 `skills.workspace.*` key 并同步 5 个 locale。为避免隔壁 Automation 并行改动中的 `AutomationJobDialog` 模块初始化错误污染本切片,`src/components/skills/SkillsWorkspacePage.test.tsx` 只在本测试内 mock dialog 外壳,保留 Skills 工作台主渲染、搜索和本地 Skill 断言。`npm test -- "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、36 个用例;`npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、2305 个 source key。`npm run typecheck` / `npm run verify:local` 本轮仍由隔壁长跑进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Skills 工作台本地化阻塞项。
+75. 2026-05-11 Skills 工作台主体 chrome 迁移续测:`src/components/skills/SkillsWorkspacePage.tsx` 继续收口 `skills.workspace.*`,覆盖推荐区标题 / 副标题、最近判断横幅、推荐 badge、沿用结果、进入生成、分组详情、分类入口、最近 / 本地 Skills 侧栏标题与空态、能力草案 / 已注册能力折叠标题、刚沉淀提示、继续操作和调整 Skills 弹窗标题 / Tips;新增 key 并同步 5 个 locale。`npm test -- "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、36 个用例;`npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、2339 个 source key。`npm run typecheck` 已尝试但因隔壁 Automation 并行脏改失败:`src/components/settings-v2/system/automation/AutomationJobDialog.tsx:101` 的 `TFunctionDetailedResult` 转 string 与 `:108` 的 `replaceAll` target lib 报错;该失败不来自本切片。隔壁 `verify:local` 本轮已进入 GUI smoke,最终在 `smoke:claw-chat-ready-streaming` 等待恢复结果超时处失败,本轮未把该无关失败作为 Skills 工作台本地化阻塞项。
+76. 2026-05-11 Skills 工作台事件反馈与生成入口提示迁移续测:`src/components/skills/SkillsWorkspacePage.tsx` 继续收口 `skills.workspace.*`,覆盖刷新 toast、本地 Skill / runtime enable / curated task 回到生成 entry banner、Managed Job 草案创建反馈、Skill scaffold 创建反馈与默认标题、最近判断 launcher prefill hint;新增 key 并同步 5 个 locale。`npm test -- "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、36 个用例;`npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、定向 `git diff --check` 与 `npm run typecheck` 通过,最新资源口径为 5 个 locale、6 个 namespace、2457 个 source key。隔壁 `verify:local` 的 GUI smoke 仍按记录 75 停在 `smoke:claw-chat-ready-streaming` 超时,本轮未把该无关失败作为 Skills 工作台本地化阻塞项。
+77. 2026-05-11 Skills 工作台 helper 动态摘要迁移续测:`src/components/skills/SkillsWorkspacePage.tsx` 继续收口 `skills.workspace.*`,覆盖能力草稿缺本地目录错误、本地 Skill 最近目标摘要、分类 starter summary、最近判断行动按钮、结果基线 source title / highlight / count 拼接;新增 key 并同步 5 个 locale。`npm test -- "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、36 个用例;`npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、2464 个 source key。`npm run typecheck` 已尝试但被隔壁 Memory 来源链并行脏改阻塞:`src/components/settings-v2/general/memory/index.tsx` 多个 `settings.memory.source.*` / `settings.memory.layers.*` key 尚未进入类型资源,且部分 helper 参数顺序与 `TFunction<"settings">` 不一致;该失败不来自本切片。
+78. 2026-05-11 Settings Automation Job Dialog 迁移续测:`src/components/settings-v2/system/automation/AutomationJobDialog.tsx` 已接入 `useTranslation("settings")`,覆盖新建 / 编辑弹窗标题说明、summary pill、基础表单字段、调度提示、旧浏览器流程下线快照、权限模式、输出投递契约、目标地址 placeholder、投递说明、校验错误和底部保存操作;新增 `settings.automation.jobDialog.*` 共 124 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2305 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobDialog.tsx" "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" --max-warnings 0` 通过。`npm run typecheck` / `npm run verify:local` 未作为本轮通过证据;本轮未触碰 Chrome Relay / Skills 文件。
+
+79. 2026-05-11 Settings Automation Details Dialog 长尾迁移续测:`src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx` 继续收口 `settings.automation.details.*`,覆盖详情弹窗头部、summary pill、基础元信息、旧浏览器流程下线提示、技能流程上下文、Scene App 回流摘要、输出契约、最近投递结果、当前起手内容、运行历史里的本地时间格式与权限 / 调度 / 投递动态 label;新增 `settings.automation.details.*` 共 101 个 key 并同步 5 个 locale,同时修复 `AutomationJobDialog` / Details Dialog interpolation helper 的返回类型与 `replaceAll` target lib 风险。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2457 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobDialog.tsx" "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本轮未触碰 Chrome Relay / Skills 实现文件。
+
+80. 2026-05-11 Settings Memory 来源链状态与策略长尾迁移续测:`src/components/settings-v2/general/memory/index.tsx` 继续收口 `settings.memory.source.*` / `settings.memory.layers.*` / `settings.memory.action.refresh`,覆盖记忆命中层状态、来源链状态总览、来源链策略、运行时 AGENTS 模板 / `.gitignore` 反馈、来源链命中详情、来源分类与相对更新时间;新增 `settings.memory.source.*` / `settings.memory.layers.*` / `settings.memory.action.refresh` 共 95 个 key 并同步 5 个 locale,本条不覆盖 `memdir` 写入区、自动索引卡片和 memdir 校验 / 初始化 / 整理反馈长尾。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2561 个 source key 结构一致;`npm test -- "src/components/settings-v2/general/memory/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/general/memory/index.tsx" "src/components/settings-v2/general/memory/index.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;GUI smoke 仍按记录 39 / 75 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Memory 文案迁移阻塞项。
+81. 2026-05-11 Skills 已安装 Skill presentation helper 本地化 copy 迁移续测:`src/components/skills/installedSkillPresentation.ts` 新增可选 `copy` 注入,并在 `src/components/skills/SkillsWorkspacePage.tsx` 传入 `skills.workspace.installedSkill.*` copy,覆盖默认 promise、required inputs、output hint、required / output 前缀;存量调用方不传 copy 时行为保持不变。`npm test -- "src/components/skills/installedSkillPresentation.test.ts" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、39 个用例;`npx eslint "src/components/skills/installedSkillPresentation.ts" "src/components/skills/installedSkillPresentation.test.ts" "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、定向 `git diff --check` 与 `npm run typecheck` 通过,最新资源口径为 5 个 locale、6 个 namespace、2561 个 source key。
+82. 2026-05-11 Skills ServiceSkill presentation helper 本地化 copy 迁移续测:`src/components/agent/chat/service-skills/skillPresentation.ts` 新增 `ServiceSkillPresentationCopy` 注入,保持默认中文兜底不破坏 Agent Chat / Home 等存量调用;`src/components/skills/SkillsWorkspacePage.tsx` 在 Skills 工作台可见路径传入 `skills.workspace.serviceSkill.*` copy,覆盖推荐卡 / 最近侧栏的 runner、action、类型、required inputs、output destination 与可读摘要,同时补齐 `skills.workspace.installedSkill.defaultPromise` / `fallbackRequiredInputs` / `fallbackOutputHint` 的 5 语种资源。`npm test -- "src/components/agent/chat/service-skills/skillPresentation.test.ts" "src/components/skills/installedSkillPresentation.test.ts" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、46 个用例;`npx eslint "src/components/agent/chat/service-skills/skillPresentation.ts" "src/components/agent/chat/service-skills/skillPresentation.test.ts" "src/components/skills/installedSkillPresentation.ts" "src/components/skills/installedSkillPresentation.test.ts" "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、定向 `git diff --check` 与 `npm run typecheck` 通过,最新资源口径为 5 个 locale、6 个 namespace、2666 个 source key。`npm run verify:local` / GUI smoke 未作为本轮通过证据;GUI smoke 仍按记录 39 / 75 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Skills ServiceSkill helper 本地化阻塞项。
+
+仍未完成:
+
+1. `navigation` namespace 已覆盖侧边栏主入口、搜索弹窗、会话列表起步路径、会话 meta 格式化、会话重命名 / 删除的 prompt / confirm / toast,以及账号菜单的核心未登录 / 已登录操作文案;但邀请入口 / 邀请弹窗、外观快捷面板、账号套餐 usage 兜底、复制邀请 toast 等侧栏周边文案仍有硬编码;`workspace`、`agent` namespace 还只是骨架,`errors` namespace 已覆盖 Crash Recovery 外壳与 crashDiagnostic 共享诊断摘要 / 复制失败 / 下载目录 fallback;Workspace / Agent Chat 仍需 P1/P2 逐步迁移;Settings 共享壳层、home、developer-lab、system/developer 壳层与目录联调懒加载工具、system/shared Clipboard 权限指引、CrashRecoveryPanel 恢复模式外壳、system/environment 主体、system/experimental 主体、system/channels 工作台壳层、网关与隧道表单、Telegram / Feishu / 微信运行控制、微信兼容扫码排障与日志 Tail 面板、system/web-search 主体、system/chrome-relay 独立连接引导窗口 / 主设置页核心首屏 / 高级工具起步区 / 高级工具连接方式卡 / 高级工具系统连接器卡 / 高级工具浏览器动作配置面板 / 高级工具后端策略详情面板 / 高级工具扩展桥接详情面板、system/shared Workspace 自愈记录共享卡片、system/automation 当前焦点条 / 概览焦点卡 / HealthPanel / 主页面壳层与调度器设置 / 主页面开始模板与任务列表 / 运行历史区、general/memory 首屏与偏好画像 / 来源链状态与策略长尾、general/hotkeys、account/profile/stats/user-center-session、appearance/about、agent/skills 高级入口、agent/image-gen、agent/video-gen、agent/media-services、agent/voice、agent/providers 的顶部切换器 / 云端反馈 / 桌宠能力偏好卡 / Companion Bridge 诊断区已完成起步迁移,但 Providers 页内残留硬编码、general/memory memdir 写入 / 自动索引长尾、Skills 工作台的 ServiceSkill launch prefill 与 CuratedTask presentation helper 返回的 runtime 数据、能力草稿面板内部文案、启动弹窗内部与模型执行提示正文长尾仍需继续。
+2. `legacy-patch/` 已完成物理隔离,并已有运行时命中量指标 API、GUI smoke metrics 导出链与离线 text / JSON 报告脚本;但 CI 尚未把 `i18n:patch-report --check` 接入删除门禁,也尚未把 `no-hit` 报告和 current 主路径依赖审计绑定为 DOM replacer 删除条件。退出条件仍是 P3 后按命中量和 current 主路径依赖清零逐步拆除。
+3. 类型绑定已覆盖当前 source resource,`AGENTS.md` / 质量工作流也已封住新增功能必须走 current i18n 的人工规则;但尚未接入官方 `i18next-cli` 的抽取 / hardcoded string lint / type generation,也尚未评估 selector API 或 `@i18next-selector/codemod` 是否适合资源规模扩大后的 P3 阶段。
+4. 最终合并前仍应基于届时工作区状态复跑 `npm run verify:local`;历史 `verify:local` 曾被非 i18n 的 seeded service skill 数量断言阻塞;最新隔壁 `verify:local` 已推进到 GUI smoke,但仍受 Agent streaming runtime blocker 影响。`ChannelLogTailPanel.test.tsx` 的 `Timeout` 类型不匹配已在记录 49 收口,当前不再作为 i18n PRD 的 typecheck blocker;记录 50 / 51 / 52 / 53 / 54 / 55 / 56 / 57 / 58 的 typecheck 因并行重验证负载或本轮未单独启动而未取得通过证据,后续收口时需复跑;记录 59 / 60 / 61 已重新取得 `npm run typecheck` 通过证据;记录 65 / 66 因隔壁 `verify:local` 长跑占用未重复启动 typecheck;记录 67 / 68 已重新取得 `npm run typecheck` 通过证据;记录 69 / 71 / 72 / 73 / 74 未单独启动 typecheck / verify:local;记录 75 已尝试 `npm run typecheck` 但当时被 AutomationJobDialog 并行脏改阻塞,且隔壁 `verify:local` 在 `smoke:claw-chat-ready-streaming` 超时失败;记录 76 已重新取得 `npm run typecheck` 通过证据;记录 77 已尝试 `npm run typecheck` 但被 Memory 来源链并行脏改阻塞;记录 78 / 79 / 81 / 82 已重新取得 `npm run typecheck` 通过证据;记录 80 已补齐 Memory 来源链资源并通过 detect / 定向测试 / ESLint,但未单独启动 typecheck / verify:local;记录 70 已重新取得 `npm run typecheck` 通过证据。
diff --git a/docs/test/README.md b/docs/test/README.md
index bdbbb309a..6b39332c2 100644
--- a/docs/test/README.md
+++ b/docs/test/README.md
@@ -48,11 +48,20 @@ docs/test/
├── integration-tests.md # 集成测试指南
├── e2e-tests.md # 浏览器续测与 E2E 总览
├── agent-evaluation.md # Agent 评估指南(核心文档)
+├── agent-qc-evidence.schema.json # Agent QC Evidence Pack schema
+├── agent-qc-gui-flows.manifest.json # Agent QC GUI / Playwright MCP flow 清单
+├── agent-qc-scenarios.manifest.json # Agent QC 核心场景清单
├── harness-evals.md # Harness eval 任务集与 runner 入口
└── test-cases/ # 测试用例模板
├── converter-tests.md # 协议转换器测试用例
├── provider-tests.md # Provider 测试用例
└── agent-tests.md # Agent 测试用例
+
+docs/tests/
+├── agent-ops-qc.md # Agent 运营级测试体系与证据门禁
+├── agent-qc-p0-scenarios.md # Agent QC P0 场景执行手册
+├── lime-agent-qc-rollout-plan.md # Lime 样本产品落地计划
+└── lime-agent-qc-current-blockers.md # 当前 P0 qcloop 阻断记录
```
## 文档索引
@@ -65,6 +74,13 @@ docs/test/
| [e2e-tests.md](e2e-tests.md) | 当前浏览器续测与 E2E 入口 | Playwright MCP / DevBridge 主路径验证 |
| [../aiprompts/playwright-e2e.md](../aiprompts/playwright-e2e.md) | 浏览器续测详细事实源 | 继续测试、复现、控制台与 Bridge 排障 |
| [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 |
+| [../tests/agent-ops-qc.md](../tests/agent-ops-qc.md) | Agent 运营级测试体系 | qcloop、证据包、发布门禁、运营回归 |
+| [../tests/agent-qc-p0-scenarios.md](../tests/agent-qc-p0-scenarios.md) | Agent QC P0 场景执行手册 | 核心场景执行、证据要求、失败沉淀 |
+| [../tests/lime-agent-qc-rollout-plan.md](../tests/lime-agent-qc-rollout-plan.md) | Lime 落地计划 | 分阶段构建 qcloop 证据链、GUI/Runtime 深测、release gate |
+| [../tests/lime-agent-qc-current-blockers.md](../tests/lime-agent-qc-current-blockers.md) | 当前 P0 阻断记录 | 真实 qcloop fail evidence、root cause、关闭条件 |
+| [agent-qc-scenarios.manifest.json](agent-qc-scenarios.manifest.json) | Agent QC 场景清单 | 机器可读场景、lane、命令、证据要求 |
+| [agent-qc-evidence.schema.json](agent-qc-evidence.schema.json) | Evidence Pack schema | 测试证据、场景结果、verdict 合同 |
+| [agent-qc-gui-flows.manifest.json](agent-qc-gui-flows.manifest.json) | GUI flow 清单 | Playwright MCP 步骤、断言、证据要求 |
| [harness-evals.md](harness-evals.md) | Harness eval 任务集与 runner | Replay 样本、grader、nightly 摘要 |
| [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 |
| [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | API Key Provider、协议转换和 API 调用 |
@@ -130,6 +146,22 @@ npm run harness:eval:promote -- --session-id "session-123" --slug "pending-reque
npm run harness:eval:trend
```
+### 运行 Agent QC 场景报告 / 合同检查
+
+```bash
+npm run agent-qc:report
+npm run agent-qc:report:json
+npm run agent-qc:gui-flow:report
+npm run agent-qc:gui-flow:check
+npm run agent-qc:check
+npm run agent-qc:qcloop-job -- --risk P0 --output "./.lime/qc/qcloop-p0-job.json" --check
+npm run agent-qc:export-evidence -- --job-id "" --output "./.lime/qc/agent-qc-evidence.json" --check
+npm run agent-qc:release-summary -- --evidence "./.lime/qc/agent-qc-evidence.json" --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" --require-risk P0 --tag "" --output "./.lime/qc/release-agent-qc.md" --check
+npm run agent-qc:audit
+```
+
+`agent-qc:check` 已进入 `npm run test:contracts`,用于防止运营级测试场景、证据 schema 和 npm script 入口漂移。
+
### 记录 Harness eval 历史窗口
```bash
diff --git a/docs/test/agent-qc-evidence.schema.json b/docs/test/agent-qc-evidence.schema.json
new file mode 100644
index 000000000..95f85a108
--- /dev/null
+++ b/docs/test/agent-qc-evidence.schema.json
@@ -0,0 +1,240 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://lime.local/schemas/agent-qc-evidence.schema.json",
+ "title": "Lime Agent QC Evidence Pack",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schemaVersion",
+ "runId",
+ "generatedAt",
+ "subject",
+ "laneResults",
+ "scenarioResults",
+ "verdict"
+ ],
+ "properties": {
+ "schemaVersion": {
+ "const": "v1"
+ },
+ "runId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "generatedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "subject": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["repo", "ref", "changedFiles"],
+ "properties": {
+ "repo": {
+ "type": "string",
+ "minLength": 1
+ },
+ "ref": {
+ "type": "string",
+ "minLength": 1
+ },
+ "diffBase": {
+ "type": "string"
+ },
+ "changedFiles": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "riskTags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "laneResults": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["laneId", "status", "commands"],
+ "properties": {
+ "laneId": {
+ "type": "string"
+ },
+ "status": {
+ "$ref": "#/$defs/status"
+ },
+ "commands": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/commandResult"
+ }
+ },
+ "notes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "scenarioResults": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["scenarioId", "status", "evidenceRefs"],
+ "properties": {
+ "scenarioId": {
+ "type": "string"
+ },
+ "status": {
+ "$ref": "#/$defs/status"
+ },
+ "executor": {
+ "type": "string"
+ },
+ "attempts": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "runtimeTranscriptRef": {
+ "type": "string"
+ },
+ "guiTraceRef": {
+ "type": "string"
+ },
+ "consoleErrorCount": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "networkErrorCount": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "evidenceRefs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "failureModes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "humanReview": {
+ "$ref": "#/$defs/humanReview"
+ }
+ }
+ }
+ },
+ "verdict": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["status", "summary"],
+ "properties": {
+ "status": {
+ "enum": ["pass", "fail", "blocked", "needs-human-review", "waived"]
+ },
+ "summary": {
+ "type": "string"
+ },
+ "blockers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "waivers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/waiver"
+ }
+ },
+ "nextAction": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "$defs": {
+ "status": {
+ "enum": ["pass", "fail", "blocked", "needs-human-review", "waived", "skipped"]
+ },
+ "commandResult": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["command", "status"],
+ "properties": {
+ "command": {
+ "type": "string"
+ },
+ "status": {
+ "$ref": "#/$defs/status"
+ },
+ "exitCode": {
+ "type": "integer"
+ },
+ "durationMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "logRef": {
+ "type": "string"
+ },
+ "artifactRefs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "humanReview": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["required", "reason"],
+ "properties": {
+ "required": {
+ "type": "boolean"
+ },
+ "reason": {
+ "type": "string"
+ },
+ "reviewer": {
+ "type": "string"
+ },
+ "decision": {
+ "enum": ["approved", "rejected", "deferred", "not-reviewed"]
+ }
+ }
+ },
+ "waiver": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "reason", "expiresAt"],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "reason": {
+ "type": "string"
+ },
+ "owner": {
+ "type": "string"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ }
+}
diff --git a/docs/test/agent-qc-gui-flows.manifest.json b/docs/test/agent-qc-gui-flows.manifest.json
new file mode 100644
index 000000000..2789edee0
--- /dev/null
+++ b/docs/test/agent-qc-gui-flows.manifest.json
@@ -0,0 +1,141 @@
+{
+ "manifestVersion": "v1",
+ "title": "Lime Agent QC GUI Flow Manifest",
+ "description": "Playwright MCP / GUI dogfood flows for P0 Agent QC scenarios. This manifest is a machine-readable runbook; it does not execute the browser by itself.",
+ "flows": [
+ {
+ "id": "gui-claw-chat-ready-streaming",
+ "scenarioId": "claw-chat-ready-streaming",
+ "risk": "P0",
+ "surface": "desktop_gui",
+ "preflight": [
+ "Run npm run verify:gui-smoke or reuse an already healthy Lime desktop session.",
+ "Confirm DevBridge health before interacting with the UI.",
+ "Start console and network collection before the first user action."
+ ],
+ "steps": [
+ "Open or focus the Claw / Agent chat workspace.",
+ "Confirm the default workspace is ready and input is enabled.",
+ "Send a short user message that does not require external credentials.",
+ "Observe streaming deltas or equivalent progress state.",
+ "Trigger interrupt while a response is active when the UI exposes interruption.",
+ "Send a follow-up message to confirm the session recovers."
+ ],
+ "assertions": [
+ "workspace ready state is visible before sending",
+ "user message appears exactly once",
+ "assistant response reaches a terminal or interrupted state",
+ "follow-up turn can be submitted after interruption",
+ "no unexpected console error",
+ "no unexpected network error",
+ "mock fallback is explicitly classified if observed"
+ ],
+ "evidenceRequired": [
+ "Playwright MCP action log",
+ "screenshot or trace of ready state",
+ "runtime transcript or request id",
+ "console summary",
+ "network summary",
+ "DevBridge health result"
+ ]
+ },
+ {
+ "id": "gui-browser-runtime-site-adapter",
+ "scenarioId": "browser-runtime-site-adapter",
+ "risk": "P0",
+ "surface": "desktop_gui_browser_runtime",
+ "preflight": [
+ "Run npm run smoke:browser-runtime and npm run smoke:site-adapters if a headless smoke is enough.",
+ "For interactive verification, reuse an existing Lime browser session instead of launching unrelated Chrome windows.",
+ "Enable console and network collection before opening the browser runtime panel."
+ ],
+ "steps": [
+ "Open the browser runtime or site adapter surface.",
+ "Confirm browser runtime status is readable.",
+ "Open the site adapter catalog or readiness panel.",
+ "Inspect at least one current adapter entry.",
+ "Confirm cleanup / detach status after the check if the flow created a browser session."
+ ],
+ "assertions": [
+ "browser runtime status is not stale",
+ "site adapter catalog has current entries",
+ "adapter naming does not drift to deprecated surface keys",
+ "cleanup status is captured",
+ "no unexpected console error",
+ "no unexpected network error"
+ ],
+ "evidenceRequired": [
+ "browser runtime status",
+ "site adapter catalog snapshot",
+ "console summary",
+ "network summary",
+ "cleanup or detach result"
+ ]
+ },
+ {
+ "id": "gui-workspace-ready-session-restore",
+ "scenarioId": "workspace-ready-session-restore",
+ "risk": "P0",
+ "surface": "desktop_gui_workspace",
+ "preflight": [
+ "Run npm run smoke:workspace-ready or confirm an existing Lime session is healthy.",
+ "Record whether the test is fresh start or session restore.",
+ "Confirm DevBridge health before reading workspace state."
+ ],
+ "steps": [
+ "Open the default workspace.",
+ "Confirm primary navigation and workspace content shell render.",
+ "Inspect session restore state if previous sessions exist.",
+ "Open the Agent chat workspace from the restored state.",
+ "Confirm no stale loading state remains."
+ ],
+ "assertions": [
+ "workspace ready smoke and GUI state agree",
+ "primary navigation is visible",
+ "restored session does not block new input",
+ "no stale skeleton or permanent loading state",
+ "DevBridge remains healthy"
+ ],
+ "evidenceRequired": [
+ "workspace smoke log or status",
+ "GUI screenshot or trace",
+ "DevBridge health result",
+ "session restore notes",
+ "console summary"
+ ]
+ },
+ {
+ "id": "gui-release-startup-smoke",
+ "scenarioId": "release-package-startup-smoke",
+ "risk": "P0",
+ "surface": "release_artifact_gui",
+ "preflight": [
+ "Use the release candidate artifact, not only the dev server.",
+ "Record platform, architecture and version.",
+ "Run npm run verify:app-version before treating the artifact as release evidence."
+ ],
+ "steps": [
+ "Install or launch the release candidate.",
+ "Confirm the first window opens.",
+ "Confirm workspace ready state.",
+ "Confirm DevBridge or equivalent runtime bridge health.",
+ "Open the Agent chat entry point without sending credential-dependent requests."
+ ],
+ "assertions": [
+ "release version matches the tag",
+ "application launches without crash",
+ "first workspace becomes ready",
+ "bridge health is captured",
+ "no startup console error blocks the main path"
+ ],
+ "evidenceRequired": [
+ "version check output",
+ "artifact path or release id",
+ "startup screenshot or trace",
+ "workspace ready status",
+ "bridge health result",
+ "platform metadata"
+ ]
+ }
+ ]
+}
diff --git a/docs/test/agent-qc-scenarios.manifest.json b/docs/test/agent-qc-scenarios.manifest.json
new file mode 100644
index 000000000..b993d2115
--- /dev/null
+++ b/docs/test/agent-qc-scenarios.manifest.json
@@ -0,0 +1,472 @@
+{
+ "manifestVersion": "v1",
+ "title": "Lime Agent QC Operating Scenarios",
+ "evidenceSchema": "docs/test/agent-qc-evidence.schema.json",
+ "qcloop": {
+ "purpose": "把 Agent 测试拆成可批量执行、独立质检、可返修的场景项。",
+ "recommendedMaxQcRounds": 3,
+ "workerPromptTemplate": "在 Lime 仓库中执行 Agent QC 场景 {{item}}。只做该场景要求的最小验证,收集命令、GUI、runtime 和证据路径,不要顺手修复无关问题。\n\n执行场景命令前先运行 qcloop worker preflight:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --check`。如果该场景命令需要 DevBridge / GUI / browser runtime,再运行:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --require-devbridge --timeout-ms 15000 --check`。\n\n如果 preflight 失败,停止后续高成本命令,输出 `QCLOOP_WORKER_RESULT=BLOCKED`,并在 stdout 中列出失败 check、health URL、cwd、stdout/stderr 证据路径;不要把环境阻断误判为产品 pass。\n\n最终 stdout 必须逐项列出 `evidence_required` 的证据是否满足,并逐项说明 `failure_modes` 如何被覆盖、排除或命中;不能只写“命令通过”或只给日志路径。\n\n如果 item 中包含 evidence_layers,最终 stdout 必须声明本次覆盖到哪些层级;只跑到 deterministic-smoke 时不得伪装成 gui-trace、runtime-transcript 或 release-artifact。\n\n只读约束:Agent QC worker 只允许执行场景命令、收集证据和输出 PASS/FAIL/BLOCKED;不得修改源码、配置、文档、锁文件或 git 状态。若 verifier 证据不足,输出缺口并让外层生成新 payload,不要在 qcloop repair 轮里修代码。\n\n结构化证据输出硬约束:最终 stdout 必须包含单行 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED`;还必须包含单行 `QCLOOP_EVIDENCE_SUMMARY_JSON=`,其中 `` 是单个 JSON object,不要 Markdown / code fence。该 JSON 至少包含 scenario_id、result、commands[]、evidence_required[]、evidence_layers_covered[]、failure_modes[]、artifacts[]、blockers[]、gui_session_owner、release_scope。evidence_required[] 每项包含 name、status(pass|fail|blocked|missing)、evidence、artifact_path;failure_modes[] 每项包含 name、status(covered|excluded|hit|unknown)、evidence。不得输出密钥、token、用户私密内容或未经脱敏的请求 / 响应正文。",
+ "verifierPromptTemplate": "审查 worker 输出是否满足场景 {{item}} 的 verifier、evidenceRequired 和 failureModes。只输出一个合法 JSON object,不要 Markdown、不要代码块、不要前后缀文本。JSON 必须至少包含 {\"pass\": true|false, \"feedback\": \"...\"};可选字段 missingEvidence、nextAction、evidenceStatus、scenarioId 必须仍在同一个 JSON object 内。\n\nqcloop worker 执行证据:\n- attempt_status: {{attempt_status}}\n- attempt_type: {{attempt_type}}\n- exit_code: {{exit_code}}\n\nworker stdout:\n{{stdout}}\n\nqcloop issue ledger:\n{{issue_ledger}}\n\n判定要求:stdout 必须包含 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED` 和 `QCLOOP_EVIDENCE_SUMMARY_JSON=`。如果 stdout 缺少 `QCLOOP_EVIDENCE_SUMMARY_JSON`,或该 JSON 无法逐项证明 scenario verifier / evidenceRequired / failureModes,必须输出 {\"pass\": false, \"feedback\": \"...\"};不能因为命令名、退出码或 worker 自评 PASS 就通过。",
+ "recommendedMaxExecutorRetries": 0
+ },
+ "lanes": [
+ {
+ "id": "L0-static-unit",
+ "title": "快速卫生与确定性单测",
+ "objective": "用最快入口发现 lint、type、unit、Rust 定向测试中的确定性错误。",
+ "gatePolicy": "普通代码改动默认执行;docs-only 可跳过。",
+ "defaultCommands": [
+ "npm run verify:local"
+ ]
+ },
+ {
+ "id": "L1-contract-bridge",
+ "title": "契约、Bridge 与治理护栏",
+ "objective": "验证前端调用、Rust 注册、DevBridge、mock、治理目录册和 harness 合同保持一致。",
+ "gatePolicy": "命令、Bridge、runtime gateway、mock 或 QC manifest/schema 改动必须执行。",
+ "defaultCommands": [
+ "npm run test:contracts",
+ "npm run test:bridge"
+ ]
+ },
+ {
+ "id": "L2-agent-runtime",
+ "title": "Agent Runtime 与工具面",
+ "objective": "验证 turn、streaming、中断、工具调用、approval、sandbox、memory、team runtime 等 Agent 内核路径。",
+ "gatePolicy": "Agent runtime、tool surface、Skill Forge、team 或 memory 改动必须执行定向 runtime 场景。",
+ "defaultCommands": [
+ "npm run smoke:agent-runtime-tool-surface",
+ "npm run smoke:agent-runtime-tool-surface-page"
+ ]
+ },
+ {
+ "id": "L3-product-surface",
+ "title": "GUI / WebUI / 桌面产品表面",
+ "objective": "验证 Lime 真实 GUI 主路径、DevBridge 健康、workspace ready、浏览器运行时和产品页面可用。",
+ "gatePolicy": "GUI 壳、Workspace、页面主路径、浏览器能力或用户可见 UI 改动必须执行。",
+ "defaultCommands": [
+ "npm run verify:gui-smoke",
+ "npm run bridge:health -- --timeout-ms 120000"
+ ]
+ },
+ {
+ "id": "L4-behavior-eval",
+ "title": "Agent 行为评测与回归样本",
+ "objective": "用 replay、grader、语义评测和趋势报告发现 Agent 行为退化。",
+ "gatePolicy": "Agent 行为、prompt、Skill、工具选择策略或长期运营回归默认进入 nightly;高风险改动需本地抽跑。",
+ "defaultCommands": [
+ "npm run harness:eval",
+ "npm run harness:eval:trend"
+ ]
+ },
+ {
+ "id": "L5-release-ops",
+ "title": "发布与运营门禁",
+ "objective": "验证版本、发布包、安装启动、release artifact 和发布证据完整性。",
+ "gatePolicy": "版本、打包、发布、安装器、工作流或运营门禁改动必须执行。",
+ "defaultCommands": [
+ "npm run verify:app-version",
+ "npm run agent-qc:check"
+ ]
+ }
+ ],
+ "scenarios": [
+ {
+ "id": "command-bridge-contract",
+ "title": "命令桥接四侧一致",
+ "risk": "P0",
+ "executor": "npm_command",
+ "lanes": [
+ "L1-contract-bridge"
+ ],
+ "commands": [
+ "npm run test:contracts"
+ ],
+ "goal": "验证 safeInvoke/invoke、Rust generate_handler、agentCommandCatalog、mockPriorityCommands/defaultMocks 不漂移。",
+ "verifier": "test:contracts 成功;失败时能指出具体缺失侧;不得出现新增 compat/dead 回流;若本轮没有命令面变更,checked surface counts + contract diff empty 可满足 surface evidence。",
+ "evidenceRequired": [
+ "contract command log",
+ "checked command surfaces summary or changed command surfaces",
+ "failure side summary or no-failure side summary"
+ ],
+ "evidenceLayers": [
+ "deterministic-smoke"
+ ],
+ "failureModes": [
+ "frontend-only command",
+ "missing rust handler",
+ "mock fallback drift",
+ "governance catalog missing",
+ "no-change surface evidence rejected"
+ ]
+ },
+ {
+ "id": "claw-chat-ready-streaming",
+ "title": "Claw 首屏、聊天、流式与中断",
+ "risk": "P0",
+ "executor": "mixed",
+ "lanes": [
+ "L2-agent-runtime",
+ "L3-product-surface"
+ ],
+ "commands": [
+ "npm run verify:gui-smoke"
+ ],
+ "goal": "启动 Lime 后验证 workspace ready、发起一次聊天、看到流式增量、可中断并能继续下一轮。",
+ "verifier": "GUI 状态、DevBridge、runtime transcript、console/network 都有证据;不能只以截图或单测通过判定。",
+ "evidenceRequired": [
+ "gui screenshot or trace",
+ "DevBridge health",
+ "runtime transcript",
+ "console/network summary",
+ "GUI session owner / isolation statement"
+ ],
+ "evidenceLayers": [
+ "deterministic-smoke",
+ "gui-trace",
+ "runtime-transcript"
+ ],
+ "failureModes": [
+ "workspace not ready",
+ "stream stuck",
+ "interrupt ignored",
+ "mock fallback mistaken as real path",
+ "parallel GUI smoke interference"
+ ]
+ },
+ {
+ "id": "tool-approval-sandbox-boundary",
+ "title": "工具调用、Approval 与 Sandbox 边界",
+ "risk": "P0",
+ "executor": "runtime_harness",
+ "lanes": [
+ "L2-agent-runtime",
+ "L4-behavior-eval"
+ ],
+ "commands": [
+ "npm run smoke:agent-runtime-tool-surface",
+ "npm run smoke:agent-runtime-approval-sandbox"
+ ],
+ "goal": "覆盖允许工具、拒绝工具、超时工具和需要授权的工具,确认 runtime 不绕过 approval 或 sandbox。",
+ "verifier": "transcript 中必须有 tool request、decision、result/error、恢复动作;拒绝或超时不能把用户卡死。",
+ "evidenceRequired": [
+ "tool timeline",
+ "approval decision",
+ "sandbox policy",
+ "error recovery transcript"
+ ],
+ "evidenceLayers": [
+ "deterministic-smoke",
+ "runtime-transcript"
+ ],
+ "failureModes": [
+ "approval bypass",
+ "tool result missing",
+ "timeout without recovery",
+ "unsafe tool exposed"
+ ]
+ },
+ {
+ "id": "skill-forge-register-bind-enable",
+ "title": "Skill Forge 生成、校验、注册、绑定与显式启用",
+ "risk": "P0",
+ "executor": "mixed",
+ "lanes": [
+ "L1-contract-bridge",
+ "L2-agent-runtime",
+ "L4-behavior-eval"
+ ],
+ "commands": [
+ "npm run test:contracts",
+ "npm run smoke:agent-service-skill-entry"
+ ],
+ "goal": "从 capability draft 到 verify/register,再到 runtime binding readiness 与 session scope 显式 enable。",
+ "verifier": "不能把已注册误判为已自动进入 tool surface;必须看到 readiness、metadata、enable 和 SkillTool gate 证据。",
+ "evidenceRequired": [
+ "capability draft evidence",
+ "registration result",
+ "runtime binding projection",
+ "SkillTool gate transcript"
+ ],
+ "evidenceLayers": [
+ "deterministic-smoke",
+ "runtime-transcript"
+ ],
+ "failureModes": [
+ "registered equals executable",
+ "metadata auto-enables skill",
+ "missing provenance",
+ "unsafe endpoint leaked"
+ ]
+ },
+ {
+ "id": "browser-runtime-site-adapter",
+ "title": "浏览器运行时与站点适配器",
+ "risk": "P0",
+ "executor": "mixed",
+ "lanes": [
+ "L2-agent-runtime",
+ "L3-product-surface"
+ ],
+ "commands": [
+ "npm run smoke:browser-runtime",
+ "npm run smoke:site-adapters"
+ ],
+ "goal": "验证 browser runtime attach/status、site adapter catalog、页面读取和清理路径。",
+ "verifier": "必须检查浏览器 session、adapter 状态、console/network 和 cleanup;不能只验证目录可读。",
+ "evidenceRequired": [
+ "browser runtime report",
+ "site adapter catalog",
+ "console/network summary",
+ "cleanup status",
+ "cleanup warning classified",
+ "GUI session owner / isolation statement"
+ ],
+ "evidenceLayers": [
+ "deterministic-smoke",
+ "gui-trace"
+ ],
+ "failureModes": [
+ "session leak",
+ "adapter catalog drift",
+ "browser permission denied",
+ "cleanup skipped",
+ "cleanup warning hidden by zero exit",
+ "parallel GUI smoke interference"
+ ]
+ },
+ {
+ "id": "knowledge-ingest-retrieve-summarize",
+ "title": "Knowledge 导入、检索与引用总结",
+ "risk": "P1",
+ "executor": "mixed",
+ "lanes": [
+ "L3-product-surface",
+ "L4-behavior-eval"
+ ],
+ "commands": [
+ "npm run smoke:knowledge-gui",
+ "npm run knowledge:product-e2e"
+ ],
+ "goal": "验证项目资料从选择、保存、整理到检索总结的产品闭环。",
+ "verifier": "回答必须带来源或空结果说明;GUI 状态、数据落盘和产品 E2E 证据要一致。",
+ "evidenceRequired": [
+ "knowledge gui smoke",
+ "product e2e log",
+ "source citation or empty-state evidence"
+ ],
+ "failureModes": [
+ "source lost",
+ "empty state hidden",
+ "legacy fallback used",
+ "provider e2e not explicit"
+ ]
+ },
+ {
+ "id": "workspace-ready-session-restore",
+ "title": "Workspace ready 与会话恢复",
+ "risk": "P0",
+ "executor": "npm_command",
+ "lanes": [
+ "L3-product-surface"
+ ],
+ "commands": [
+ "npm run smoke:workspace-ready",
+ "npm run verify:gui-smoke"
+ ],
+ "goal": "验证默认 workspace 准备态、会话恢复、关键面板基础可用。",
+ "verifier": "workspace-ready smoke 和 GUI smoke 都通过;失败时能定位到 Bridge、文件系统、状态恢复或 UI 层。",
+ "evidenceRequired": [
+ "workspace smoke log",
+ "gui smoke log",
+ "DevBridge status",
+ "design canvas project roundtrip save/open evidence",
+ "GUI session owner / isolation statement"
+ ],
+ "evidenceLayers": [
+ "deterministic-smoke",
+ "gui-trace"
+ ],
+ "failureModes": [
+ "workspace init failed",
+ "session restore stale",
+ "bridge unavailable",
+ "ui ready false positive",
+ "design canvas export no save status",
+ "parallel GUI smoke interference"
+ ]
+ },
+ {
+ "id": "team-runtime-message-peers",
+ "title": "Team Runtime 创建、通信与清理",
+ "risk": "P1",
+ "executor": "runtime_harness",
+ "lanes": [
+ "L1-contract-bridge",
+ "L2-agent-runtime"
+ ],
+ "commands": [
+ "npm run test:contracts",
+ "npm run smoke:agent-runtime-tool-surface-page"
+ ],
+ "goal": "验证 Agent/TeamCreate/TeamDelete/SendMessage/ListPeers/SendUserMessage current surface。",
+ "verifier": "tool inventory、runtime 注册、mock fallback、前端 tool display 同步;不得复活 SubAgentTask compat 工具。",
+ "evidenceRequired": [
+ "tool inventory",
+ "runtime transcript",
+ "frontend display snapshot",
+ "mock fallback summary"
+ ],
+ "failureModes": [
+ "tool inventory mismatch",
+ "peer list stale",
+ "compat tool resurrected",
+ "message not displayed"
+ ]
+ },
+ {
+ "id": "automation-scheduler-retry-cancel",
+ "title": "自动化任务触发、重试、取消与状态回放",
+ "risk": "P1",
+ "executor": "mixed",
+ "lanes": [
+ "L2-agent-runtime",
+ "L4-behavior-eval"
+ ],
+ "commands": [
+ "npm run verify:local"
+ ],
+ "goal": "验证调度任务不会静默失败,连续失败、冷却恢复、取消和状态回放都有证据。",
+ "verifier": "必须看到任务状态机、失败计数、重试/取消动作和用户可见状态;不能只看后台日志。",
+ "evidenceRequired": [
+ "scheduler state",
+ "retry/cancel log",
+ "user-visible status",
+ "replay state"
+ ],
+ "failureModes": [
+ "silent retry loop",
+ "cancel ignored",
+ "state replay missing",
+ "background failure hidden"
+ ]
+ },
+ {
+ "id": "harness-replay-regression",
+ "title": "Harness Replay 样本与趋势回归",
+ "risk": "P0",
+ "executor": "npm_command",
+ "lanes": [
+ "L4-behavior-eval"
+ ],
+ "commands": [
+ "npm run harness:eval",
+ "npm run harness:eval:trend"
+ ],
+ "goal": "验证固定 replay 样本、工作区发现、grader 合同和 trend 摘要不退化。",
+ "verifier": "summary/trend 产物存在且 current/degraded observability gap 角色清晰。",
+ "evidenceRequired": [
+ "harness eval summary",
+ "trend report",
+ "invalid case list",
+ "observability outcome summary"
+ ],
+ "evidenceLayers": [
+ "deterministic-smoke",
+ "runtime-transcript"
+ ],
+ "failureModes": [
+ "fixture invalid",
+ "grader contract drift",
+ "trend history broken",
+ "observability gap mixed"
+ ]
+ },
+ {
+ "id": "qcloop-batch-verifier-repair",
+ "title": "qcloop 批量执行、独立质检与返修闭环",
+ "risk": "P1",
+ "executor": "qcloop",
+ "lanes": [
+ "L4-behavior-eval",
+ "L5-release-ops"
+ ],
+ "commands": [
+ "npm run agent-qc:report",
+ "npm run agent-qc:export-evidence -- --job-json ./tmp/qcloop-job.json --items-json ./tmp/qcloop-items.json --check"
+ ],
+ "goal": "把一组 Agent QC 场景提交给 qcloop,验证 worker/verifier/repair/max_qc_rounds 和 evidence store。",
+ "verifier": "每个 item 有独立状态、attempt、qc_round、feedback;耗尽项不会被误报为通过。",
+ "evidenceRequired": [
+ "qcloop job id",
+ "item status table",
+ "verifier verdict json",
+ "repair round summary",
+ "agent qc evidence pack",
+ "stale item sidecar",
+ "worker stdout/stderr length summary",
+ "worker CLI environment sidecar",
+ "inner Codex MCP startup policy",
+ "MCP command override sidecar",
+ "inner worker rules/skill isolation policy",
+ "read-only worker policy",
+ "max_qc_rounds policy",
+ "active GUI qcloop sidecar check",
+ "GUI single-owner policy"
+ ],
+ "failureModes": [
+ "missing item",
+ "self-review only",
+ "exhausted marked success",
+ "feedback not used",
+ "running no-output stale",
+ "worker lease heartbeat without stdout",
+ "worker codex binary unavailable",
+ "worker auth or sandbox misconfiguration",
+ "worker user-config MCP startup no-output hang",
+ "mcp_servers root override does not disable nested servers",
+ "inner worker project rules cause nonessential tool startup",
+ "repair prompt mutates workspace",
+ "worker modifies source during QC",
+ "parallel GUI smoke interference"
+ ]
+ },
+ {
+ "id": "release-package-startup-smoke",
+ "title": "发布包安装、启动与首屏冒烟",
+ "risk": "P0",
+ "executor": "release_workflow",
+ "lanes": [
+ "L3-product-surface",
+ "L5-release-ops"
+ ],
+ "commands": [
+ "npm run verify:app-version",
+ "npm run verify:gui-smoke"
+ ],
+ "goal": "发布前验证版本一致、启动冒烟、首屏 ready、Bridge health 和发布阻断条件可被 release gate 复核。",
+ "verifier": "verify:app-version 与 GUI startup smoke 成功;如无真实安装包 artifact,应明确记录为 source-tree startup smoke,不能把它伪装成 installer 验证;release Evidence Pack 覆盖由 release-summary gate 单独强制。",
+ "evidenceRequired": [
+ "version check",
+ "startup smoke",
+ "artifact or source-tree startup scope",
+ "waiver list",
+ "GUI smoke natural exit and design canvas roundtrip result",
+ "GUI session owner / isolation statement"
+ ],
+ "evidenceLayers": [
+ "deterministic-smoke",
+ "release-artifact"
+ ],
+ "failureModes": [
+ "version mismatch",
+ "artifact missing",
+ "startup crash",
+ "manual-only release",
+ "unowned waiver",
+ "GUI smoke hangs before design canvas export status",
+ "parallel GUI smoke interference"
+ ]
+ }
+ ]
+}
diff --git a/docs/test/testing-strategy-2026.md b/docs/test/testing-strategy-2026.md
index 145192494..303593a13 100644
--- a/docs/test/testing-strategy-2026.md
+++ b/docs/test/testing-strategy-2026.md
@@ -10,9 +10,23 @@
- `docs/test/README.md`:当前测试入口与命令索引
- `docs/test/e2e-tests.md`:当前浏览器续测与 E2E 总览入口
+- `docs/tests/agent-ops-qc.md`:当前 Agent 运营级测试体系、qcloop 场景与 Evidence Pack 门禁
+- `docs/tests/agent-qc-p0-scenarios.md`:当前 Agent QC P0 场景执行、GUI/runtime 证据与失败沉淀手册
+- `docs/tests/lime-agent-qc-rollout-plan.md`:当前 Lime 样本产品的 Agent 运营级测试分阶段落地计划
+- `docs/test/agent-qc-scenarios.manifest.json`:当前 Agent QC 机器可读场景清单
+- `docs/test/agent-qc-evidence.schema.json`:当前 Agent QC Evidence Pack schema
+- `docs/test/agent-qc-gui-flows.manifest.json`:当前 Agent QC GUI / Playwright MCP flow 清单
- `docs/aiprompts/playwright-e2e.md`:当前浏览器续测 / Playwright MCP 事实源
- `package.json`:当前统一测试命令入口
- `scripts/local-ci.mjs`:当前本地智能校验入口
+- `scripts/agent-qc-report.mjs`:当前 Agent QC 场景报告与合同检查入口
+- `scripts/agent-qc-gui-flow-report.mjs`:当前 Agent QC GUI flow 报告与合同检查入口
+- `scripts/agent-qc-qcloop-job.mjs`:当前 Agent QC manifest 到 qcloop job payload 的生成入口
+- `scripts/agent-qc-export-evidence.mjs`:当前 qcloop job 到 Evidence Pack 的导出入口
+- `scripts/agent-qc-release-summary.mjs`:当前 Agent QC Evidence Pack 到 release note 质量证据的汇总入口
+- `scripts/agent-qc-completion-audit.mjs`:当前 Agent QC 整体目标完成度审计入口
+- `.github/workflows/harness-nightly.yml`:当前会上传 `artifacts/agent-qc/agent-qc-report.*`、`agent-qc-gui-flow-report.*` 与 `release-agent-qc-preview.*`
+- `.github/workflows/release.yml`:当前在创建 GitHub Release 前强制校验 `agent_qc_evidence_path` 对应的 Agent QC Evidence Pack
- `scripts/report-legacy-surfaces.mjs`:当前 legacy / compat 回流护栏
### compat
@@ -48,6 +62,7 @@
| 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 |
| ------ | ------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| P1 | Agent eval 仍未完全工程化 | 价值高,且当前最缺的是把证据沉淀成长期回归资产 | 已补 `docs/test/harness-evals.md`、`harness-evals.manifest.json`、`scripts/harness-eval-runner.mjs`、`scripts/harness-eval-trend-report.mjs` 与 nightly 摘要 / trend 骨架,但真实执行与更多高价值样本仍缺 | 形成稳定任务集、可增长 replay 样本、grader、nightly 输出与趋势指标 |
+| P1 | qcloop 真实运行结果尚未导出为 Evidence Pack | 运营级测试需要从“场景清单”进入“每次运行可审计证据” | 已补 Agent QC manifest、evidence schema、报告脚本与 `test:contracts` 门禁;下一步需要 qcloop job 结果按 schema 落盘 | qcloop 批次可导出 `agent-qc-evidence.schema.json` 形状,并被 release / nightly 消费 |
| P2 | terminal / server 自包含 smoke 仍可继续扩面 | 最小 GUI smoke 基线已具备,但更细分主链仍缺专项守卫 | 当前 `workspace-ready / browser-runtime / site-adapters` 已覆盖 GUI 最小主链;`smoke:social-workbench` 仍依赖已有 session,terminal / server 还没有各自独立的自包含 smoke 入口 | 如后续需要继续扩面,应补 terminal 或 server 的独立 smoke,而不是继续把现有 3 条 current smoke 算成缺口 |
## 4. 建议执行顺序
diff --git a/docs/tests/README.md b/docs/tests/README.md
new file mode 100644
index 000000000..ad547602d
--- /dev/null
+++ b/docs/tests/README.md
@@ -0,0 +1,25 @@
+# `docs/tests` 测试文档
+
+本目录放面向维护者和测试 Agent 阅读的测试方法论、运行手册与运营门禁说明。
+
+## 当前入口
+
+- `agent-ops-qc.md`:Agent 运营级测试体系,定义 qcloop、Evidence Pack、测试分层和发布门禁。
+- `agent-qc-p0-scenarios.md`:P0 场景执行手册,说明核心场景如何选择、生成 qcloop payload、按 GUI flow 运行、验收和沉淀证据。
+- `lime-agent-qc-rollout-plan.md`:Lime 作为样本产品的 Agent 运营级测试落地计划,说明从 partial evidence 到 P0 release gate 的分阶段路径。
+- `lime-agent-autonomous-testing-plan.md`:Lime 作为实际 Agent 产品的自主化测试体系示例计划,说明风险地图、Agent 分工、测试组合和分阶段建设路径。
+- `lime-agent-autonomous-test-execution-matrix.md`:Lime 自主测试执行矩阵,把 owner gate、P0 场景、证据层和失败回写落成 Agent 可执行步骤。
+- `lime-agent-qc-current-blockers.md`:Lime 当前 P0 qcloop 阻断清单,记录真实 fail evidence、root cause 和关闭条件。
+- `lime-agent-qc-stale-worker-analysis-2026-05-10.md`:当前 stale qcloop worker 的根因分析样本,汇总 qcloop status、DB lease、进程树、binary provenance、失败分类和恢复路径。
+- `lime-agent-qc-qcloop-operations.md`:qcloop 批次只读监控、worker preflight、sidecar evidence、stale item 和重跑策略运维手册。
+- `lime-agent-qc-stale-owner-intervention.md`:stale GUI qcloop owner 的只读取证、owner 确认和最小处置协议。
+- `lime-agent-qc-evidence-contract.md`:qcloop worker / verifier 的结构化 evidence 输出契约,定义 `QCLOOP_WORKER_RESULT` 与 `QCLOOP_EVIDENCE_SUMMARY_JSON`。
+- `lime-agent-qc-completion-audit-2026-05-10.md`:当前整体目标完成度审计,逐项映射需求、证据、缺口和关闭条件。
+- `ai-agent-testing-guide.md`:AI Agent 测试基础理论与评分器说明。
+
+## 与 `docs/test` 的分工
+
+- `docs/tests/`:人读文档、运行手册、运营测试策略。
+- `docs/test/`:测试入口索引、机器可读 manifest/schema、harness eval 资产和测试用例模板。
+
+如果文档会被 qcloop、CI 或脚本直接读取,优先放在 `docs/test/`;如果文档主要指导人和 Agent 如何执行测试,优先放在 `docs/tests/`。
diff --git a/docs/tests/agent-ops-qc.md b/docs/tests/agent-ops-qc.md
new file mode 100644
index 000000000..50c5642f8
--- /dev/null
+++ b/docs/tests/agent-ops-qc.md
@@ -0,0 +1,275 @@
+# Lime Agent 运营级测试体系
+
+> 目标:把 Lime 的测试从“人手工点一遍”升级为“Agent 自动执行、证据可审计、人类只处理例外”的运营级质量系统。
+
+## 1. 结论
+
+Lime 已经有较丰富的测试基础:`verify:local`、`test:contracts`、`verify:gui-smoke`、`smoke:*`、`harness:eval`、nightly 和发布工作流。下一阶段的关键不是再堆一批零散测试,而是建立一条统一主链:
+
+```text
+改动 / 发布目标
+ -> Test Planner 选择测试 lane
+ -> qcloop / npm / Playwright MCP / harness 执行
+ -> Evidence Pack 汇总证据
+ -> Verifier 判定 pass / fail / blocked / needs-human-review / waived
+ -> 失败沉淀 replay / scenario / regression
+ -> 发布门禁只审核证据,不靠人工记忆
+```
+
+运营阶段的默认分工是:
+
+- Agent 负责读 diff、选择测试、执行场景、收集证据、初步归因。
+- qcloop 负责批量队列、独立 verifier、多轮返修和状态留痕。
+- 人类负责审核高风险 waiver、设计核心场景、处理语义质量争议。
+- CI / nightly / release workflow 负责把证据变成门禁。
+
+## 2. 测试分层
+
+| Lane | 名称 | 证明什么 | 当前入口 | 不能证明什么 |
+| --- | --- | --- | --- | --- |
+| `L0-static-unit` | 快速卫生与确定性单测 | 低级错误、类型、确定性逻辑 | `npm run verify:local` | GUI 真能用、Agent 行为质量 |
+| `L1-contract-bridge` | 契约、Bridge 与治理护栏 | 前端 / Rust / mock / catalog 同步 | `npm run test:contracts` | 用户路径体验 |
+| `L2-agent-runtime` | Agent Runtime 与工具面 | turn、streaming、tool、approval、sandbox、team | `smoke:agent-runtime-*` | 真实 GUI 表面 |
+| `L3-product-surface` | GUI / WebUI / 桌面产品表面 | DevBridge、workspace、browser runtime、页面可用 | `npm run verify:gui-smoke` | 长程语义退化 |
+| `L4-behavior-eval` | Agent 行为评测与回归样本 | replay、grader、语义质量、趋势 | `npm run harness:eval` | 安装包可用 |
+| `L5-release-ops` | 发布与运营门禁 | 版本、包、启动、release evidence | `verify:app-version` + release smoke | 未覆盖的线上新场景 |
+
+规则:任何 Agent runtime、GUI 主路径或发布改动,都不能只拿 L0 作为可交付结论。
+
+## 3. Evidence Pack 标准
+
+Evidence Pack 是测试系统的事实源,schema 位于:
+
+- `docs/test/agent-qc-evidence.schema.json`
+
+每次 Agent QC 运行至少要记录:
+
+- `subject`:仓库、ref、diff base、changed files、风险标签。
+- `laneResults`:每个 lane 的命令、状态、日志、artifact。
+- `scenarioResults`:场景 id、执行器、runtime transcript、GUI trace、console/network 摘要、失败模式。
+- `verdict`:总体状态、阻断原因、waiver、下一步动作。
+
+状态语义固定为:
+
+| 状态 | 含义 |
+| --- | --- |
+| `pass` | 证据完整且通过 |
+| `fail` | 有明确失败,必须修复 |
+| `blocked` | 环境、权限、凭证或外部依赖阻断,不能假装通过 |
+| `needs-human-review` | 语义质量或风险接受需要人审 |
+| `waived` | 已记录 owner、原因、过期时间的临时放行 |
+| `skipped` | 当前改动不适用,必须有选择理由 |
+
+## 4. 场景 Manifest
+
+运营级场景清单位于:
+
+- `docs/test/agent-qc-scenarios.manifest.json`
+
+本仓库提供校验与报告入口:
+
+```bash
+npm run agent-qc:report
+npm run agent-qc:report:json
+npm run agent-qc:check
+```
+
+Manifest 的职责:
+
+- 固定每个核心场景属于哪些 lane。
+- 固定默认命令、执行器、目标、verifier、证据要求和失败模式。
+- 给 qcloop 提供可拆分的 item 来源。
+- 让 CI 可以检查场景清单是否引用了不存在的 npm script。
+
+首批 P0 / P1 场景包括:
+
+- 命令桥接四侧一致。
+- Claw 首屏、聊天、流式与中断。
+- 工具调用、approval 与 sandbox。
+- Skill Forge 生成、校验、注册、绑定与显式启用。
+- 浏览器运行时与站点适配器。
+- Knowledge 导入、检索与引用总结。
+- Workspace ready 与会话恢复。
+- Team Runtime 创建、通信与清理。
+- 自动化任务触发、重试、取消与状态回放。
+- Harness replay 样本与趋势回归。
+- qcloop 批量执行、独立质检与返修闭环。
+- 发布包安装、启动与首屏冒烟。
+
+## 5. qcloop 使用方式
+
+qcloop 不是替代 `npm`、Rust 或 Playwright,而是把它们编排成可审计的 Agent QA loop。
+
+推荐让外层 Agent 创建 qcloop 批次:
+
+```text
+请读取 http://127.0.0.1:3000/llm-full.txt,然后使用 qcloop 按 docs/test/agent-qc-scenarios.manifest.json 测试当前 Lime 改动。只选择与 diff 风险相关的场景,输出 evidence pack 摘要。
+```
+
+Worker prompt 应遵守:
+
+- 每个 item 只做一个场景。
+- 优先执行 manifest 中的默认命令。
+- GUI 场景必须采集 DevBridge、console、network、截图或 trace。
+- Runtime 场景必须采集 transcript、tool timeline、approval、sandbox 和错误恢复。
+- 不顺手修复无关问题;失败只做归因和最小复现。
+
+Verifier prompt 应遵守:
+
+- 独立审查 worker 输出,不能自评通过。
+- qcloop 模板必须包含 `{{stdout}}` 或 `{{output}}`,并至少带出 `{{attempt_status}}`、`{{attempt_type}}`、`{{exit_code}}`;否则 verifier 实际看不到 worker 证据,会把可用 stdout 误判为缺证据。
+- 默认不要把完整 `{{stderr}}` 放进 verifier prompt;Codex / GUI 场景 stderr 可能包含超长运行日志,优先要求 worker 在 stdout 末尾输出可审查摘要与 evidence 路径。
+- 按 `verifier`、`evidenceRequired`、`failureModes` 判定。
+- 缺证据时输出 `needs-human-review` 或 `blocked`,不能输出 pass。
+- 只接受结构化 JSON verdict,便于后续进入 Evidence Pack。
+
+可以先从 manifest 生成 qcloop job payload,避免人工复制场景:
+
+```bash
+npm run agent-qc:qcloop-job -- \
+ --risk P0 \
+ --cwd "$(pwd)" \
+ --output "./.lime/qc/qcloop-p0-job.json" \
+ --check
+```
+
+如果需要直接复制 curl:
+
+```bash
+npm run agent-qc:qcloop-job -- --risk P0 --format curl
+```
+
+qcloop 批次完成后,用仓库内导出脚本把 job 结果转成 Evidence Pack:
+
+```bash
+npm run agent-qc:export-evidence -- \
+ --job-id "" \
+ --output "./.lime/qc/agent-qc-evidence.json" \
+ --ref "" \
+ --diff-base "origin/main" \
+ --check
+```
+
+离线排障时也可以从 qcloop API 保存的 JSON 导出:
+
+```bash
+node scripts/agent-qc-export-evidence.mjs \
+ --job-json "./tmp/qcloop-job.json" \
+ --items-json "./tmp/qcloop-items.json" \
+ --output "./tmp/agent-qc-evidence.json" \
+ --check
+```
+
+P0 场景的执行细节见 `docs/tests/agent-qc-p0-scenarios.md`。
+
+发布前再把一个或多个 Evidence Pack 汇总成 release note 可引用的质量证据:
+
+```bash
+npm run agent-qc:release-summary -- \
+ --evidence "./.lime/qc/agent-qc-evidence.json" \
+ --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \
+ --require-risk P0 \
+ --harness-summary "./.lime/harness/reports/harness-eval-summary.json" \
+ --harness-trend "./.lime/harness/reports/harness-eval-trend.json" \
+ --tag "" \
+ --output "./.lime/qc/release-agent-qc.md" \
+ --check
+```
+
+`--check` 的语义是发布门禁:没有 Evidence Pack、Evidence Pack 非 `pass`、未覆盖全部 P0 scenario id,或存在未处理 blocker 时都不能作为绿色发布证据。
+
+正式 GitHub Release 也执行同一条硬门禁:`.github/workflows/release.yml` 会在创建 / 刷新 release notes 前读取 `agent_qc_evidence_path`,默认值为 `.lime/qc/agent-qc-evidence.json`。文件不存在、`agent-qc-release-summary --check` 非 pass,或 Evidence Pack 未覆盖全部 P0 scenario id 时,release workflow 会直接失败,不再创建“只靠人工点过”的发布。
+
+Nightly 会额外上传 `artifacts/agent-qc/`:
+
+- `agent-qc-report.md/json`:当前场景 manifest 报告。
+- `agent-qc-gui-flow-report.md/json`:当前 GUI / Playwright MCP flow manifest 报告。
+- `release-agent-qc-preview.md/json`:无真实 Evidence Pack 时的 release 摘要预览,状态保持 `blocked`,用于提醒发布前必须补证据。
+
+需要判断整体目标是否真的完成时,运行完成度审计:
+
+```bash
+npm run agent-qc:audit
+```
+
+该命令会把标准文档、scenario manifest、GUI flow manifest、qcloop payload、Evidence Pack 导出、nightly artifact、真实 qcloop evidence、真实 GUI evidence 和 release hard gate 逐项映射为 `PASS/MISS`。真实 qcloop evidence 必须覆盖 manifest 中所有 P0 scenario id,不能只用相同数量的非 P0 场景凑数。只要真实证据或 release 硬门禁缺失,审计结果就保持 `incomplete`。
+
+## 6. 组合测试策略
+
+Agent 产品不能依赖单一测试手段。Lime 的默认组合如下:
+
+| 组合 | 用法 | Lime 示例 |
+| --- | --- | --- |
+| 白盒 + 黑盒 | 白盒看 transcript,黑盒看用户结果 | tool timeline + GUI 状态 |
+| 快照 + 语义评测 | UI 防结构漂移,Agent 防语义退化 | React snapshot + harness grader |
+| 冒烟 + 长程任务 | PR 快速验证,nightly 跑长链 | `verify:gui-smoke` + `harness:eval:trend` |
+| Mock + Real Backend | 本地可 mock,发布必须真实路径 | DevBridge mock + release startup smoke |
+| 确定性断言 + LLM Judge | 合同用代码断言,开放回答用 rubric | `test:contracts` + grader.md |
+| CI + qcloop | CI 做硬门禁,qcloop 做批量质检 | manifest item + verifier/repair |
+
+## 7. 改动类型到测试选择
+
+| 改动类型 | 最小门槛 | 运营增强 |
+| --- | --- | --- |
+| 普通前端逻辑 | `npm run verify:local` | 受影响 UI snapshot |
+| Tauri 命令 / Bridge / mock | `verify:local` + `test:contracts` | qcloop 跑 `command-bridge-contract` |
+| GUI 壳 / Workspace / 主页面 | `verify:local` + `verify:gui-smoke` | Playwright MCP 跑真实交互 |
+| Agent Runtime / tool surface | Rust 定向测试 + `smoke:agent-runtime-*` | transcript 进入 Evidence Pack |
+| Skill Forge / SkillTool | contracts + runtime binding 定向测试 | `skill-forge-register-bind-enable` 场景 |
+| Browser Runtime / site adapter | `smoke:browser-runtime` + `smoke:site-adapters` | console/network/cleanup 证据 |
+| Knowledge 产品路径 | `smoke:knowledge-gui` + `knowledge:product-e2e` | 来源引用与空结果语义评测 |
+| 发布 / 版本 / 包 | `verify:app-version` + GUI smoke | release evidence pack + waiver 审核 |
+
+## 8. 发布门禁
+
+发布前不再接受“我手工点过”。必须有 Evidence Pack,且满足:
+
+- L0/L1 无失败。
+- 涉及 GUI 的改动有 L3 证据。
+- 涉及 Agent runtime 的改动有 L2 transcript。
+- 涉及行为质量的改动进入 L4 eval 或记录明确 waiver。
+- 发布包有 L5 证据:版本一致、artifact、安装或启动 smoke。
+- 所有 waiver 都有 owner、原因、过期时间和复测计划。
+
+建议 release note 增加“测试证据”小节:
+
+```text
+Quality evidence:
+- verify:local: pass
+- test:contracts: pass
+- verify:gui-smoke: pass
+- agent-qc scenarios: 8 pass / 1 waived / 0 fail
+- harness trend: no new current observability gap
+```
+
+## 9. 线上反馈闭环
+
+运营后,线上问题必须沉淀为测试资产:
+
+1. 收集用户 bug、日志异常、CI flaky、runtime transcript。
+2. Regression Curator Agent 生成最小复现场景。
+3. 人类审核是否进入长期回归。
+4. 按类型沉淀为 qcloop scenario、harness replay、Playwright MCP 流程或单元测试。
+5. Nightly 跟踪复发率、flaky rate、修复耗时和行为质量趋势。
+
+原则:每次真实事故至少新增一个可复跑测试或一条证据规则。
+
+## 10. 当前落地状态
+
+已落地:
+
+- Agent QC 运营模型文档:本文件。
+- Evidence Pack schema:`docs/test/agent-qc-evidence.schema.json`。
+- 核心场景 manifest:`docs/test/agent-qc-scenarios.manifest.json`。
+- manifest 校验与报告脚本:`scripts/agent-qc-report.mjs`。
+- `test:contracts` 接入 `agent-qc:check`,避免 QC 标准自身漂移。
+- qcloop job 导出脚本:`scripts/agent-qc-export-evidence.mjs`,可把真实 job / items 转成 Evidence Pack sidecar。
+- qcloop 只读状态监控:`scripts/agent-qc-qcloop-status.mjs`,可识别 running / pending / exhausted / stale,并在 worker stdout 明确 `QCLOOP_WORKER_RESULT=BLOCKED` 时保留环境阻断语义。
+- release summary 与 completion audit:`scripts/agent-qc-release-summary.mjs`、`scripts/agent-qc-completion-audit.mjs`,发布门禁只接受官方 pass evidence。
+- 隔离 qcloop sidecar:在 `127.0.0.1:18080`、独立 DB 和显式 Codex sandbox 配置下,worker preflight、workspace ready、browser runtime、Skill Forge、release source-tree startup smoke 已能通过;full P0 v1 已启动且当前 4/8 success、1 running/stale、3 pending;这些是排障证据,不能单独替代官方 8/8 P0 Evidence Pack。
+
+后续优先级:
+
+1. 用修复后的默认 qcloop 或隔离 qcloop 跑同一批次 8/8 P0,避免 partial sidecar 被误用为发布证据。
+2. 为 `claw-chat-ready-streaming` 和 `tool-approval-sandbox-boundary` 补稳定 GUI / runtime transcript 证据,避免只用 smoke 代替深路径。
+3. 将真实 8/8 P0 pass Evidence Pack 写入官方 `.lime/qc/agent-qc-evidence.json` 后,再更新 release note 质量证据。
diff --git a/docs/tests/agent-qc-p0-scenarios.md b/docs/tests/agent-qc-p0-scenarios.md
new file mode 100644
index 000000000..1eac49b9b
--- /dev/null
+++ b/docs/tests/agent-qc-p0-scenarios.md
@@ -0,0 +1,155 @@
+# Lime Agent QC P0 场景执行手册
+
+> 本文件是 `docs/tests/agent-ops-qc.md` 的执行层补充,专门描述 P0 场景如何被 Agent / qcloop / Playwright MCP 执行和验收。
+
+## 1. 使用方式
+
+先生成场景报告,确认 manifest 自身有效:
+
+```bash
+npm run agent-qc:report
+npm run agent-qc:gui-flow:report
+npm run agent-qc:check
+```
+
+如果要让 qcloop 批量执行,把 P0 场景作为 items 提交给 qcloop。推荐 item 直接使用 scenario id,便于后续导出 Evidence Pack:
+
+```text
+command-bridge-contract
+claw-chat-ready-streaming
+tool-approval-sandbox-boundary
+skill-forge-register-bind-enable
+browser-runtime-site-adapter
+workspace-ready-session-restore
+harness-replay-regression
+release-package-startup-smoke
+```
+
+也可以直接生成 qcloop job payload,避免手工维护 items:
+
+```bash
+npm run agent-qc:qcloop-job -- \
+ --risk P0 \
+ --cwd "$(pwd)" \
+ --output "./.lime/qc/qcloop-p0-job.json" \
+ --check
+```
+
+生成后由外层 Agent 调 qcloop API 创建和运行批次;本脚本只生成 payload,不会主动启动 qcloop 或提交任务。
+
+qcloop 完成后导出 Evidence Pack:
+
+```bash
+npm run agent-qc:export-evidence -- \
+ --job-id "" \
+ --output "./.lime/qc/agent-qc-evidence.json" \
+ --ref "" \
+ --diff-base "origin/main" \
+ --check
+```
+
+正式发布门禁只接受覆盖全部 P0 scenario id 的 `.lime/qc/agent-qc-evidence.json`。如果只是验证 qcloop 导出链路,应写入 `.lime/qc/agent-qc-evidence.partial.json`,不能把 partial evidence 当成发布通过证据。
+
+如果 qcloop 不在运行,也可以用离线 JSON:
+
+```bash
+node scripts/agent-qc-export-evidence.mjs \
+ --job-json "./tmp/qcloop-job.json" \
+ --items-json "./tmp/qcloop-items.json" \
+ --output "./tmp/agent-qc-evidence.json" \
+ --check
+```
+
+发布前生成 release note 可引用的 Agent QC 摘要:
+
+```bash
+npm run agent-qc:release-summary -- \
+ --evidence "./.lime/qc/agent-qc-evidence.json" \
+ --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \
+ --require-risk P0 \
+ --tag "" \
+ --output "./.lime/qc/release-agent-qc.md" \
+ --check
+```
+
+正式 `release.yml` 默认也会读取 `.lime/qc/agent-qc-evidence.json`。如果团队把 evidence pack 放在其他路径,使用 workflow dispatch 时必须填写 `agent_qc_evidence_path`;否则发布会被 Agent QC 硬门禁阻断。
+
+## 2. P0 场景矩阵
+
+| Scenario | 最低入口 | 关键证据 | 阻断条件 |
+| --- | --- | --- | --- |
+| `command-bridge-contract` | `npm run test:contracts` | 命令合同日志、四侧同步摘要 | 前端/Rust/mock/catalog 任一侧缺失 |
+| `claw-chat-ready-streaming` | `npm run verify:gui-smoke` + Playwright MCP | GUI trace、DevBridge、runtime transcript、console/network | workspace 未 ready、流式卡死、中断不可恢复 |
+| `tool-approval-sandbox-boundary` | `npm run smoke:agent-runtime-tool-surface` + `npm run smoke:agent-runtime-approval-sandbox` | tool timeline、approval decision、sandbox policy | 工具绕过授权、超时无恢复、危险工具暴露 |
+| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft、verify/register、runtime binding、SkillTool gate | 把 registered 误判为 executable、metadata 自动启用 skill |
+| `browser-runtime-site-adapter` | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` | browser session、adapter catalog、console/network、cleanup | session 泄漏、adapter 漂移、cleanup 缺失 |
+| `workspace-ready-session-restore` | `npm run smoke:workspace-ready` + `npm run verify:gui-smoke` | workspace smoke、GUI smoke、DevBridge、design canvas 工程保存 / 打开结果 | ready 假阳性、会话恢复脏状态、设计画布导出无保存状态 |
+| `harness-replay-regression` | `npm run harness:eval` + `npm run harness:eval:trend` | summary、trend、invalid case、observability outcome | fixture invalid、grader 合同漂移、trend 断裂 |
+| `release-package-startup-smoke` | `npm run verify:app-version` + release / GUI startup smoke | 版本一致性、启动 smoke、artifact 或 source-tree 启动范围、waiver、GUI smoke 自然收口 | 手工口头放行、版本不一致、安装启动失败、把 source-tree smoke 伪装成 installer 验证、GUI smoke 卡在设计画布导出 |
+
+### 2.1 证据深度分层
+
+P0 场景允许先用确定性 smoke 快速证明主路径可执行,但 release gate 不能只看 smoke 摘要。每个 worker stdout 必须明确声明本次覆盖的是哪一层:
+
+| 层级 | 允许证明什么 | 不能替代什么 |
+| --- | --- | --- |
+| `deterministic-smoke` | 命令能运行、DevBridge 可达、workspace ready、基础 runtime surface 可读 | live long-turn transcript、真实中断恢复、approval/sandbox 完整轨迹 |
+| `gui-trace` | 用户路径截图 / trace、console/network 摘要、关键 UI 断言 | 后端 turn 持久化和 tool timeline |
+| `runtime-transcript` | submit / stream / interrupt / resume / tool request / decision / result 顺序 | GUI 可见性、桌面壳启动、发布包启动 |
+| `release-artifact` | 安装包或 source-tree 启动范围、版本一致性、waiver | 运行期 Agent 行为质量 |
+
+如果当前只跑到 `deterministic-smoke`,worker 可以输出 `QCLOOP_WORKER_RESULT=PASS` 表示命令通过;但 verifier 仍应在缺少 deep evidence 时判该 P0 场景不满足 release pass。正确做法是补证据或拆分场景,不是降低 verifier。
+
+`npm run agent-qc:check` 会机械校验 P0 场景必须声明合法 `evidenceLayers`。当前允许的层级是 `deterministic-smoke`、`gui-trace`、`runtime-transcript`、`release-artifact`;缺失或未知层级都应阻断。
+
+## 3. GUI 场景证据要求
+
+GUI 场景不能只提交截图,至少要包含:
+
+- DevBridge 是否健康。
+- GUI session owner / isolation statement:说明本次是否独占 Lime GUI / DevBridge 会话,或列出仍在 running/stale 的 GUI qcloop sidecar。
+- 关键用户路径的页面状态。
+- console error 摘要。
+- network error 摘要。
+- 如果使用 Playwright MCP,记录操作步骤和最终断言。
+- 如果命中 mock fallback,必须说明这是预期 mock 还是错误退化。
+
+同一台机器上的 GUI P0 不应并发运行多个 full P0 qcloop 批次。多个 worker 复用同一个 `127.0.0.1:1420` / `127.0.0.1:3030` 会话时,页面导航、按钮点击和状态断言可能互相抢占;这类失败必须按 `parallel GUI smoke interference` 记录,而不是直接归咎产品。
+
+启动新的 GUI P0 批次前,先执行:
+
+```bash
+npm run agent-qc:gui-owner-check -- --check
+```
+
+该检查发现 active GUI qcloop owner 时必须阻断新批次。
+
+## 4. Runtime 场景证据要求
+
+Runtime 场景不能只提交最终回答,至少要包含:
+
+- turn / request id。
+- tool call timeline。
+- approval decision。
+- sandbox policy。
+- tool result 或 error。
+- 中断、超时、失败后的恢复动作。
+
+## 5. Verifier 判定规则
+
+- 缺证据时输出 `needs-human-review`,不要输出 `pass`。
+- 环境或权限阻断时输出 `blocked`,不要输出 `fail`。
+- `failed` / `exhausted` qcloop item 对应 Evidence Pack `fail`。
+- `pending` / `running` qcloop item 对应 Evidence Pack `blocked`。
+- qcloop job 的 `verifier_prompt_template` 必须显式包含 `{{stdout}}` 或 `{{output}}`,否则 verifier 看不到 worker 输出;`agent-qc:qcloop-job` 会自动补充 stdout / attempt 状态占位符。
+- 只有所有必需证据齐全且 verifier 条件满足时才输出 `pass`。
+
+## 6. 失败沉淀
+
+P0 失败修复后必须至少做一项沉淀:
+
+- 新增或提升 harness replay case。
+- 新增 qcloop scenario item。
+- 新增 Playwright MCP 操作手册或可复用 trace。
+- 新增 Vitest / Rust 定向回归。
+- 更新 Evidence Pack verifier 规则,防止同类缺证据再次误通过。
diff --git a/docs/tests/lime-agent-autonomous-test-execution-matrix.md b/docs/tests/lime-agent-autonomous-test-execution-matrix.md
new file mode 100644
index 000000000..bbdea27c1
--- /dev/null
+++ b/docs/tests/lime-agent-autonomous-test-execution-matrix.md
@@ -0,0 +1,138 @@
+# Lime Agent 自主测试执行矩阵
+
+> 本文件是 `docs/tests/lime-agent-autonomous-testing-plan.md` 的执行层补充:把“应该如何测试 Lime”落成 Agent 可以按步骤执行的矩阵。它不替代通用标准,也不替代 qcloop Evidence Pack;它只定义 Lime 作为样本产品时,测试 Agent 如何选择场景、采集证据、阻断发布和回写回归。
+
+## 1. 适用范围
+
+Lime 是桌面 GUI + Agent Runtime + 工具/技能/浏览器能力的组合产品,因此不能用单一测试手段证明可运营。一次 Lime Agent 自主测试至少覆盖四类事实:
+
+| 事实层 | 需要证明什么 | 不能接受的假阳性 |
+| --- | --- | --- |
+| 壳层 | App / DevBridge / workspace ready 可用 | 页面打开但 `127.0.0.1:3030/health` 不通 |
+| 运行时层 | thread / turn / stream / persistence 状态正确 | 模型有回复但 turn 状态、恢复状态或持久化错误 |
+| 能力层 | tool / approval / sandbox / skill / browser 真实执行 | mock 返回、注册成功或 UI 显示可用被误认为能力可执行 |
+| 运营层 | qcloop / Evidence Pack / release gate 可审计 | 一次宿主手工命令通过被当成发布证据 |
+
+## 2. 执行前 Owner Gate
+
+任何会复用 GUI、DevBridge、Cargo target 或 qcloop worker 的重型任务,先执行 owner gate。该 gate 只读取状态,不终止进程。
+
+```bash
+npm run agent-qc:qcloop-status -- \
+ --base-url "http://127.0.0.1:18080" \
+ --job-id "" \
+ --format json \
+ --output "./.lime/qc/qcloop-status.current.json"
+
+npm run agent-qc:gui-owner-check -- \
+ --format json \
+ --output "./.lime/qc/gui-owner-current.json" \
+ --watch-history-output "./.lime/qc/stale-owner-watch-history.jsonl"
+
+npm run agent-qc:process-owner-check -- \
+ --format json \
+ --output "./.lime/qc/gui-process-owner-current.json" \
+ --markdown-output "./.lime/qc/gui-process-owner-current.md"
+
+npm run agent-qc:qcloop-db-lease -- \
+ --db "./.lime/qc/qcloop-isolated-worker-preflight.db" \
+ --job-id "" \
+ --format json \
+ --output "./.lime/qc/qcloop-db-lease-current.json" \
+ --markdown-output "./.lime/qc/qcloop-db-lease-current.md"
+```
+
+通过条件:
+
+- `gui-owner-current.json` 没有 active / stale GUI qcloop owner。
+- `gui-process-owner-current.json` 没有 raw GUI smoke、qcloop worker、Cargo / Rust 构建 owner。
+- `qcloop-db-lease-current.json` 没有 running item、续约 lock 或 no-output active attempt。
+- DevBridge preflight 通过:`npm run agent-qc:qcloop-preflight -- --require-devbridge --check`。
+
+阻断条件:
+
+- 存在 stale qcloop worker 且仍在续约 DB lease。
+- 存在长时间 `smoke:*` 或 `verify:gui-smoke` 进程。
+- 存在 Cargo / Rust 编译或 Tauri dev owner,可能抢占 target、DevBridge 或窗口状态。
+- 官方 `.lime/qc/agent-qc-evidence.json` 不是同一批次 8/8 P0 pass。
+
+## 3. P0 执行矩阵
+
+| P0 场景 | 执行命令 | 必须采集的证据 | Release 判定 |
+| --- | --- | --- | --- |
+| `command-bridge-contract` | `npm run test:contracts` | 前端调用、Rust 注册、governance catalog、mock counts | 命令退出码 + contract 摘要均通过 |
+| `workspace-ready-session-restore` | `npm run smoke:workspace-ready` + `npm run verify:gui-smoke -- --reuse-running` | workspace ready、session restore、DevBridge health、GUI trace | GUI ready 和恢复状态都可观察 |
+| `claw-chat-ready-streaming` | `npm run verify:gui-smoke -- --reuse-running` 或专项 Claw smoke | first delta、interrupt command、aborted turn、follow turn completed、GUI 可见恢复结果 | 不能只有“有回复”,必须证明中断后状态正确 |
+| `tool-approval-sandbox-boundary` | `npm run smoke:agent-runtime-tool-surface` + `npm run smoke:agent-runtime-approval-sandbox` | approval policy、sandbox policy、denied/resolved flow、tool timeline | approval / sandbox 进入 turn config 且无绕过 |
+| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft / verify / register / binding readiness / enable allowlist | 注册不等于可执行,显式 enable 才可进入能力面 |
+| `browser-runtime-site-adapter` | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` | attach/status、adapter list、cleanup、console/network 摘要 | cleanup warning 必须归类;session 泄漏不可放行 |
+| `harness-replay-regression` | `npm run harness:eval` + `npm run harness:eval:trend` | eval summary、observability gap、trend 样本 | trend 样本不足只能作为 seed,不能替代长期趋势 |
+| `release-package-startup-smoke` | `npm run verify:app-version` + `npm run verify:gui-smoke -- --reuse-running` | version consistency、artifact scope、startup smoke | 必须标明 source-tree 还是 installer artifact |
+
+执行原则:P0 全绿前不扩 P1/P2;P0 partial sidecar 只能排障,不能覆盖官方 Evidence Pack。
+
+## 4. Agent 分工
+
+| Agent | 输入 | 动作 | 输出 |
+| --- | --- | --- | --- |
+| Planner | diff、manifest、当前 owner sidecar | 选择最小 P0/P1 场景,生成 qcloop payload | `qcloop-*.json`、计划摘要 |
+| Executor | 单个 scenario item | 只执行指定命令,不修无关问题 | stdout/stderr、trace、summary JSON |
+| Verifier | manifest、stdout、exit code、artifact refs | 独立判断 evidence 是否满足要求 | `pass/fail/blocked` verdict |
+| Curator | 失败 evidence | 把失败沉淀为单测、smoke、harness replay 或 GUI flow | 新回归资产和关闭条件 |
+| Release Gate | Evidence Pack、tag、manifest | 验证 P0 覆盖和 pass 状态 | release summary / 阻断原因 |
+
+关键约束:Executor 不能自证通过;Verifier 必须能从证据中复核每个 `evidenceRequired` 与 `failureModes`。
+
+## 5. 组合测试策略
+
+| 组合 | 何时使用 | Lime 示例 |
+| --- | --- | --- |
+| 白盒 + 黑盒 | 协议正确但 GUI 可能不可用 | `test:contracts` 后补 `verify:gui-smoke` |
+| GUI + Runtime transcript | UI 显示正确但后端状态可能错 | Claw streaming / interrupt / resume |
+| Smoke + Deep evidence | 快速入口通过但 P0 需要更强证据 | `verify:gui-smoke` pass 后仍检查 transcript、console、network |
+| Direct host + qcloop | 区分产品问题和 worker 环境问题 | 宿主 smoke pass 但 qcloop worker loopback blocked |
+| Replay + Trend | 修复一次失败后防止回归 | `harness:eval` + `harness:eval:trend` |
+| Failure injection + Recovery | 权限拒绝、超时、断桥后仍要安全 | approval denied、tool timeout、DevBridge unavailable |
+
+## 6. 失败分类与回写
+
+| 失败分类 | 判定标准 | 回写资产 |
+| --- | --- | --- |
+| Product blocker | 宿主直接执行也失败,且影响 P0 行为 | 修产品 + Rust/Vitest/GUI 回归 |
+| Environment blocker | qcloop worker blocked,但宿主 direct host pass | preflight、owner gate、qcloop runtime 修复 |
+| Evidence blocker | 命令通过但缺 transcript / trace / failure mode 解释 | 强化 worker prompt、exporter、schema 或 verifier |
+| Concurrency blocker | 多个 GUI owner 抢同一窗口/DevBridge | single-owner gate、stale owner runbook |
+| Release blocker | Evidence Pack 非 pass 或 P0 覆盖不全 | release summary hard gate |
+
+每个失败的关闭条件固定为:下次能由机器先发现、能定位到证据、能阻断发布。
+
+## 7. 当前 Lime 样本执行顺序
+
+当存在其他本地进程时,本轮只做安全动作:
+
+1. 刷新 qcloop status、GUI owner、raw process owner、DB lease sidecar。
+2. 若 owner 仍 busy,只更新文档和 sidecar,不启动新的 full P0 或完整 `verify:local`。
+3. 若 owner 自然释放,先跑完整 `npm run verify:local`,关闭 `local-verify-gate`。
+4. 在单一 GUI owner 前提下,使用修正后的 qcloop 环境跑 8/8 P0。
+5. 只有 8/8 P0 pass 后,才覆盖 `.lime/qc/agent-qc-evidence.json`。
+6. 再执行 `npm run agent-qc:release-summary -- --check` 与 `npm run agent-qc:audit -- --format json`。
+
+## 8. 不允许的捷径
+
+- 不把 isolated sidecar pass 当成官方 release evidence。
+- 不把宿主 direct host pass 当成 qcloop verifier pass。
+- 不把 `verify:gui-smoke` pass 当成完整 `verify:local` pass。
+- 不在 stale GUI owner 仍存在时启动新的 full GUI P0。
+- 不通过降低 verifier、删 evidenceRequired 或改 schema 来制造绿色。
+- 不在 owner 未确认时 kill / pause / interrupt qcloop worker。
+
+## 9. 相关事实源
+
+- 通用标准:`docs/tests/agent-ops-qc.md`
+- P0 场景:`docs/tests/agent-qc-p0-scenarios.md`
+- Lime 分阶段计划:`docs/tests/lime-agent-autonomous-testing-plan.md`
+- 当前阻断:`docs/tests/lime-agent-qc-current-blockers.md`
+- qcloop 运维:`docs/tests/lime-agent-qc-qcloop-operations.md`
+- Evidence 契约:`docs/tests/lime-agent-qc-evidence-contract.md`
+- 机器 manifest:`docs/test/agent-qc-scenarios.manifest.json`
+- Evidence schema:`docs/test/agent-qc-evidence.schema.json`
diff --git a/docs/tests/lime-agent-autonomous-testing-plan.md b/docs/tests/lime-agent-autonomous-testing-plan.md
new file mode 100644
index 000000000..221d8b4bc
--- /dev/null
+++ b/docs/tests/lime-agent-autonomous-testing-plan.md
@@ -0,0 +1,328 @@
+# Lime Agent 自主化测试体系示例计划
+
+> 本文件把通用 Agent QC 标准落到 Lime 这个实际产品上:目标不是让人多点几遍 GUI,而是让测试 Agent 能读改动、选场景、执行验证、收集证据、独立复核,并把失败回写为长期回归资产。
+
+## 1. 问题判断
+
+Lime 即将运营,测试策略必须从“开发者人工确认”升级为“Agent 自己证明自己可运营”。人工测试仍有价值,但只能用于设计场景、审核 waiver 和校准语义评测,不应作为发布通过的主要证据。
+
+Agent 类产品的核心风险不是单个函数失败,而是多个不确定系统叠加后出现假阳性:
+
+| 风险面 | 典型假阳性 | 必须自动采集的证据 |
+| --- | --- | --- |
+| GUI 桌面壳 | 页面能打开,但 DevBridge 未就绪 | health、workspace ready、截图或 trace、console/network 摘要 |
+| Agent Runtime | 模型有回复,但 turn / thread 状态错 | session、thread、turn transcript、stream event、持久化结果 |
+| Tool Surface | 工具显示可用,但未经过授权或未写回结果 | tool request、approval decision、sandbox policy、tool result/error |
+| Streaming / Interrupt | UI 显示停止,后端继续写 completed | first token、interrupt command、post-stop transcript、resume turn |
+| Skill / Service Skill | 已注册被误认为已可执行 | draft、verify、register、binding readiness、session enable、allowlist |
+| Browser / Site Adapter | adapter catalog 可读,但 session 泄漏 | attach/status、adapter result、cleanup、network/console |
+| Knowledge / Files | 能读取文件,但引用和来源不可信 | source id、chunk、引用位置、空结果策略 |
+| Release Ops | 源码可跑,但发布包不可启动 | version check、artifact scope、startup smoke、release evidence |
+
+结论:Lime 的测试体系要围绕“证据”建,而不是围绕“命令跑过”建。每个场景都必须回答:谁执行、执行了什么、看到了什么、为什么可以判定通过、失败后下次怎么提前发现。
+
+## 2. 目标状态
+
+目标流水线:
+
+```text
+Diff / Release Candidate / Nightly
+ -> Test Planner Agent 识别风险与场景
+ -> qcloop 拆分 P0/P1 item
+ -> Executor Agent 执行 npm / Rust / GUI / harness / Playwright MCP
+ -> Evidence Collector 写入 Evidence Pack
+ -> Verifier Agent 按 manifest 独立判定
+ -> Regression Curator 把失败沉淀为长期回归
+ -> Release Gate 只读证据并阻断发布
+```
+
+角色分工:
+
+| Agent | 职责 | 产物 |
+| --- | --- | --- |
+| Test Planner Agent | 读取 diff、manifest、风险矩阵,选择最小但足够的场景 | qcloop payload、测试计划摘要 |
+| Executor Agent | 执行单个场景,不顺手修复无关问题 | 命令日志、GUI trace、runtime transcript |
+| Verifier Agent | 独立审查 evidenceRequired / failureModes | JSON verdict |
+| Regression Curator Agent | 把失败归类并生成长期回归资产 | 新增 smoke / replay / unit test / qcloop scenario |
+| Release Gate Agent | 汇总 Evidence Pack 并生成 release note 质量小节 | `release-agent-qc.md`、CI gate 结果 |
+
+人类只处理三件事:新增关键业务场景、审核高风险 waiver、校准 LLM judge 的 rubric。
+
+## 3. Lime 风险地图
+
+Lime 当前最需要覆盖的不是“所有代码行”,而是用户运营路径和 Agent 自主执行路径:
+
+```text
+桌面启动
+ -> DevBridge health
+ -> workspace ready / session restore
+ -> Claw 输入与首屏
+ -> Runtime submit_turn
+ -> streaming / interrupt / resume
+ -> tool request / approval / sandbox / result
+ -> Skill / Browser / Knowledge 等能力面
+ -> evidence pack / harness replay
+ -> release startup smoke
+```
+
+按风险优先级分层:
+
+| 优先级 | 必测路径 | 当前主入口 |
+| --- | --- | --- |
+| P0 | command bridge contract | `npm run test:contracts` |
+| P0 | GUI shell / workspace / DevBridge | `npm run verify:gui-smoke` |
+| P0 | runtime tool / approval / sandbox | `npm run smoke:agent-runtime-tool-surface` + 专项 transcript evidence |
+| P0 | Claw streaming / interrupt / resume | GUI deep flow + runtime transcript |
+| P0 | Skill Forge register / bind / enable | `npm run smoke:agent-service-skill-entry` + runtime binding evidence |
+| P0 | Browser runtime / site adapter / cleanup | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` |
+| P0 | release startup and evidence gate | `npm run verify:app-version` + release summary gate |
+| P1 | Knowledge product path | `npm run smoke:knowledge-gui` + `npm run knowledge:product-e2e` |
+| P1 | Team / long task / harness trend | `npm run harness:eval` + `npm run harness:eval:trend` |
+
+P0 的定义:失败会导致用户无法启动、无法对话、无法安全调用工具、无法恢复状态,或发布包不能可信上线。
+
+## 4. 测试手段组合
+
+单一测试方式无法覆盖 Agent 产品。Lime 默认使用组合策略:
+
+| 手段 | 证明范围 | Lime 用法 |
+| --- | --- | --- |
+| 白盒测试 | 内部状态、协议、持久化是否正确 | Rust / Vitest / command contract / runtime transcript |
+| 黑盒测试 | 用户可见路径是否可用 | GUI smoke、Playwright MCP、发布包启动 |
+| 灰盒测试 | UI 操作与后端事件是否对应 | GUI 截图 + DevBridge health + runtime session read |
+| 快照测试 | UI 结构、工作台布局、报告形状不漂移 | React snapshot / JSON report snapshot / evidence schema |
+| 冒烟测试 | 快速确认主路径没断 | `verify:gui-smoke`、`smoke:*` |
+| Replay 测试 | 线上失败或复杂任务可复现 | harness replay、runtime transcript replay |
+| LLM Judge | 开放式答案质量与语义回归 | rubric + golden samples + human calibration |
+| Metamorphic 测试 | 同义输入、顺序变化后核心约束仍成立 | 改写 prompt 后仍需调用同类 skill / tool |
+| 故障注入 | 断网、工具拒绝、超时、权限拒绝时能恢复 | approval deny、tool timeout、DevBridge unavailable |
+| Differential 测试 | mock / real backend、不同模型或不同 runtime 输出对比 | mock smoke 不能替代 release real path |
+| 长程稳定性 | 资源泄漏、session 泄漏、趋势退化 | nightly qcloop、harness trend、browser cleanup |
+
+组合原则:
+
+- GUI P0 不能只有截图,必须带 DevBridge / console / network / runtime 证据。
+- Runtime P0 不能只有命令退出码,必须带 transcript / tool timeline / approval / sandbox 证据。
+- Release P0 不能只有源码 smoke,必须标明是 source-tree startup 还是 installer artifact。
+- 语义场景不能只靠 LLM judge,必须有确定性 guardrail:结构、引用、禁止行为、工具调用边界。
+
+## 5. 分阶段建设计划
+
+### Phase 0:统一事实源
+
+目标:让测试标准可以被机器读取。
+
+已落地入口:
+
+```bash
+npm run agent-qc:report
+npm run agent-qc:gui-flow:report
+npm run agent-qc:check
+```
+
+完成标准:
+
+- `docs/test/agent-qc-scenarios.manifest.json` 固定场景、命令、证据和失败模式。
+- `docs/test/agent-qc-gui-flows.manifest.json` 固定 GUI / Playwright MCP flow。
+- `docs/test/agent-qc-evidence.schema.json` 固定 Evidence Pack 形状。
+- `npm run test:contracts` 包含 `agent-qc:check`,防止测试标准自身漂移。
+
+### Phase 1:让 qcloop 接管场景执行
+
+目标:每个 P0 场景都成为 qcloop item,由独立 verifier 判定,而不是执行者自评。
+
+生成 P0 payload:
+
+```bash
+npm run agent-qc:qcloop-job -- \
+ --risk P0 \
+ --cwd "$(pwd)" \
+ --base-url "http://127.0.0.1:8080" \
+ --output "./.lime/qc/qcloop-p0-job.json" \
+ --check
+```
+
+导出 Evidence Pack:
+
+```bash
+npm run agent-qc:export-evidence -- \
+ --base-url "http://127.0.0.1:8080" \
+ --job-id "" \
+ --output "./.lime/qc/agent-qc-evidence.json" \
+ --ref "" \
+ --check
+```
+
+执行约束:
+
+- 本地 API 使用 `http://127.0.0.1:8080`,避免 `localhost` 代理或 IPv6 干扰。
+- qcloop 进程 cwd 可能不是 Lime,payload 必须显式传 `--cwd`。
+- partial / sidecar evidence 只能用于排障,不能冒充正式 release pass。
+- 运行中批次只做只读监控;使用 `npm run agent-qc:qcloop-status -- --job-id ""` 判断 running / pending / exhausted / stale,不主动中断 worker。
+- qcloop worker 执行前先跑 `npm run agent-qc:qcloop-preflight -- --check`;GUI / DevBridge 场景必须追加 `--require-devbridge`,否则不能把本地 loopback 权限缺失误判为产品通过或产品失败。
+
+### Phase 2:先打穿 P0,再扩 P1/P2
+
+目标:P0 全绿前,不把精力分散到大量低风险场景。
+
+P0 最小闭环:
+
+1. `command-bridge-contract`:防止前端、Rust、mock、治理目录漂移。
+2. `workspace-ready-session-restore`:证明首屏和历史会话可恢复。
+3. `claw-chat-ready-streaming`:证明聊天、流式、中断、恢复不是假通过。
+4. `tool-approval-sandbox-boundary`:证明工具调用不绕过授权和 sandbox。
+5. `skill-forge-register-bind-enable`:证明注册不等于自动执行,显式 enable 才进入 allowlist。
+6. `browser-runtime-site-adapter`:证明 browser session、adapter 和 cleanup 可用。
+7. `harness-eval-regression`:证明核心行为样本没有退化。
+8. `release-package-startup-smoke`:证明版本、启动和 release gate 可复核。
+
+只有 P0 通过后,再把 Knowledge、Team、长任务、任务调度、跨模型比较扩到 P1/P2。
+
+### Phase 3:补齐 Runtime 级深证据
+
+目标:让 runtime 测试从“命令通过”升级到“行为可信”。
+
+每个 runtime 场景必须记录:
+
+- `sessionId`、`threadId`、`turnId`。
+- `submit_turn`、`stream event`、`interrupt`、`thread_read` 的顺序。
+- tool request / approval decision / sandbox policy / result or error。
+- 失败后的恢复动作:retry、fallback、用户可见错误或安全停止。
+- mock 与 real path 的边界说明。
+
+建议新增或强化专项 evidence:
+
+| 缺口 | 建议资产 |
+| --- | --- |
+| approval / sandbox 证据不足 | `npm run smoke:agent-runtime-approval-sandbox` 生成确定性前端/投影 evidence;仍需补真实 runtime harness replay 和 tool timeline JSON |
+| streaming stop 后状态不一致 | Rust persistence test + GUI deep flow regression |
+| SkillTool allowlist 只测注册不测启用 | Rust gate test + frontend metadata builder test |
+| tool timeout 后恢复不清晰 | failure injection smoke + transcript assertion |
+
+### Phase 4:补齐 GUI / TUI / WebUI 产品证据
+
+目标:任何用户可见主路径都能由 Agent 自动操作、截图、断言和归档。
+
+GUI / WebUI flow 最小证据:
+
+- 页面或窗口可达。
+- 主交互前后截图或 trace。
+- 可访问性节点或稳定选择器断言。
+- console error / warning 摘要。
+- network failure 摘要。
+- 与后端状态对应的 runtime / DevBridge 证据。
+
+TUI / CLI flow 最小证据:
+
+- 命令输入、退出码、stdout/stderr 摘要。
+- PTY 场景要记录关键屏幕帧或结构化 transcript。
+- 长任务要记录取消、超时、恢复和清理。
+
+Lime 是桌面 GUI 产品,所以 GUI 主路径仍以 `verify:gui-smoke` 和 Playwright MCP 为主;如果未来提供 TUI 或 WebUI,也应复用同一套 Evidence Pack schema,而不是另起一套口头流程。
+
+### Phase 5:把失败变成长期资产
+
+目标:每个真实 P0 失败都要留下下次可自动发现的资产。
+
+失败归档规则:
+
+| 失败类型 | 回写资产 |
+| --- | --- |
+| 合同漂移 | `test:contracts` / governance catalog test |
+| GUI ready 假阳性 | `verify:gui-smoke` / GUI flow manifest / Playwright MCP trace |
+| runtime 状态错 | Rust 定向测试 / transcript replay |
+| approval bypass | approval policy unit test / harness replay |
+| sandbox 漏洞 | sandbox boundary test / denylist snapshot |
+| streaming 卡死 | interrupt regression / persistence assertion |
+| 语义退化 | harness eval sample / LLM judge rubric |
+| 发布失败 | release workflow gate / startup smoke |
+
+复盘问题固定为:这次失败下次是否能由机器先发现?如果不能,修复没有完成。
+
+### Phase 6:发布与运营门禁
+
+目标:发布只接受 evidence,不接受“我刚手动测过”。
+
+发布前 gate:
+
+```bash
+npm run agent-qc:release-summary -- \
+ --evidence "./.lime/qc/agent-qc-evidence.json" \
+ --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \
+ --require-risk P0 \
+ --tag "" \
+ --output "./.lime/qc/release-agent-qc.md" \
+ --check
+```
+
+完成审计:
+
+```bash
+npm run agent-qc:audit -- --format json
+```
+
+发布可继续的条件:
+
+- 官方 `.lime/qc/agent-qc-evidence.json` 存在。
+- `verdict.status` 是 `pass`。
+- 覆盖 manifest 中全部 P0 scenario id。
+- release summary `--check` 通过。
+- waiver 有 owner、原因、过期时间、复测计划。
+
+## 6. 当前 Lime 样本状态
+
+截至 2026-05-11 01:13,本地测试体系已经具备标准、manifest、schema、qcloop exporter、release summary 和 audit,但不能宣称 P0 完成。
+
+当前关键事实:
+
+- `npm run agent-qc:check` 已能校验 scenario manifest 与 GUI flow manifest。
+- `.github/workflows/release.yml` 已要求 Evidence Pack 和 P0 覆盖。
+- `.lime/qc/agent-qc-evidence.json` 当前仍是 `fail`,不能发布。
+- qcloop P0 v3 覆盖 8/8 P0,但只有部分通过。
+- qcloop v4 已 completed / fail,6/6 exhausted;它证明旧 verifier / worker 环境不足以给出 P0 绿色证据。
+- qcloop v5 已 completed / fail;6 个重跑项 0 pass,`claw-chat-ready-streaming`、`skill-forge-register-bind-enable`、`browser-runtime-site-adapter`、`workspace-ready-session-restore` 与 `release-package-startup-smoke` 均未产生可发布证据,其中多项 worker 明确输出 `QCLOOP_WORKER_RESULT=BLOCKED`,说明 qcloop worker 内 DevBridge health 被环境 / 权限阻断。
+- `tool-approval-sandbox-boundary` 已补 `smoke:agent-runtime-approval-sandbox` 确定性 smoke,但当前 qcloop item 已 failed;worker stdout 自报 PASS 与 qcloop exit / verifier 结论冲突,不能替代官方 qcloop live transcript evidence。
+- v5 completed 后宿主 shell 直连 `127.0.0.1:3030/health` 也失败;本轮已通过 headless Tauri 恢复宿主 DevBridge,并写入 `.lime/qc/qcloop-devbridge-health-restored.json`。
+- 恢复宿主 DevBridge 后创建的只读 qcloop worker preflight job `1778403715309891000` 仍为 `blocked`:worker cwd/tmp 通过,但 `devbridge-health` 对 `127.0.0.1:3030` 仍 `fetch failed`。当前阻断已收敛为 qcloop 内层 worker loopback / sandbox 权限,不能直接重跑完整 P0。
+- 通过隔离 qcloop server `127.0.0.1:18080`、独立 DB 和显式 Codex sandbox 配置,worker preflight job `1778404260108641000` 已通过,说明 qcloop worker 权限问题可以被环境修复。
+- 在隔离 qcloop 下,`workspace-ready-session-restore`、`browser-runtime-site-adapter`、`skill-forge-register-bind-enable`、`release-package-startup-smoke` 四个 P0 sidecar 已通过;其中 release startup v2 明确只覆盖 `source-tree-startup-smoke`,不等同于 installer artifact 验证。
+- 已启动 isolated full P0 v1 `1778405842243079000`:当前 4/8 success,`browser-runtime-site-adapter` running 且无 stdout/stderr,`--stale-minutes 1` 只读检查已标记 stale,后续 3 个 P0 pending;这仍是 sidecar running/stale 状态,不能覆盖官方 `.lime/qc/agent-qc-evidence.json`。
+- 宿主 shell 直接执行 `agent-qc:qcloop-preflight -- --require-devbridge`、`smoke:browser-runtime`、`smoke:site-adapters` 已通过;browser runtime cleanup 有非阻断 warning,因此当前 full P0 卡点更像 qcloop worker / provider 无输出,而不是宿主 DevBridge 或产品命令不可用。
+- 宿主 shell 直接执行 `harness:eval` 与 `harness:eval:trend` 已通过;trend 当前只有 1 个样本,只能作为 seed,仍需 qcloop verifier 和后续 nightly 样本确认长期趋势。
+- 宿主 shell 直接执行 `smoke:workspace-ready`、`verify:app-version`、`verify:gui-smoke -- --reuse-running` 已通过;这证明 source-tree workspace / release smoke 可跑,但不替代 qcloop verifier 或 installer artifact 验证。
+- 2026-05-10 19:50 只读刷新显示,仍有多个 qcloop 隔离批次未终态:`isolated-p0-full-v1` 4/8 success 且 `browser-runtime-site-adapter` stale;`nomcp-p0-v2` 已出现 `tool-approval-sandbox-boundary` failed;`mcpdisabled-p0-v1` 在 `claw-chat-ready-streaming` stale;`fast-p0-v1` 已通过 command bridge 后进入 `claw-chat-ready-streaming`;`fastmini-readonly-p0-v1` 已 terminal failed,8 个 item 全部 exhausted,sidecar evidence 为 6 fail / 2 blocked。主要原因是 verifier 正确拒绝缺少 deep evidence 的 worker 摘要,同时暴露了 `workspace-ready-session-restore` 中 `smoke:design-canvas` 保存成功状态断言失败,以及 `release-package-startup-smoke` GUI smoke 未自然收口。
+- 这批失败把 P0 拆得更清楚:`verify:gui-smoke`、`smoke:agent-runtime-*`、`smoke:browser-runtime` 这类确定性 smoke 可以证明“主路径可执行”,但不能自动证明 live long-turn interrupt、approval/sandbox transcript、console/network trace、SkillTool gate 和 cleanup 证据都齐全。自主测试体系必须把二者分成不同证据层,而不是降低 P0 verifier。
+- `smoke:design-canvas` 的细化证据已落到 `.lime/qc/design-canvas-failure-fastmini-workspace-extract.json`:页面已进入 `CANVAS:DESIGN 专属 GUI SMOKE`,DevBridge 为 `ok`,但 `project-roundtrip-save-open` 阶段没有出现 `已保存图层设计工程`,说明“页面可见”还不能证明“工程保存链路可用”。
+- 当前仍有多个 GUI / DevBridge qcloop sidecar running 或 stale,后续 P0 必须先做到 GUI single-owner。否则多个 worker 复用同一个 `127.0.0.1:1420` / `127.0.0.1:3030` 会话,会让页面导航、按钮点击和状态断言互相干扰。manifest 已把 `GUI session owner / isolation statement` 与 `parallel GUI smoke interference` 纳入 GUI P0 证据。
+- 2026-05-11 01:02 只读刷新显示,`isolated-p0-full-v1` 仍为 `stale`:4 success / 1 running / 3 pending / 1 stale,`browser-runtime-site-adapter` stale 约 `26202s`,DB lease 仍被 `qcloop-worker-1` 延长到 `2026-05-11T01:17:06+08:00`,active attempt stdout/stderr 仍为空;这证明当前卡点仍是 qcloop worker no-output owner,而不是可发布的 P0 完成。
+- 最新完整 `verify:local` 已刷新为 `status=fail`,失败阶段是 `verify:gui-smoke / smoke:claw-chat-ready-streaming`。后续 direct Claw post-refresh fallback 已 pass,但另一轮 session-restore direct smoke 在 DevBridge 中途不可达时 fail;两者都只能作为 Claw deep flow 的 sidecar 证据,不能替代完整 `verify:local` pass。
+- 2026-05-11 01:13 观察到外部 `verify:gui-smoke -- --reuse-running --timeout-ms 240000` 自然通过,且 Claw streaming / interrupt / resume 在 latest summary 中为 `verdict=pass`、`recoveryVisibleSource=live-stream`。这证明 GUI smoke 主链已经恢复到可通过状态,但 `.lime/qc/gui-process-owner-current.json` 仍显示 raw process owner `busy`,且完整 `verify:local` 尚未重跑通过。
+- 2026-05-11 01:32 已把 raw process owner 检查脚本化为 `npm run agent-qc:process-owner-check`,把 qcloop SQLite lease 取证脚本化为 `npm run agent-qc:qcloop-db-lease`,并新增 `docs/tests/lime-agent-autonomous-test-execution-matrix.md` 作为 Lime 样本的执行矩阵。最新 sidecar 仍显示 `qcloop stale` 与 `raw process owner busy`,因此本轮仍不能启动完整 `verify:local` 或新的 full GUI P0。
+
+这正是自主测试体系的价值:它没有把“本机某次手动看起来可用”误判为发布通过,而是把缺证据和真实产品 blocker 留在门禁中。
+
+## 7. 近期执行顺序
+
+在不打断本机其他进程的前提下,下一步按以下顺序推进:
+
+1. 保留 qcloop v5 completed / fail sidecar、宿主 DevBridge 恢复 sidecar、默认 worker preflight blocked sidecar、isolated worker pass sidecar、4 个 isolated P0 pass sidecar,以及 release startup v1 blocked 历史证据,不覆盖官方 evidence。
+2. 继续只读观察所有 running / stale qcloop 批次;若当前 worker 自然结束,再导出 sidecar 并根据终态决定是否复用待用 payload。没有 owner 明确授权前,不 kill、不 pause、不重启这些进程。
+3. 把 P0 分成两层验收:确定性 smoke 层用于证明主路径可执行,deep evidence 层用于证明 live transcript / trace / console-network / cleanup / approval-sandbox 证据齐全;官方 release gate 仍必须看 deep evidence pass。
+4. 先等当前 running/stale GUI qcloop 批次自然结束,或在 owner 明确授权下收口;下一轮只允许一个 GUI owner 跑 full P0,worker 必须输出 GUI session owner / isolation statement。
+5. 在单一 GUI owner 前提下,定位 `workspace-ready-session-restore` 中 `smoke:design-canvas` 的保存成功状态断言失败,因为它已经是 qcloop worker 内可复现的 GUI P0 signal,不应被旧 isolated pass 覆盖;下一步重点是确认导出按钮点击后是否有保存中 / 保存成功状态,还是导出流程在首个可观察状态前卡住。
+6. 把 `tool-approval-sandbox-boundary` 拆成更窄的 runtime transcript 或 harness replay 场景,让 approval / sandbox 证据以结构化 JSON 进入 verifier,而不是只引用组件级 smoke。
+7. 在 `.lime/qc/gui-process-owner-current.json` 不再显示长时间 raw GUI smoke / Cargo owner 后,重新跑完整 `npm run verify:local`;只有该统一门禁通过,`local-verify-gate` 才能关闭。
+8. 环境阻断解除后,定位 `claw-chat-ready-streaming` 的 stop / interrupt 后持久化、会话恢复和 DevBridge 中途不可达问题,补 Rust 或 harness replay 回归。
+9. 只有 8/8 P0 全部 pass 后,才把结果导出为官方 `.lime/qc/agent-qc-evidence.json`,再运行 `npm run agent-qc:release-summary -- --check` 和 `npm run agent-qc:audit -- --format json`。
+
+## 8. 与其他文档的关系
+
+- 通用运营标准:`docs/tests/agent-ops-qc.md`。
+- P0 场景执行手册:`docs/tests/agent-qc-p0-scenarios.md`。
+- Lime 落地路线:`docs/tests/lime-agent-qc-rollout-plan.md`。
+- Lime 执行矩阵:`docs/tests/lime-agent-autonomous-test-execution-matrix.md`。
+- 当前 P0 阻断:`docs/tests/lime-agent-qc-current-blockers.md`。
+- 机器可读场景:`docs/test/agent-qc-scenarios.manifest.json`。
+- 机器可读证据 schema:`docs/test/agent-qc-evidence.schema.json`。
+
+本文件负责回答“Lime 作为实际 Agent 产品,应该如何一步步从现有丰富测试升级为自主化运营测试体系”。
diff --git a/docs/tests/lime-agent-qc-completion-audit-2026-05-10.md b/docs/tests/lime-agent-qc-completion-audit-2026-05-10.md
new file mode 100644
index 000000000..02b761173
--- /dev/null
+++ b/docs/tests/lime-agent-qc-completion-audit-2026-05-10.md
@@ -0,0 +1,832 @@
+# Lime Agent QC 整体目标完成度审计(2026-05-10)
+
+> 审计目的:把“实现整体目标”拆成可验证交付物,逐项映射到仓库文件、命令输出和 qcloop 真实状态。只要官方 P0 Evidence Pack 未通过,本目标不得标记完成。
+
+最近一次只读刷新:2026-05-11 01:32。刷新只更新 `.lime/qc/*` sidecar 和 docs/tests 审计文档,不提交、不推送、不中断任何正在运行的 qcloop worker。当前权威机器审计见 `.lime/qc/agent-qc-audit-current.json`。
+
+## 1. 目标拆解
+
+本轮整体目标不是单纯新增文档,而是为 Lime 这个即将运营的 Agent 产品建立可由 Agent 自主执行的测试体系。成功标准如下:
+
+1. `docs/tests/` 下有人读测试标准、P0 手册、Lime 示例计划和当前 blocker 记录。
+2. `docs/test/` 下有机器可读 scenario manifest、GUI flow manifest 和 Evidence Pack schema。
+3. 仓库脚本能校验标准、生成 qcloop job、只读监控 qcloop 状态、导出 Evidence Pack、生成 release summary、执行 completion audit。
+4. qcloop 能执行真实 P0 场景并导出官方 `.lime/qc/agent-qc-evidence.json`。
+5. 发布门禁只接受官方 Evidence Pack `pass`,且必须覆盖全部 P0 scenario id。
+6. GUI / Runtime / approval / sandbox / release 等关键风险有对应测试手段,不依赖人工口头确认。
+7. 当前未通过的 P0 blocker 有明确证据、原因和关闭条件。
+8. 在用户明确要求 Lime 不推送时,不执行 commit / push。
+
+## 2. Prompt-to-artifact checklist
+
+| 要求 | 具体证据 | 当前状态 | 结论 |
+| --- | --- | --- | --- |
+| `docs/tests` 下新增测试文档 | `docs/tests/lime-agent-autonomous-testing-plan.md`、`docs/tests/lime-agent-autonomous-test-execution-matrix.md` | 已存在,覆盖风险地图、Agent 分工、组合测试、Phase 0-6、owner gate、P0 执行矩阵和失败回写 | PASS |
+| `docs/tests` 入口更新 | `docs/tests/README.md` | 已加入 Lime 自主测试计划与执行矩阵 | PASS |
+| 运营级测试标准 | `docs/tests/agent-ops-qc.md` | 已定义 lane、Evidence Pack、qcloop、release gate | PASS |
+| P0 场景手册 | `docs/tests/agent-qc-p0-scenarios.md` | 已存在 | PASS |
+| Lime 落地计划 | `docs/tests/lime-agent-qc-rollout-plan.md` | 已存在 | PASS |
+| 当前 blocker 记录 | `docs/tests/lime-agent-qc-current-blockers.md` | 已记录 v3 / v4 / v5、官方 evidence fail、scenario blocker 和关闭条件 | PASS |
+| Scenario manifest | `docs/test/agent-qc-scenarios.manifest.json` | `npm run agent-qc:check` 显示 12 个场景、8 个 P0、0 issue;现在会校验 qcloop verifier prompt 必须带 stdout / attempt 状态占位符;`command-bridge-contract` 已允许 no-change surface evidence 用 checked surface counts + contract pass 满足;8 个 P0 均声明合法 `evidenceLayers`,缺失或未知层级会被 `agent-qc:check` 阻断;GUI P0 已显式要求 `GUI session owner / isolation statement`;workspace/release P0 已显式要求 design canvas 工程 roundtrip 和 GUI smoke 自然收口证据 | PASS |
+| qcloop stale / cleanup / worker env / read-only repair 机器标准 | `docs/test/agent-qc-scenarios.manifest.json` | `qcloop-batch-verifier-repair` 已把 stale sidecar、stdout/stderr 长度、no-output lease 心跳、worker CLI 环境 sidecar、内层 Codex MCP 启动策略、read-only worker policy 与 max_qc_rounds policy 列入证据 / failure mode;`browser-runtime-site-adapter` 已把 cleanup warning 分类纳入证据 | PASS |
+| GUI flow manifest | `docs/test/agent-qc-gui-flows.manifest.json` | `npm run agent-qc:check` 显示 4 个 P0 GUI flow、0 issue | PASS |
+| Evidence schema | `docs/test/agent-qc-evidence.schema.json` | `agent-qc:audit` 识别为存在 | PASS |
+| qcloop payload 生成 | `scripts/agent-qc-qcloop-job.mjs` / `scripts/lib/agent-qc-qcloop-job-core.mjs` / `scripts/lib/agent-qc-report-core.mjs` | `agent-qc:audit` 识别 `itemCount=8`;本轮已修正 verifier prompt 自动包含 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}`,并让 `agent-qc:check` 与 `agent-qc:qcloop-job --check` 机械阻断缺占位符的模板;qcloop item 现在会带 `evidence_layers` | PASS |
+| 证据深度分层 payload | `.lime/qc/qcloop-p0-evidence-layers-v1-payload.json` | 已生成但未提交运行;8 个 P0 item 均带 `evidence_layers`,`max_qc_rounds=1`,GUI P0 item 均带 GUI session owner / isolation evidence,workspace/release P0 item 均带 design canvas roundtrip / GUI smoke 收口 evidence 与 failure mode,用于后续 worker 明确区分 `deterministic-smoke` / `gui-trace` / `runtime-transcript` / `release-artifact` | PASS sidecar |
+| design canvas failure extract | `.lime/qc/design-canvas-failure-fastmini-workspace-extract.json` | 从 fast-mini readonly P0 的 workspace item attempt 中只读提取;DevBridge `ok`、`smoke:workspace-ready` PASS、页面进入 `CANVAS:DESIGN`,但 `project-roundtrip-save-open` 等待 `已保存图层设计工程` 超时 | FAIL evidence sidecar |
+| qcloop verifier evidence 占位符 | `scripts/lib/agent-qc-completion-audit-core.mjs` / `scripts/agent-qc-completion-audit.mjs` | `npm run agent-qc:audit -- --format json` 显示 `qcloop-verifier-evidence-placeholders` 通过,证据为 `stdout=true attempt_status=true exit_code=true` | PASS |
+| qcloop evidence 导出 | `scripts/agent-qc-export-evidence.mjs` / `scripts/lib/agent-qc-evidence-core.mjs` | 已导出 `.lime/qc/agent-qc-evidence.p0-rerun-v5-current.json` sidecar;worker stdout 明确 `QCLOOP_WORKER_RESULT=BLOCKED` 或 stderr 显示内层 CLI / 认证 / sandbox 配置阻断时归类为 `blocked`,避免把环境阻断误写成产品 fail | PASS |
+| qcloop 只读状态监控 | `scripts/agent-qc-qcloop-status.mjs` / `scripts/lib/agent-qc-qcloop-status-core.mjs` / `docs/tests/lime-agent-qc-qcloop-operations.md` | 已支持 running / pending / exhausted / stale 分类,并可把运行中批次状态导出到 `.lime/qc/qcloop-status.*.json`;不会中断 worker;显式 blocked marker 会保留环境阻断语义;stale item 现在输出 `staleSeconds` 与 worker `durationSeconds` | PASS |
+| qcloop DB lease 只读取证 | `scripts/agent-qc-qcloop-db-lease.mjs` / `package.json` | `npm run agent-qc:qcloop-db-lease` 会只读导出 qcloop SQLite DB 中的 active item、lease、attempt stdout/stderr 长度和相关进程快照,用于判断 no-output stale worker 是否仍被续约 | PASS |
+| GUI owner 并发检查 | `scripts/agent-qc-gui-owner-check.mjs` / `scripts/lib/agent-qc-gui-owner-core.mjs` / `package.json` | `npm run agent-qc:gui-owner-check -- --check` 会只读扫描 `.lime/qc/qcloop-status.*.json`,active GUI owner 超过上限时非 0 退出;用于启动新 GUI P0 前阻断并发 GUI smoke 干扰 | PASS |
+| raw process owner 检查 | `scripts/agent-qc-process-owner-check.mjs` / `package.json` | `npm run agent-qc:process-owner-check` 会只读扫描 raw GUI smoke、qcloop、Cargo / Rust owner,并生成 `.lime/qc/gui-process-owner-current.json` / `.md`;用于决定是否等待完整 `verify:local` | PASS |
+| qcloop worker preflight | `scripts/agent-qc-qcloop-preflight.mjs` / `scripts/lib/agent-qc-qcloop-preflight-core.mjs` / `docs/test/agent-qc-scenarios.manifest.json` | 已支持 cwd / tmp / DevBridge 前置检查,并把 preflight 写入 qcloop worker prompt;GUI / browser runtime 场景可先阻断环境问题 | PASS |
+| release summary | `scripts/agent-qc-release-summary.mjs` | `agent-qc:audit` 识别为存在 | PASS |
+| release hard gate | `.github/workflows/release.yml` | `agent-qc:audit` 显示强制 Evidence Pack pass 且覆盖 P0 | PASS |
+| nightly artifact | `.github/workflows/harness-nightly.yml` | `agent-qc:audit` 显示上传 `artifacts/agent-qc/*` | PASS |
+| GUI 真实证据 | `.lime/qc/gui-evidence` | `agent-qc:audit` 显示存在;本地 GUI smoke 曾通过 | PASS |
+| approval / sandbox smoke | `scripts/agent-runtime-approval-sandbox-smoke.mjs`、`package.json`、manifest commands | direct-host `npm run smoke:agent-runtime-approval-sandbox -- --timeout-ms 120000 --output .lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/runtime-approval-sandbox-smoke.fixed.json` 通过,denied / resolved 两条 flow 均生成 `runtime_permission_confirmation:*` | PASS sidecar,仍非官方 full P0 pack |
+| qcloop worker Vitest 沙箱写入问题 | `scripts/lib/vitest-smoke-runner.mjs`、`scripts/agent-runtime-tool-surface-smoke.mjs` | smoke 改为 `/tmp` Vitest config/cache,`npm run smoke:agent-runtime-tool-surface` 通过 | PASS |
+| 官方 P0 Evidence Pack | `.lime/qc/agent-qc-evidence.json` | `agent-qc:audit` 显示 `status=fail scenarios=8/8` | FAIL |
+| qcloop v4 重跑 | job `1778392677659787000` | 已 `completed` / `fail`,6/6 exhausted;只作为排障 sidecar,不是官方发布证据 | FAIL |
+| qcloop v5 重跑 | job `1778398587521627000` | 已 `completed` / `fail`;1 failed、5 exhausted-or-blocked、0 success;不覆盖官方 Evidence Pack | FAIL |
+| 宿主 DevBridge 恢复 | `.lime/qc/qcloop-devbridge-health-restored.json` | `npm run bridge:health` 与宿主 `agent-qc:qcloop-preflight -- --require-devbridge` 均通过;说明 headless Tauri / DevBridge 已恢复 | PASS |
+| qcloop worker DevBridge preflight | job `1778403715309891000`、`.lime/qc/qcloop-status.worker-devbridge-preflight.json` | 只读 preflight job 显示内层 worker cwd/tmp 通过,但 `devbridge-health` 仍 `fetch failed`,sidecar 归类为 `blocked` | BLOCKED |
+| isolated qcloop worker preflight | job `1778404260108641000`、`.lime/qc/qcloop-status.isolated-worker-devbridge-preflight-v2.json` | 隔离 qcloop server `127.0.0.1:18080` 使用独立 DB 和显式 Codex sandbox 配置后,worker DevBridge preflight 通过 | PASS sidecar |
+| isolated P0 sidecar pass | jobs `1778404364137496000` / `1778404601640847000` / `1778404743505029000` | `workspace-ready-session-restore`、`browser-runtime-site-adapter`、`skill-forge-register-bind-enable` 在 isolated qcloop 下通过;仍非官方全量 P0 Evidence Pack | PASS sidecar |
+| isolated release startup sidecar v1 | job `1778404882904047000` | 执行时宿主 DevBridge 再次断开,preflight blocked,未执行版本或 GUI smoke;保留为历史环境阻断证据 | BLOCKED sidecar |
+| isolated release startup sidecar v2 | job `1778405385701480000`、`.lime/qc/qcloop-status.isolated-release-startup-v2.json` | worker preflight、`verify:app-version`、`verify:gui-smoke -- --reuse-running` 均通过;stdout 明确 `ARTIFACT_SCOPE=source-tree-startup-smoke` | PASS sidecar |
+| isolated full P0 sidecar v1 | job `1778405842243079000`、`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json`、`.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json` | 批次仍 running;`command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`skill-forge-register-bind-enable` 已 success;`browser-runtime-site-adapter` running 且 stale 约 `28020s`;DB lease 仍为 `lock_owner=qcloop-worker-1`、`lock_expires_at=2026-05-11T01:47:06+08:00`;active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` stdout/stderr 仍为空;后续 3 个 P0 pending | STALE sidecar |
+| isolated no-MCP full P0 v1 | job `1778410893606889000`、`.lime/qc/qcloop-status.isolated-nomcp-p0-v1-current.json` | 因 `QCLOOP_CODEX_BIN` 指向坏 Homebrew symlink,8 个 item 立即 failed;现按 worker CLI 环境阻断归类,不作为产品失败 | BLOCKED sidecar |
+| isolated no-MCP full P0 v2 | job `1778410956075020000`、`.lime/qc/qcloop-status.isolated-nomcp-p0-v2-current.json` | 使用正确 Codex bin 与 `QCLOOP_CODEX_EXTRA_ARGS='--ephemeral -c mcp_servers={}'`;实测仍可能存在用户级 MCP 子进程;当前 2 success、1 failed、1 running、4 pending,`tool-approval-sandbox-boundary` 已 failed,`skill-forge-register-bind-enable` running | RUNNING with failed item sidecar |
+| isolated MCP-disabled full P0 v1 | job `1778412160003934000`、`.lime/qc/qcloop-status.isolated-mcpdisabled-p0-v1-current.json` | 覆盖已知 MCP command 为空,初始 `ps` 未观察到用户级 MCP 子进程;当前 1 failed、1 running、6 pending,`claw-chat-ready-streaming` stale | STALE with failed item sidecar |
+| isolated fast full P0 v1 | job `1778412499745993000`、`.lime/qc/qcloop-status.isolated-fast-p0-v1-current.json` | 在 MCP command 覆盖基础上追加 `--ignore-rules`;当前 1 success、1 running、6 pending,`claw-chat-ready-streaming` running | RUNNING sidecar |
+| isolated fast-mini readonly full P0 v1 | job `1778412738097137000`、`.lime/qc/qcloop-status.fastmini-readonly-p0-v1-current.json`、`.lime/qc/agent-qc-evidence.fastmini-readonly-p0-v1-current.json` | 使用 `gpt-5.4-mini`、low reasoning、只读 prompt、`max_qc_rounds=1` 与 MCP command 覆盖;当前 job 已 terminal `failed`,8 exhausted;sidecar evidence 导出为 6 fail / 2 blocked,并暴露 `workspace-ready-session-restore` 在 `smoke:design-canvas` 保存成功状态断言失败、`release-package-startup-smoke` GUI smoke 未自然收口 | FAIL sidecar |
+| stale gate exit check | `agent-qc:qcloop-status -- --check-terminal --fail-on-stale` | 当前 full P0 job 返回 `QCLOOP_STATUS_CHECK_EXIT_STATUS=2`,摘要显示 `duration=67m stdout=0 stderr=0`;证明 stale gate 会非 0 阻断 | PASS |
+| direct browser runtime / site adapter host check | `agent-qc:qcloop-preflight -- --require-devbridge` + `smoke:browser-runtime` + `smoke:site-adapters` | 宿主 shell 直接执行退出码 0;DevBridge pass,browser runtime smoke 与 site adapter smoke 通过;browser cleanup 输出非阻断 `close_cdp_session` warning | PASS with warning |
+| direct harness replay host check | `npm run harness:eval` + `npm run harness:eval:trend` | 宿主 shell 直接执行退出码 0;2/2 ready、invalid=0、current observability gap=0、degraded gap=1;trend 样本数 1,只能作为 seed | PASS with trend caveat |
+| direct workspace / release host check | `smoke:workspace-ready` + `verify:app-version` + `verify:gui-smoke -- --reuse-running` | 宿主 shell 直接执行退出码 0;workspace ready、版本一致性、DevBridge health、GUI smoke 通过;GUI smoke 内含 browser runtime、site adapters、service skill entry、runtime tool surface、knowledge GUI、design canvas | PASS |
+| `verify:gui-smoke` 最新复核 | `.lime/qc/verify-gui-smoke-current.json`、`.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-0108.log` | 外部启动的显式 Sensenova `verify:gui-smoke -- --reuse-running --timeout-ms 240000` 已自然通过;覆盖 Claw streaming / interrupt / resume、knowledge GUI 与 design canvas;仅作为 GUI smoke pass,不关闭完整 `verify:local` | PASS sidecar |
+| raw GUI / Cargo process owner | `.lime/qc/gui-process-owner-current.json` | 当前仍为 `busy`,`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7`;因此本轮不启动新的完整 `verify:local` 或 full GUI P0 | BLOCKED sidecar |
+| `local-verify-gate` | `.lime/qc/verify-local-current.json`、`.lime/qc/verify-local-sensenova-2026-05-11-0023.log` | 最新完整 `npm run verify:local` 失败在 `verify:gui-smoke / smoke:claw-chat-ready-streaming`;后续 `verify:gui-smoke` 已 pass,但它仍不能替代完整 `verify:local` pass | FAIL |
+| GitHub push / release | git remote / GitHub | 用户明确要求 Lime 不推送,本轮未执行 commit / push | PASS |
+
+## 3. 当前命令证据
+
+最近一次审计命令:
+
+```bash
+npm run agent-qc:audit -- --format json
+```
+
+结果摘要:
+
+```text
+status=incomplete
+passed=16/18
+failed=2/18
+缺口=real-qcloop-evidence, local-verify-gate
+官方 evidence=.lime/qc/agent-qc-evidence.json status=fail scenarios=8/8
+```
+
+最新只读刷新:
+
+```bash
+npm run agent-qc:qcloop-status -- \
+ --base-url "http://127.0.0.1:18080" \
+ --job-id "1778405842243079000" \
+ --format json \
+ --output "./.lime/qc/qcloop-status.isolated-p0-full-v1-current.json"
+
+npm run agent-qc:gui-owner-check -- \
+ --format json \
+ --output "./.lime/qc/gui-owner-current.json" \
+ --watch-history-output "./.lime/qc/stale-owner-watch-history.jsonl"
+
+npm run agent-qc:process-owner-check -- \
+ --format json \
+ --output "./.lime/qc/gui-process-owner-current.json" \
+ --markdown-output "./.lime/qc/gui-process-owner-current.md"
+
+npm run agent-qc:qcloop-db-lease -- \
+ --db "./.lime/qc/qcloop-isolated-worker-preflight.db" \
+ --job-id "1778405842243079000" \
+ --format json \
+ --output "./.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json" \
+ --markdown-output "./.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md"
+```
+
+结果摘要:
+
+```text
+qcloop status=stale
+counts=4 success / 1 running / 3 pending / 1 stale
+active=browser-runtime-site-adapter
+staleSeconds≈28020
+GUI owner=blocked, ownerCount=1, staleOwnerCount=1
+raw process owner=busy, activeGuiSmoke=2, cargoOrRust=4, qcloopRelated=7
+DB lease sidecar=.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json
+```
+
+最新 GUI smoke sidecar:
+
+```bash
+LIME_AGENT_QC_PROVIDER="custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed" \
+LIME_AGENT_QC_MODEL="sensenova-6.7-flash-lite" \
+npm run verify:gui-smoke -- --reuse-running --timeout-ms 240000
+```
+
+结果摘要:
+
+```text
+status=pass
+evidence=.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-0108.log
+claw-chat-ready-streaming verdict=pass
+recoveryVisibleSource=live-stream
+interruptedTurnStatus=aborted
+followTurnStatus=completed
+```
+
+最新 fast-mini readonly P0 sidecar:
+
+```bash
+npm run agent-qc:export-evidence -- \
+ --base-url "http://127.0.0.1:18085" \
+ --job-id "1778412738097137000" \
+ --output "./.lime/qc/agent-qc-evidence.fastmini-readonly-p0-v1-current.json" \
+ --ref "local-fastmini-readonly-p0-v1-current"
+```
+
+结果摘要:
+
+```text
+job=failed
+status=fail
+scenarioCount=8
+fail=6
+blocked=2
+workspace-ready-session-restore: smoke:workspace-ready 通过,但 verify:gui-smoke 失败在 smoke:design-canvas 保存成功状态断言
+release-package-startup-smoke: verify:app-version 通过,但 verify:gui-smoke 未自然收口,blocked
+```
+
+design canvas 失败细化:
+
+```text
+sidecar=.lime/qc/design-canvas-failure-fastmini-workspace-extract.json
+item=workspace-ready-session-restore
+stage=project-roundtrip-save-open
+missingText=已保存图层设计工程
+pageState=CANVAS:DESIGN 专属 GUI SMOKE / AI 图层化设计画布
+classification=ui ready false positive / design canvas export no save status
+```
+
+最新 evidence-layer payload 预生成:
+
+```bash
+npm run agent-qc:qcloop-job -- \
+ --risk P0 \
+ --cwd "$(pwd)" \
+ --name "lime-agent-qc-p0-evidence-layers-v1-2026-05-10" \
+ --max-qc-rounds 1 \
+ --output "./.lime/qc/qcloop-p0-evidence-layers-v1-payload.json" \
+ --check
+```
+
+结果摘要:
+
+```text
+valid=true
+itemCount=8
+max_qc_rounds=1
+8 个 P0 item 均带 evidence_layers
+GUI P0 item 均带 GUI session owner / isolation statement
+workspace-ready-session-restore 带 design canvas project roundtrip save/open evidence
+release-package-startup-smoke 带 GUI smoke natural exit and design canvas roundtrip result
+```
+
+GUI owner 并发检查:
+
+```bash
+npm run agent-qc:gui-owner-check -- --check
+```
+
+当前预期:该命令会非 0 退出,因为仍有 active GUI owner sidecar;这不是脚本失败,而是阻止新 GUI P0 批次抢会话的正确行为。
+
+当前 release summary gate 复核:
+
+```bash
+npm run agent-qc:release-summary -- \
+ --evidence "./.lime/qc/agent-qc-evidence.json" \
+ --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \
+ --require-risk P0 \
+ --tag "local-agent-qc-audit" \
+ --output "./.lime/qc/release-agent-qc.current-audit.md" \
+ --check
+```
+
+结果摘要:
+
+```text
+RELEASE_SUMMARY_EXIT_STATUS=1
+Agent QC release summary 状态为 fail,不能作为绿色发布证据。
+```
+
+最近一次标准校验:
+
+```bash
+npm run agent-qc:check
+```
+
+结果摘要:
+
+```text
+scenarioCount=12
+p0ScenarioCount=8
+guiFlowCount=4
+issueCount=0
+```
+
+新增 approval / sandbox smoke:
+
+```bash
+npm run smoke:agent-runtime-approval-sandbox -- --no-write
+```
+
+结果摘要:
+
+```text
+4 组 Vitest smoke 通过:
+- submit preferences approval/sandbox policy
+- runtime permission / tool lifecycle projection
+- permission confirmation UI recovery
+- Harness tool permission / pending approval display
+```
+
+新增 approval / sandbox live runtime sidecar:
+
+```bash
+npm run smoke:agent-runtime-approval-sandbox -- \
+ --timeout-ms 120000 \
+ --output ".lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/runtime-approval-sandbox-smoke.fixed.json"
+```
+
+结果摘要:
+
+```text
+status=pass
+provider=deepseek
+model=deepseek-v4-flash
+providerSource=auto-enabled-provider
+liveAssertions.devBridgeHealthy=true
+liveAssertions.permissionRequestCreatedBeforeModel=true
+liveAssertions.deniedDecisionClearsPendingRequest=true
+liveAssertions.resolvedDecisionClearsPendingRequest=true
+liveAssertions.approvalPolicySubmitted=true
+liveAssertions.sandboxPolicySubmitted=true
+evidence=.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json
+scope=direct-host-backend-sidecar
+```
+
+新增 qcloop 只读状态监控:
+
+```bash
+npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:8080" --job-id "1778398587521627000"
+```
+
+结果摘要:
+
+```text
+verdict=fail
+items=6 terminal=6 nonTerminal=0
+success=0 failed=1 exhausted=5 running=0 pending=0 stale=0
+claw-chat-ready-streaming evidenceStatus=blocked, worker stdout 包含 QCLOOP_WORKER_RESULT=BLOCKED
+tool-approval-sandbox-boundary qcloopStatus=failed,但 worker stdout 自报 PASS 与 verifier feedback 冲突,不能作为 P0 pass
+```
+
+宿主 DevBridge 恢复检查:
+
+```bash
+npm run bridge:health -- --timeout-ms 120000 --interval-ms 2000
+npm run agent-qc:qcloop-preflight -- --expected-cwd "$(pwd)" --require-devbridge --timeout-ms 30000 --check
+```
+
+结果摘要:
+
+```text
+bridge:health status=ok
+QCLOOP_PREFLIGHT_RESULT=PASS
+devbridge-health PASS: status=ok
+sidecar=.lime/qc/qcloop-devbridge-health-restored.json
+```
+
+qcloop 内层 worker DevBridge preflight:
+
+```bash
+job=1778403715309891000
+npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:8080" --job-id "$job" --format json --output "./.lime/qc/qcloop-status.worker-devbridge-preflight.json"
+```
+
+结果摘要:
+
+```text
+job=completed
+item=exhausted
+evidenceStatus=blocked
+worker stdout: QCLOOP_PREFLIGHT_RESULT=BLOCKED, devbridge-health BLOCKED: fetch failed
+worker stdout: QCLOOP_WORKER_RESULT=BLOCKED
+```
+
+隔离 qcloop worker preflight 与 P0 sidecar:
+
+```bash
+# isolated qcloop server: 127.0.0.1:18080
+# db: .lime/qc/qcloop-isolated-worker-preflight.db
+npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778404260108641000" --format json --output "./.lime/qc/qcloop-status.isolated-worker-devbridge-preflight-v2.json"
+npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778404364137496000" --format json --output "./.lime/qc/qcloop-status.isolated-workspace-ready.json"
+npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778404601640847000" --format json --output "./.lime/qc/qcloop-status.isolated-browser-runtime-site-adapter.json"
+npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778404743505029000" --format json --output "./.lime/qc/qcloop-status.isolated-skill-forge.json"
+npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778405385701480000" --format json --output "./.lime/qc/qcloop-status.isolated-release-startup-v2.json"
+npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778405842243079000" --format json --output "./.lime/qc/qcloop-status.isolated-p0-full-v1-current.json"
+npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778405842243079000" --stale-minutes 1 --format json --output "./.lime/qc/qcloop-status.isolated-p0-full-v1-stale-check.json"
+```
+
+结果摘要:
+
+```text
+isolated-worker-devbridge-preflight-v2=complete
+isolated-workspace-ready=complete
+isolated-browser-runtime-site-adapter=complete
+isolated-skill-forge=complete
+isolated-release-startup-v1=blocked,因为宿主 127.0.0.1:3030 再次断开
+isolated-release-startup-v2=complete,source-tree startup smoke 通过,未声明 installer artifact 验证
+isolated-p0-full-v1=stale sidecar,4/8 success,browser-runtime-site-adapter running 且无 stdout/stderr,3 pending
+```
+
+Runtime tool surface smoke:
+
+```bash
+npm run smoke:agent-runtime-tool-surface
+```
+
+结果摘要:
+
+```text
+runtime tool surface:6 tests pass
+runtime inventory:49 tests pass
+```
+
+宿主 browser runtime / site adapter 直接检查:
+
+```bash
+npm run agent-qc:qcloop-preflight -- --expected-cwd "$(pwd)" --require-devbridge --timeout-ms 30000 --check
+npm run smoke:browser-runtime
+npm run smoke:site-adapters
+```
+
+结果摘要:
+
+```text
+QCLOOP_PREFLIGHT_RESULT=PASS
+smoke:browser-runtime exit=0;session 创建与 attach/status 通过;cleanup warning: close_cdp_session 未找到 session
+smoke:site-adapters exit=0;catalog/list/recommend/search 通过,adapters=11
+```
+
+该直接检查只能证明宿主产品命令可跑;不能替代 qcloop full P0 job 的 verifier 证据。
+
+宿主 harness replay 直接检查:
+
+```bash
+npm run harness:eval
+npm run harness:eval:trend
+```
+
+结果摘要:
+
+```text
+harness:eval exit=0;2 cases ready / invalid=0 / current observability gap=0 / degraded observability gap=1
+harness:eval:trend exit=0;samples=1;只能形成 trend seed,不能判断长期退化
+```
+
+该直接检查只能证明 harness 命令可跑;不能替代 qcloop full P0 job 的 verifier 证据。
+
+宿主 workspace / release source-tree 直接检查:
+
+```bash
+npm run smoke:workspace-ready
+npm run verify:app-version
+npm run verify:gui-smoke -- --reuse-running
+```
+
+结果摘要:
+
+```text
+smoke:workspace-ready exit=0;DevBridge ready,workspaceCount=104,repaired=false,relocated=false
+verify:app-version exit=0;版本一致性通过: 1.32.0
+verify:gui-smoke exit=0;workspace ready、browser runtime、site adapters、service skill entry、runtime tool surface、knowledge GUI、design canvas 均通过
+```
+
+该直接检查只能证明宿主 source-tree 路径可跑;不能替代 installer artifact 验证,也不能替代 qcloop full P0 verifier。
+
+## 4. 当前 qcloop 状态
+
+v4 job:`1778392677659787000`(completed / fail)
+
+v5 job:`1778398587521627000`(completed / fail)
+
+worker preflight job:`1778403715309891000`(completed / blocked sidecar)。它是在宿主 DevBridge 已恢复后创建的只读 job,只验证 qcloop 内层 worker 是否能访问 Lime cwd、tmp 和 `127.0.0.1:3030/health`。结果为 cwd/tmp 通过、DevBridge health `fetch failed`,因此后续 P0 重跑前必须先在安全窗口修复 qcloop serve 的 Codex sandbox / loopback 权限;不能直接把宿主 health pass 当作 qcloop P0 可运行。
+
+isolated qcloop sidecar jobs:
+
+| Job | 场景 | 状态 | 结论 |
+| --- | --- | --- | --- |
+| `1778404260108641000` | worker DevBridge preflight v2 | `completed` / `success` | 正确 sandbox 配置下 worker 可访问 DevBridge |
+| `1778404364137496000` | `workspace-ready-session-restore` | `completed` / `success` | `smoke:workspace-ready` 与 `verify:gui-smoke -- --reuse-running` 通过 |
+| `1778404601640847000` | `browser-runtime-site-adapter` | `completed` / `success` | `smoke:browser-runtime` 与 `smoke:site-adapters` 通过 |
+| `1778404743505029000` | `skill-forge-register-bind-enable` | `completed` / `success` | `test:contracts` 与 `smoke:agent-service-skill-entry` 通过 |
+| `1778404882904047000` | `release-package-startup-smoke` v1 | `failed` / `blocked` | 宿主 DevBridge 再次断开,preflight blocked,未执行版本 / GUI smoke |
+| `1778405385701480000` | `release-package-startup-smoke` v2 | `completed` / `success` | worker preflight、版本一致性与 `verify:gui-smoke -- --reuse-running` 通过;scope 为 `source-tree-startup-smoke` |
+| `1778405842243079000` | full P0 v1 | `running` / stale sidecar | 4/8 success;`browser-runtime-site-adapter` running 无 stdout/stderr,`codex exec` 进程仍在;`workspace-ready-session-restore`、`harness-replay-regression`、`release-package-startup-smoke` pending;最新 `staleSeconds=8002` |
+| `1778410893606889000` | no-MCP full P0 v1 | `failed` / blocked sidecar | `QCLOOP_CODEX_BIN` 指向坏 symlink,属于 worker CLI 环境阻断,不是产品失败 |
+| `1778410956075020000` | no-MCP full P0 v2 | `running` / sidecar | 正确 Codex bin + `mcp_servers={}` 尝试;当前 2 success、1 failed、1 running、4 pending;`tool-approval-sandbox-boundary` failed,`skill-forge-register-bind-enable` running |
+| `1778412160003934000` | MCP-disabled full P0 v1 | `running` / stale sidecar | 正确 Codex bin + 已知 MCP command 置空;初始 `ps` 未观察到用户级 MCP 子进程;`command-bridge-contract` failed;`claw-chat-ready-streaming` running 且 stale 约 674 秒,后续 6 个 P0 pending |
+| `1778412499745993000` | fast full P0 v1 | `running` / sidecar | 正确 Codex bin + MCP command 置空 + `--ignore-rules`;`command-bridge-contract` success;`claw-chat-ready-streaming` running,后续 6 个 P0 pending |
+| `1778412738097137000` | fast-mini readonly full P0 v1 | `failed` / fail sidecar | gpt-5.4-mini + low reasoning + MCP command 置空 + `--ignore-rules` + 只读 prompt + `max_qc_rounds=1`;8 exhausted;sidecar evidence 为 6 fail / 2 blocked |
+
+| Scenario | 状态 | 证据结论 |
+| --- | --- | --- |
+| `claw-chat-ready-streaming` | default `exhausted` / evidence `blocked`;isolated full sidecar `success` with shallow scope | default worker 明确输出 `QCLOOP_WORKER_RESULT=BLOCKED`;isolated full v1 已通过 GUI smoke / DevBridge / runtime surface,但 stdout 明确缺 live long-turn interrupt transcript;同日 deep flow 仍有 product backend blocker |
+| `tool-approval-sandbox-boundary` | default `failed` / evidence `fail`;isolated full sidecar `success` with deterministic scope | default worker stdout 自报 `QCLOOP_WORKER_RESULT=PASS` 但 qcloop exit `-1` / verifier 仍判缺 live runtime transcript;isolated full v1 已通过 tool surface + approval sandbox smoke,但不是 live long-turn transcript |
+| `skill-forge-register-bind-enable` | default `exhausted` / evidence `blocked`;isolated sidecar `success` | default worker 多轮均停在 DevBridge preflight blocked;isolated single场景与 full v1 均已通过 contracts + service skill entry smoke |
+| `browser-runtime-site-adapter` | `exhausted` / evidence `blocked` | worker 明确输出 `QCLOOP_WORKER_RESULT=BLOCKED`,多轮均停在 DevBridge preflight blocked,未进入 browser session / adapter catalog / cleanup 主链 |
+| `workspace-ready-session-restore` | v5 `exhausted` / evidence `blocked`;fast-mini readonly `exhausted` / evidence `fail` | fast-mini worker 明确 `smoke:workspace-ready` 通过,但 `verify:gui-smoke` 失败在 `smoke:design-canvas` 保存成功状态断言,命中 `ui ready false positive` 风险;不能用旧 isolated pass 覆盖当前 release 证据 |
+| `release-package-startup-smoke` | default qcloop `exhausted` / evidence `blocked`;isolated source-tree sidecar `success` | v5 多轮 worker 未提供版本、GUI smoke、首屏 ready、Bridge health、waiver 和失败模式排除证据,最终 exhausted;隔离 v2 已证明 source-tree startup smoke 通过,但不是同一全量官方 P0 Evidence Pack |
+
+sidecar evidence:`.lime/qc/agent-qc-evidence.p0-rerun-v5-completed.json`(从 completed v5 导出;`p0-rerun-v5-current.json` 已同步为同一状态)
+
+```text
+status=fail
+scenarioCount=6
+1 failed / 5 exhausted-or-blocked / 0 running / 0 pending / 0 pass
+```
+
+该 sidecar 只用于排障,不能覆盖官方 `.lime/qc/agent-qc-evidence.json`。v4 completed sidecar `.lime/qc/agent-qc-evidence.p0-rerun-v4-completed.json` 仍保留为 fail 排障证据。
+
+已生成后续单场景重跑 payload:`.lime/qc/qcloop-tool-approval-sandbox-rerun-payload.json`,包含 `smoke:agent-runtime-tool-surface`、新增 `smoke:agent-runtime-approval-sandbox`,以及带 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}` 的 verifier prompt。`agent-qc:qcloop-job --check` 现在也会校验这些占位符。该单场景 payload 尚未提交;v5 已使用新的 6 场景 payload 先跑完整剩余 P0。
+
+同时生成了全量 P0 重跑 payload:`.lime/qc/qcloop-p0-rerun-with-verifier-evidence-payload.json`,覆盖 8 个 P0 场景,verifier prompt 同样带 stdout / exit code 占位符。
+
+本轮追加准备、提交并启动了当前 6 个未通过 / 未完成 P0 的 v5 重跑 payload:`.lime/qc/qcloop-p0-rerun-v5-verifier-evidence-ready-payload.json`,新 job 为 `1778398587521627000`。payload 已通过 `agent-qc:qcloop-job --check`,确认 worker prompt 含 preflight,verifier prompt 含 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}`。首个场景已证明 qcloop worker 基础 preflight PASS,但 DevBridge preflight BLOCKED。只读环境侧证据已写入 `.lime/qc/qcloop-executor-env-20260510.json`:qcloop serve 进程当前未设置 `QCLOOP_CODEX_SANDBOX=off` / bypass / approval policy。
+
+v5 completed 后补充了宿主 DevBridge health 证据:`.lime/qc/qcloop-devbridge-health-after-v5.json` 显示宿主 shell 直连 `http://127.0.0.1:3030/health` 失败,且 3030 无监听。随后本轮通过 `npm run tauri:dev:headless` 恢复了宿主 DevBridge,恢复证据写入 `.lime/qc/qcloop-devbridge-health-restored.json`;但默认 qcloop worker preflight job `1778403715309891000` 仍证明默认 qcloop serve 的内层 worker 无法访问 DevBridge。
+
+隔离 qcloop server `127.0.0.1:18080` 使用显式 sandbox 配置后,worker preflight 与 4 个 P0 sidecar 已通过,说明 worker 权限问题可以被环境修复。`release-package-startup-smoke` v2 已在 source-tree scope 下通过,但当前仍不能直接写官方 Evidence Pack,因为还缺同一批次 8/8 P0 success,且 installer artifact 未验证。
+
+## 5. 完成判定
+
+当前整体目标 **未完成**。
+
+原因:官方 P0 qcloop Evidence Pack 仍为 `fail`。v5 重跑已 completed / fail,isolated full P0 v1 仍 stale,no-MCP full P0 v2 已出现 failed item,MCP-disabled / fast full P0 批次仍 running 或 stale,fast-mini readonly full P0 已 terminal failed,尚未形成 8/8 verifier success。fast-mini readonly P0 sidecar 还暴露了 `workspace-ready-session-restore` 的 GUI smoke 子项失败。即使文档、manifest、GUI flow、release gate、approval/sandbox smoke 都已经落地,也不能把这些 proxy signal 或 partial sidecar 当作真实 P0 pass。
+
+关闭条件保持不变:
+
+1. qcloop P0 批次覆盖全部 8 个 P0 scenario id。
+2. `.lime/qc/agent-qc-evidence.json` 的 `verdict.status` 为 `pass`。
+3. release summary 以 `--require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --check` 通过。
+4. `npm run agent-qc:audit -- --format json` 返回 `complete`。
+
+## 6. 下一刀
+
+1. 保留宿主 DevBridge 恢复、默认 worker blocked、isolated worker pass、4 个 P0 isolated pass 与 release v1 blocked / v2 pass sidecar,不覆盖官方 evidence。
+2. 继续只读观察 isolated full P0 v1、no-MCP v2、MCP-disabled v1、fast v1;fast-mini readonly v1 已 terminal failed,只保留 sidecar evidence,不覆盖官方 evidence。
+3. 优先确认 GUI P0 是否存在并发干扰:当前仍有多个 running/stale GUI qcloop sidecar,新的 payload 已要求 GUI worker 输出 session owner / isolation statement。只有确认单一 GUI owner 后,`smoke:design-canvas` 失败才可直接归为产品回归。
+4. 在单一 GUI owner 前提下,定位 `workspace-ready-session-restore` 中 `smoke:design-canvas` 保存成功状态断言失败;这是 qcloop worker 内已经捕获的 GUI P0 signal,不能被旧 isolated pass 覆盖。当前已确认缺的是 `已保存图层设计工程` 可观察状态,下一步应判断导出按钮点击后是状态未渲染、导出流程卡住,还是断言文本与产品文案漂移。
+5. 当前 running/stale 批次自然结束后,优先使用 `.lime/qc/qcloop-p0-evidence-layers-v1-payload.json` 作为下一轮只读 P0 payload;该 payload 明确 `evidence_layers` 且 `max_qc_rounds=1`,避免 worker 把 deterministic smoke 伪装成 deep evidence。
+6. 把 `tool-approval-sandbox-boundary` 拆成更窄的 runtime transcript / evidence JSON 场景,避免确定性 smoke 与 live runtime 证据混在同一个 verifier 口径里。
+7. 环境阻断解除后,再修复 `claw-chat-ready-streaming` 的 stop / interrupt 后端语义,并重跑 GUI deep flow 与 qcloop 场景。
+
+
+## 8. 2026-05-10 20:28 增量审计
+
+当前机器审计命令:
+
+```bash
+node scripts/agent-qc-completion-audit.mjs --format json > ".lime/qc/agent-qc-audit-current.json"
+```
+
+结果摘要:
+
+```text
+status=incomplete
+passed=16/17
+failed=1/17
+唯一缺口=real-qcloop-evidence
+```
+
+新增或收紧的 checklist 项:
+
+| 检查项 | 证据 | 当前状态 |
+| --- | --- | --- |
+| `structured-evidence-contract` | `docs/tests/lime-agent-qc-evidence-contract.md`、qcloop worker prompt、verifier prompt、`agent-qc:export-evidence`、`agent-qc:release-summary` | PASS:`doc=true worker=true verifier=true strictJson=true exporter=true release=true` |
+| `stale-owner-intervention-protocol` | `docs/tests/lime-agent-qc-stale-owner-intervention.md`、`docs/tests/lime-agent-qc-qcloop-operations.md` | PASS:已定义只读取证、owner 确认格式和 post-clear rerun runbook |
+| release structured evidence gate | `scripts/lib/agent-qc-release-summary-core.mjs` / `scripts/lib/agent-qc-release-summary-core.test.ts` | PASS:pass 场景若只有 `qcloop:*` evidenceRefs 会被 release summary 阻断 |
+| Evidence exporter structured summary gate | `scripts/lib/agent-qc-evidence-core.mjs` / `scripts/lib/agent-qc-evidence-core.test.ts` | PASS:qcloop `success` item 缺 `QCLOOP_EVIDENCE_SUMMARY_JSON` 或 JSON 无法解析时导出为 `fail` |
+| GUI owner stale detail | `scripts/lib/agent-qc-gui-owner-core.mjs` / `.lime/qc/gui-owner-current.json` | PASS:报告 `staleOwnerCount=1`、`oldestStaleSeconds=9543`,nextAction 要求只读观察或 owner 确认 |
+| post-stale rerun plan | `.lime/qc/post-stale-owner-rerun-plan.md`、`.lime/qc/qcloop-p0-structured-evidence-v1-payload.json` | PASS sidecar:payload `valid=true`、`itemCount=8`、`maxQcRounds=1`,未提交 qcloop job |
+
+当前唯一 blocker 仍是官方 P0 Evidence Pack:
+
+```text
+.lime/qc/agent-qc-evidence.json status=fail
+qcloop-status.isolated-p0-full-v1-current.json verdict=stale success=4 running=1 pending=3 stale=1
+qcloop-status.isolated-p0-full-v1-stale-check.json verdict=stale success=4 running=1 pending=3 stale=1
+```
+
+只读进程核查显示 stale owner 背后仍有真实内层 worker:
+
+```text
+PID=69738
+process=codex exec
+scenario=browser-runtime-site-adapter
+workerDurationSeconds≈9543
+stdoutLength=0
+stderrLength=0
+```
+
+因此当前仍不得:
+
+- 覆盖 `.lime/qc/agent-qc-evidence.json`。
+- 启动新的 full GUI P0 批次。
+- kill / pause / interrupt PID `69738` 或 qcloop serve。
+- 调用 `update_goal(status="complete")`。
+
+关闭条件保持不变:owner 明确处理或自然释放 stale worker 后,`agent-qc:gui-owner-check -- --check` 必须通过;随后用 `.lime/qc/qcloop-p0-structured-evidence-v1-payload.json` 或重新生成的等价 payload 跑出 8/8 P0 structured evidence pass,再执行 `agent-qc:export-evidence`、`agent-qc:release-summary --check` 和 `agent-qc:audit`。
+
+### 2026-05-10 20:32 payload retry control update
+
+`agent-qc:qcloop-job` 已支持 `--max-executor-retries`,`docs/test/agent-qc-scenarios.manifest.json` 设置 `recommendedMaxExecutorRetries=0`。当前 post-stale P0 structured payload 重新生成后:
+
+```text
+valid=true
+itemCount=8
+max_qc_rounds=1
+max_executor_retries=0
+```
+
+该设置用于发布证据批次:不自动重试内层 CLI,避免当前 stale owner 问题未解决时重复制造孤儿 worker。若 owner 明确需要基础设施重试,必须在隔离环境中显式改为 1。
+
+### 2026-05-10 20:40 只读状态复核
+
+本轮遵守用户要求:不 push、不 commit、不终止或重启其他正在运行的进程。只读刷新结果如下:
+
+```text
+agent-qc:audit status=incomplete
+passed=16/17
+failed=1/17
+唯一缺口=real-qcloop-evidence
+```
+
+`1778405842243079000` 仍是当前唯一 active GUI qcloop owner:
+
+```text
+job status=running
+verdict=stale
+counts=8 total / 4 success / 1 running / 3 pending / 1 stale
+active scenario=browser-runtime-site-adapter
+worker durationSeconds=10508
+worker stdoutLength=0
+worker stderrLength=0
+observed PID=69738
+process=codex exec
+```
+
+`agent-qc:gui-owner-check` 当前输出:
+
+```text
+ownerCount=1
+staleOwnerCount=1
+oldestStaleSeconds=10508
+verdict=blocked
+```
+
+release summary gate 也按预期保持阻断:
+
+```text
+RELEASE_SUMMARY_EXIT_STATUS=1
+summary=Agent QC release summary 状态为 fail,不能作为绿色发布证据
+structured evidenceRefs missing=command-bridge-contract, harness-replay-regression
+```
+
+因此当前可做动作仍然只有:
+
+- 继续只读观察并刷新 sidecar。
+- 更新 `docs/tests` 运行手册、审计文档和 stale owner request。
+- 等 owner 明确确认或 stale worker 自然释放后,再执行 post-stale P0 structured evidence runbook。
+
+当前仍不得:
+
+- 覆盖 `.lime/qc/agent-qc-evidence.json`。
+- 启动新的 full GUI P0 批次。
+- kill / pause / interrupt PID `69738` 或 qcloop serve。
+- 把任何 isolated partial pass 写成 release pass。
+- 调用 `update_goal(status="complete")`。
+
+### 2026-05-10 20:44 只读续刷
+
+状态未自然释放。`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 更新后仍显示 `verdict=stale`、`4 success / 1 running / 3 pending / 1 stale`,`browser-runtime-site-adapter staleSeconds=10772`,worker stdout/stderr 仍为 `0 / 0`。`.lime/qc/gui-owner-current.json` 更新后仍显示 `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds=10772`、`verdict=blocked`。`ps` 仍可见 PID `69738` 的内层 `codex exec`,本轮未执行 kill / pause / interrupt。
+
+### 2026-05-10 20:48 DB / lease 取证
+
+只读新增 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1.md`。SQLite 显示 active item `1778405842246191000` 仍为 `running`,`lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-10T21:02:05+08:00`;active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` 从 `2026-05-10T17:45:05+08:00` 开始后仍未完成,stdout/stderr 长度为 `0 / 0`。进程树显示 PID `69738` 的 PPID 已变为 `1`,仍在 qcloop serve 的 PGID `69307` 中,并带有 Playwright MCP / Context7 MCP 子进程。该证据支持当前卡点是 qcloop worker / inner MCP startup no-output hang,而不是可直接放行的产品 P0 pass。
+
+### 2026-05-10 20:52 qcloop runtime binary provenance
+
+只读新增 `.lime/qc/qcloop-runtime-binary-provenance-18080.md`。`lsof` 显示当前 18080 qcloop serve PID `69307` 运行的是 `/Users/coso/Documents/dev/ai/limecloud/qcloop/qcloop`,SHA-256 为 `177bf7fa79212d065c5cb6cc5cdbb153d3079cec3252a157993f70ab35dc5fe1`;已构建的 `.lime/qc/bin/qcloop-timeout-fixed` SHA-256 为 `2895e33d068a7109607d3e45b6e3bebe1807b6f71d387a3b62eb669a3d4314c7`。两者大小、mtime 和 checksum 均不同,说明 timeout / process-group cleanup fix 尚未作用于当前 running serve 或 PID `69738`。
+
+### 2026-05-10 20:54 owner decision packet
+
+只读新增 `.lime/qc/stale-owner-intervention-request.json`,并在 `docs/tests/lime-agent-qc-stale-owner-intervention.md` 中登记为机器可读决策包。该 JSON 汇总 job、active item、DB lease、PID、binary provenance、证据引用、禁止动作和确认文本;它只用于 owner 决策,不等于授权。本轮仍没有执行 kill / pause / interrupt / restart。
+
+### 2026-05-10 21:02 lease 过期窗口后复核
+
+只读新增 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-after-expiry.md`。等待超过先前记录的 `lock_expires_at=2026-05-10T21:02:05+08:00` 后,qcloop status 仍为 `stale`,`browser-runtime-site-adapter staleSeconds=11845`,stdout/stderr 仍为 `0 / 0`;SQLite 显示 active item 仍为 `running`,但 `lock_expires_at` 已延长到 `2026-05-10T21:17:05+08:00`;PID `69738` 仍存活且 PPID 为 `1`。这证明当前 owner 没有自然释放,qcloop 仍在维持 lease heartbeat。
+
+### 2026-05-10 21:05 GUI owner decision output
+
+`agent-qc:gui-owner-check -- --format json` 已在 stale owner 场景输出 `ownerIntervention` 字段,包含 `requiredConfirmationText`、`prohibitedUntilConfirmed`、`evidenceRefs` 和下一步动作。定向测试 `npx vitest run scripts/lib/agent-qc-gui-owner-core.test.ts` 通过 `6/6`;当前 `.lime/qc/gui-owner-current.json` 显示 `ownerIntervention.status=requires_owner_confirmation`。该字段只用于机器可读提醒,不等于 owner 已授权。
+
+2026-05-10 21:08 追加审计收紧:`agent-qc:audit` 的 `stale-owner-intervention-protocol` 现在同时检查 `ownerIntervention` 机器可读输出和文档中的 decision packet 说明。定向测试 `npx vitest run scripts/lib/agent-qc-completion-audit-core.test.ts scripts/lib/agent-qc-gui-owner-core.test.ts` 通过 `20/20`;当前审计项证据为 `doc=true operations=true ownerIntervention=true docDecisionPacket=true`。
+
+### 2026-05-10 21:10 stale owner watch history
+
+只读新增 `.lime/qc/stale-owner-watch-history.jsonl`,以 JSONL 记录 stale owner 的观察序列:20:44 GUI owner blocked、20:48 DB lease 到 21:02、21:02 lease 延长到 21:17。该 sidecar 只用于证明持续 heartbeat / no-output hang,不构成处理授权。
+
+2026-05-10 21:18 续刷:超过第二个 lease 窗口后,qcloop status 仍为 `stale`,`browser-runtime-site-adapter staleSeconds=12753`,stdout/stderr 仍为 `0 / 0`;SQLite 显示 `lock_expires_at=2026-05-10T21:32:05+08:00`,PID `69738` 仍存活。该观察已追加到 `.lime/qc/stale-owner-watch-history.jsonl`,当前 entries=4。
+
+2026-05-10 21:21 追加脚本化 watch history:`agent-qc:gui-owner-check` 支持 `--watch-history-output`,并已用该参数追加 `.lime/qc/stale-owner-watch-history.jsonl`,当前 entries=5。定向测试 `npx vitest run scripts/lib/agent-qc-gui-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts` 通过 `21/21`。
+
+2026-05-10 21:23 追加 audit gate 收紧:`agent-qc:audit` 的 `stale-owner-intervention-protocol` 现在同时检查 watch history 输出能力和 stale owner 文档中的 watch history 说明。定向测试 `npx vitest run scripts/lib/agent-qc-completion-audit-core.test.ts scripts/lib/agent-qc-gui-owner-core.test.ts` 通过 `22/22`;当前证据为 `doc=true operations=true ownerIntervention=true docDecisionPacket=true watchHistory=true docWatchHistory=true`。
+
+2026-05-10 21:32 续刷:超过第三个 lease 窗口后,qcloop status 仍为 `stale`,`browser-runtime-site-adapter staleSeconds=13657`,stdout/stderr 仍为 `0 / 0`;SQLite 显示 `lock_expires_at=2026-05-10T21:47:05+08:00`,PID `69738` 仍存活。该观察通过 `agent-qc:gui-owner-check -- --watch-history-output` 追加到 `.lime/qc/stale-owner-watch-history.jsonl`,当前 entries=6。
+
+2026-05-10 21:34 追加契约门禁复核:`npm run test:contracts` 通过,覆盖 `check:agent-runtime-clients`、command contracts、harness contracts、modality runtime contracts、harness cleanup report contract 与 `agent-qc:check`。这证明本轮 Agent QC 脚本 / audit / GUI owner 变更没有破坏仓库契约门禁,但仍不能替代真实 8/8 P0 qcloop Evidence Pack。
+
+2026-05-10 21:36 追加 `verify:local` 复核:`npm run verify:local` 在 `typecheck` 阶段失败,错误为 `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx(129,51): error TS2345: Argument of type 'number' is not assignable to parameter of type 'Timeout'.` 该文件不属于本轮 Agent QC docs / scripts 修改范围,且当前工作树存在其他活动变更;本轮未尝试修复。sidecar 见 `.lime/qc/verify-local-2026-05-10-2136.md`。这意味着除 `real-qcloop-evidence` 主缺口外,仓库级 `verify:local` 当前也不是绿色。
+
+2026-05-10 21:38 追加只读排查:`git status` 显示 `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx` 是 untracked,相关 `ChannelLogTailPanel.tsx` 已 modified;失败行是 `vi.spyOn(window, "setInterval").mockReturnValue(0);`。由于该文件不属于本轮 Agent QC docs / scripts 修改范围,且当前工作树存在其他活动变更,本轮仍未修改。sidecar 见 `.lime/qc/verify-local-channel-log-tail-investigation.md`。
+
+2026-05-10 21:48 追加版本变更只读观察:npm 输出显示当前包版本为 `lime@1.33.0`。只读检查显示 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json` 均为 `1.33.0`,且 `package-lock.json` / `src-tauri/Cargo.lock` 也处于 modified 状态。`npm run verify:app-version` 通过,版本一致性为 `1.33.0`。本轮未修改版本文件;sidecar 见 `.lime/qc/version-change-observation-2026-05-10-2148.md`。
+
+2026-05-10 21:52 追加 local verify gate:`.lime/qc/verify-local-current.json` 记录 `npm run verify:local` 当前为 `fail`,失败阶段 `typecheck`。`agent-qc:audit` 现在新增 `local-verify-gate`,要求仓库统一本地校验为 pass;定向测试 `npx vitest run scripts/lib/agent-qc-completion-audit-core.test.ts scripts/lib/agent-qc-gui-owner-core.test.ts` 通过 `23/23`。当前 completion audit 变为 `16/18`,缺口为 `real-qcloop-evidence` 与 `local-verify-gate`。
+
+### 2026-05-10 20:54 补充审计记录:Agent UI manual Playwright evidence
+
+- 新增 evidence:`.lime/qc/gui-evidence/agent-ui-manual-e2e-2026-05-10/summary.json`。
+- 覆盖 UI surface:home、new task workspace、Skills catalog、Skill preflight、Skills search、capability drafts、registered skills、input enabled state、knowledge popover、advanced settings。
+- 结果:manual Agent UI flow 为 `pass_with_findings`;console `0 error / 0 warning`;非静态 network `194/194` HTTP `200`。
+- 发现:本轮没有复现 `Agent 1000` 标签;新增 3 个产品化发现,其中 `skill-preflight-memory-internals-visible` 为 medium,建议后续隐藏或改写内部记忆标签与历史错误片段。
+- 审计影响:该证据加强 `real-gui-evidence`,但不改变 `real-qcloop-evidence` 状态;当前 audit 仍应保持 `incomplete`,唯一主缺口仍为官方 P0 qcloop Evidence Pack 未 pass。
+
+### 2026-05-10 21:10 补充审计记录:Skill reference sanitization fix
+
+- 修复 evidence:`.lime/qc/gui-evidence/skill-preflight-reference-sanitized-2026-05-10/summary.json`。
+- 代码:`src/components/agent/chat/utils/curatedTaskReferenceSelection.ts`。
+- 回归:`src/components/agent/chat/utils/curatedTaskReferenceSelection.test.ts`、`src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx`。
+- 验证:定向 Vitest `18/18` 通过;定向 ESLint 通过;`npm run typecheck` 通过;Playwright 复测 `auto_analysis/-32603/Pexels API Key/fp:/task_id` 均未出现在 Skill 补参卡用户可见文本中。
+- 审计影响:修复了 20:54 manual Playwright 发现的 medium 产品 UI/UX 信息外露;不影响 `agent-qc:audit` 的 `real-qcloop-evidence` 主缺口,整体仍不能标记 complete。
+
+### 2026-05-10 21:17 补充审计记录:Skills search local-hit empty state fix
+
+- 修复 evidence:`.lime/qc/gui-evidence/skills-search-local-hit-2026-05-10/summary.json`。
+- 代码:`src/components/skills/SkillsWorkspacePage.tsx`。
+- 回归:`src/components/skills/SkillsWorkspacePage.test.tsx`。
+- 验证:SkillsWorkspacePage 定向 Vitest `31/31` 通过;定向 ESLint 通过;`npm run typecheck` 通过;Playwright 搜索 `cover` 验证 `cover_generate` 可见,旧全局无结果文案不可见,新文案正确指向右侧可用 Skill。
+- 审计影响:修复了 20:54 manual Playwright 发现的 low 产品 UI/UX 误导;不改变 `agent-qc:audit` 的 `real-qcloop-evidence` 主缺口,整体仍不能标记 complete。
+
+### 2026-05-10 21:28 补充审计记录:Advanced settings Plan label localization fix
+
+- 修复 evidence:`.lime/qc/gui-evidence/advanced-settings-plan-label-cn-2026-05-10/summary.json`。
+- 代码:`src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx`。
+- 回归:`src/components/agent/chat/components/Inputbar/index.test.tsx`、`src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx`、`src/components/agent/chat/components/EmptyState.test.tsx`。
+- 验证:定向 Vitest `146/146` 通过;定向 ESLint 通过;`npm run typecheck` 通过;`npm run bridge:health -- --timeout-ms 30000` 通过;Playwright 展开高级设置后显示 `计划执行`,`aria-label` / `title` 为 `开启计划执行`,页面正文不再出现独立 `Plan`、`Plan 模式` 或 `开启/关闭 Plan` 文案。
+- 审计影响:修复了 20:54 manual Playwright 发现的 low 产品 UI/UX polish;不改变 `agent-qc:audit` 的 `real-qcloop-evidence` 主缺口,整体仍不能标记 complete。
+
+### 2026-05-10 21:35 补充审计记录:Prompt-to-artifact checklist 与 Design Canvas 复核
+
+本次继续执行前,按用户目标“不要只测 Agent UI;看到的后端或产品 UI/UX 问题都要测试并解决”做 prompt-to-artifact 对照:
+
+| 明确要求 / 成功标准 | 现有或新增证据 | 覆盖结论 |
+| --- | --- | --- |
+| 不只覆盖 Agent UI 表层 | `12` 到 `24` Claw cancel / recovery evidence、`runtime-approval-sandbox-smoke.json`、browser runtime / site adapter sidecar、workspace / release sidecar、Design Canvas smoke | PARTIAL PASS:覆盖了后端 runtime、approval/sandbox、browser runtime、workspace、release source-tree 与 Design Canvas,但仍缺同批次 full P0 qcloop pass |
+| 普通用户 Agent UI / Skills 全路径可交互 | `.lime/qc/gui-evidence/agent-ui-manual-e2e-2026-05-10/summary.json` | PASS with findings:已后续修复 3 个 UI/UX 问题 |
+| 看到的 UI/UX 问题要修 | `skill-preflight-reference-sanitized`、`skills-search-local-hit`、`advanced-settings-plan-label-cn` 三个 evidence 目录与对应 Vitest / ESLint / typecheck | PASS:已修复信息外露、搜索空态误导、高级设置内部英文标签 |
+| 后端 cancel / recovery 问题要修 | `docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/24-claw-cancel-sensenova-clean-console-summary.json` 及 runtime/thread evidence | PASS sidecar:long turn 为 `aborted`,follow-up 不被旧输出污染;仍需官方 qcloop 同批次采信 |
+| approval / sandbox 边界要有 transcript 级证据 | `.lime/qc/runtime-approval-sandbox-smoke.json`,`transcriptKind=verified_projection_and_live_runtime_transcript` | PASS sidecar:包含 live runtime permission confirmation transcript;仍非官方 full P0 pack |
+| Design Canvas 保存状态旧失败要复核 | 新增 `.lime/qc/gui-evidence/design-canvas-project-roundtrip-current-2026-05-10/summary.json`;`npm run smoke:design-canvas -- --timeout-ms 180000` 通过 | PASS sidecar:当前 host 下 `project-roundtrip-save-open` 可通过,无需代码修复 |
+| Harness Replay / Eval 后端回归要复核 | 新增 `.lime/qc/backend-evidence/harness-replay-regression-current-2026-05-10/summary.json`;`npm run harness:eval:json` 与 `npm run harness:eval:trend:json` 通过;history record 已生成第一条真实 baseline | PASS with limitation:当前 eval 可运行且 current gap 为 0;trend `sampleCount=1` 仍只是 seed |
+| 不与隔壁 v0.6 / qcloop GUI owner 冲突 | `npm run agent-qc:gui-owner-check -- --check` 仍 blocked;本轮未 kill / pause / interrupt PID `69738`,未启动新 full GUI P0 | PASS:遵守不抢 owner |
+| 官方发布前 Evidence Pack 必须真实 pass | `node scripts/agent-qc-completion-audit.mjs --format json` 仍 `incomplete`,`real-qcloop-evidence` failed;`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 仍 stale | FAIL:整体不能标记 complete |
+
+新增 Design Canvas 复测详情:
+
+- 命令:`npm run smoke:design-canvas -- --timeout-ms 180000`。
+- 结果:exit `0`;日志显示 `project-roundtrip-save-open`、`upload-flat-image-extraction`、`extraction-quality-export-manifest` 均完成。
+- Evidence:`.lime/qc/gui-evidence/design-canvas-project-roundtrip-current-2026-05-10/summary.json`。
+- 审计影响:旧 fast-mini sidecar 的 `smoke:design-canvas` 失败不再是当前 host 可复现的产品阻塞;但这仍是 sidecar / direct host evidence,不能覆盖 `real-qcloop-evidence` 主缺口,整体仍不能标记 complete。
+
+### 2026-05-10 21:39 补充审计记录:Harness Replay / Eval 后端回归复测
+
+- 选择原因:full P0 qcloop owner 仍 stale,不能抢占 GUI owner;`harness-replay-regression` 属于非 GUI 后端证据链,可直接复测,不影响隔壁 v0.6 / qcloop。
+- 命令:`npm run harness:eval:json`、`npm run harness:eval:trend:json`。
+- 结果:两条命令 exit `0`;eval summary 显示 `suiteCount=3`、`caseCount=2`、`readyCount=2`、`invalidCount=0`、`needsHumanReviewCount=0`、`currentObservabilityGapCaseCount=0`、`degradedObservabilityGapCaseCount=1`、`currentRecoveredVerificationCaseCount=3`。
+- Trend 限制:`sampleCount=1`,stdout 明确“当前仅形成 trend seed,还不能判断长期退化”。
+- History 处理:只读检查未发现既有 `.lime/harness/history` 或 `reports` harness summary / trend 样本;因此没有复制同一轮样本伪造长期趋势,而是执行 `node scripts/harness-eval-history-record.mjs ...`,把本轮结果记录为第一条真实 baseline,并生成 summary / trend / cleanup / dashboard 报告。
+- Evidence:`.lime/qc/backend-evidence/harness-replay-regression-current-2026-05-10/summary.json`。
+- 审计影响:证明当前 host 上 harness replay / eval 后端链路可运行;但 trend 仍弱,且 full P0 qcloop 未采信,不能覆盖 `real-qcloop-evidence` 主缺口。
+
+### 2026-05-10 22:15 补充审计记录:Approval / Sandbox live runtime transcript 收口
+
+- 选择原因:full P0 qcloop owner 仍 stale,不能启动新的 GUI P0;`tool-approval-sandbox-boundary` 可以用 direct-host 后端 smoke 补齐 live runtime transcript sidecar。
+- 初始失败:`smoke:agent-runtime-approval-sandbox` 没有给 live runtime 请求传 provider/model preference;provider 未配置时,`agent_runtime_submit_turn` 没有形成权限确认 transcript。UI 侧失败截图为 `.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/agent-runtime-create-session-fail-2026-05-10.png`。
+- 代码:`scripts/agent-runtime-approval-sandbox-smoke.mjs` 新增 provider/model preference 参数与本地 enabled provider 自动选择;本轮选择 `deepseek` / `deepseek-v4-flash`,不暴露 API Key。
+- 验证:`node --check scripts/agent-runtime-approval-sandbox-smoke.mjs`、`npx vitest run scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts`、`npm run smoke:agent-runtime-approval-sandbox -- --timeout-ms 120000 --output .lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/runtime-approval-sandbox-smoke.fixed.json`、`npm run smoke:agent-runtime-tool-surface`、`npm run agent-qc:check` 均通过。
+- Evidence:`.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json`,verdict=`pass`;`runtime-approval-sandbox-smoke.fixed.json`,status=`pass`。
+- Live assertions:`devBridgeHealthy`、`permissionRequestCreatedBeforeModel`、`deniedDecisionClearsPendingRequest`、`resolvedDecisionClearsPendingRequest`、`approvalPolicySubmitted`、`sandboxPolicySubmitted` 均为 `true`;denied / resolved 两条 flow 均生成 `runtime_permission_confirmation:`。
+- 审计影响:`tool-approval-sandbox-boundary` 从“缺 live transcript sidecar”降级为 direct-host backend sidecar pass;但官方 `.lime/qc/agent-qc-evidence.json` 仍为 fail,不能覆盖 `real-qcloop-evidence`。
+
+### 2026-05-10 22:20 补充审计记录:当前 completion gate 仍 incomplete
+
+- `node scripts/agent-qc-completion-audit.mjs --format json` 当前仍为 `status=incomplete`、`16/18`;缺口为 `real-qcloop-evidence` 与 `local-verify-gate`。
+- `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 刷新后仍为 `verdict=stale`,job `1778405842243079000` 为 `4 success / 1 running / 3 pending / 1 stale`,`browser-runtime-site-adapter` stale 约 `16482s`。
+- `.lime/qc/gui-owner-current.json` 显示 `ownerCount=1`、`staleOwnerCount=1`、`verdict=blocked`;watch history 已追加到 `.lime/qc/stale-owner-watch-history.jsonl`,当前 13 条。
+- `local-verify-gate` 仍失败在 `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx(129,51)`:`Argument of type 'number' is not assignable to parameter of type 'Timeout'.` 该文件属于 settings-v2 高冲突区域,本轮未修改。
+
+### 2026-05-10 22:45 补充审计记录:verify:local 新失败点与 qcloop stale owner 续刷
+
+- `npm run verify:local` 已结束,exit `1`。本轮 `verify:app-version`、`lint`、`typecheck` 与 `vitest-smart` 批次 `1-38` 通过;`npm test` / `vitest-smart` 在批次 `39/54` 失败。
+- 新失败点为 `src/components/agent/chat/hooks/agentStreamSubmissionLifecycle.test.ts:98`:测试期望 `activeStream` 不包含 `turnId`,但当前实现返回了 `turnId`。相关 `agentStreamSubmissionLifecycle.test.ts` 与 `agentStreamSubmissionLifecycle.ts` 当前均为活动工作树修改,本轮只记录 gate 结果,不改动这些文件。
+- `.lime/qc/verify-local-current.json` 已更新为 `status=fail`、`failedStage="npm test / vitest-smart batch 39/54"`;新增 sidecar `.lime/qc/verify-local-2026-05-10-2239.md`。
+- `qcloop-status.isolated-p0-full-v1-current.json` 只读刷新后仍为 `verdict=stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;`browser-runtime-site-adapter` stale 约 `17692s`,stdout/stderr 仍为 `0 / 0`。
+- SQLite 只读复核显示 active item `1778405842246191000` 仍由 `qcloop-worker-1` 持有,`lock_expires_at=2026-05-10T22:56:05+08:00`;PID `69738` 仍存活,PPID=`1`、PGID=`69307`、elapsed≈`04:56:40`。新增 sidecar `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md`。
+- `agent-qc:gui-owner-check -- --watch-history-output` 已追加 watch history;`.lime/qc/stale-owner-watch-history.jsonl` 当前为 `14` 条。
+- `node scripts/agent-qc-completion-audit.mjs --format json` 当前仍为 `status=incomplete`、`16/18`;缺口保持 `real-qcloop-evidence` 与 `local-verify-gate`。本轮没有 kill / pause / interrupt / restart 任何进程,没有修改 qcloop DB,没有覆盖官方 `.lime/qc/agent-qc-evidence.json`,也没有 commit / push / tag / release。
+
+### 2026-05-10 23:40 补充审计记录:verify:local 新一轮仍在运行
+
+- 当前存在一轮新的 `npm run verify:local` wrapper 正在运行,未结束,因此 `.lime/qc/verify-local-current.json` 仍是 22:39 的旧 fail 结果;completion audit 继续显示 `status=incomplete`、`16/18`。
+- 新一轮已通过 `verify:app-version`、`lint`、`typecheck`、前端 Vitest 批次、`test:contracts`、`cargo test --manifest-path src-tauri/Cargo.toml` 主库测试,以及 `verify:gui-smoke` 中的 `workspace-ready` / `browser-runtime` / `site-adapters` / Skill Forge 前端 smoke。
+- 当前停在 `smoke:agent-service-skill-entry` 的 Rust 定向测试 `register_capability_draft_persists_readonly_http_preflight_provenance`;日志最后显示 cargo artifact lock 与后续编译,相关 `rustc` 仍有 CPU 活动。为避免和隔壁 v0.6 / qcloop worker 冲突,本轮没有中断任何进程。
+- Running snapshot:`.lime/qc/verify-local-2026-05-10-2340-running.md`。
+- qcloop 官方证据仍未完成:`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 仍 `verdict=stale`,job `1778405842243079000` 为 `4 success / 1 running / 3 pending / 1 stale`;`.lime/qc/gui-owner-current.json` 仍 `blocked`。
+- 审计影响:当前不能把 `local-verify-gate` 从缺口移除;只有该 wrapper 自然结束并写出 `status=pass` 后,才能重跑 completion audit 并更新整体完成度。
+
+### 2026-05-10 23:50 补充审计记录:Skill Forge Rust 定向测试进展到第二条 Cargo lock
+
+- `verify:local` 已从第一条 Skill Forge Rust 定向测试中恢复并通过:`register_capability_draft_persists_readonly_http_preflight_provenance` 对应 cargo test exit `0`。
+- 当前卡点移动到第二条 Rust 定向测试:`registered_skill_becomes_ready_for_manual_enable_binding_candidate`,日志显示 `Blocking waiting for file lock on artifact directory`。
+- 只读进程快照显示仍有并发 Cargo 工作活跃,因此本轮继续等待或记录阻塞,不中断任何进程。
+- Evidence:`.lime/qc/verify-local-2026-05-10-2350-cargo-lock.md`。
+- 审计影响:当前 active run 尚未结束,不能把 `local-verify-gate` 记为 pass;`completion audit` 仍保持 `16/18`,但旧 22:39 Vitest 失败已不再是 active run 的当前停留点。
+
+### 2026-05-10 23:55 目标级 prompt-to-artifact 审计刷新
+
+目标拆解为 7 个可验证交付项:
+
+| 要求 | 证据 | 当前结论 |
+| --- | --- | --- |
+| 不只覆盖 Agent UI,必须包含后端 runtime 行为 | `24-claw-cancel-sensenova-clean-console-summary.json`、`approval-sandbox-live-current-2026-05-10/summary.json` | sidecar pass;不能替代官方 qcloop |
+| Agent UI / Skills 全路径用 Playwright 测试并留截图 | `.lime/qc/gui-evidence/agent-ui-manual-e2e-2026-05-10/summary.json`,10 张截图 | pass |
+| 发现的产品 UI/UX 问题要修复并复测 | `skill-preflight-reference-sanitized`、`skills-search-local-hit`、`advanced-settings-plan-label-cn` | pass |
+| 非 Agent UI 的产品 / 后端风险要测试 | `design-canvas-project-roundtrip-current`、`harness-replay-regression-current` | partial pass;Harness trend 仍只有 `sampleCount=1` |
+| `verify:local` 统一门禁通过 | `.lime/qc/verify-local-current.json`、`.lime/qc/verify-local-2026-05-10-2350-cargo-lock.md` | blocked / not pass;active run 仍等待 Cargo lock |
+| 官方 full P0 qcloop Evidence Pack 通过 | `.lime/qc/agent-qc-evidence.json`、`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json`、`.lime/qc/gui-owner-current.json` | blocked;qcloop stale,GUI owner blocked |
+| 不与隔壁 v0.6 / stale GUI owner 冲突 | qcloop status、GUI owner check、process snapshots | pass;未 kill / pause / interrupt、未新开 full P0、未改 DB |
+
+机器可读审计:`.lime/qc/objective-completion-audit-2026-05-10-2355.json`,`achieved=false`。因此不能调用 goal complete,也不能把当前 sidecar pass 宣称为整体完成。
+
+### 2026-05-10 23:56 补充审计记录:completion gate 仍 incomplete,local verify 归因为 GUI smoke timeout
+
+- `npm run verify:local` 已自然结束,exit `124`;`.lime/qc/verify-local-current.json` 已保持 `status=fail`,并把失败阶段归一化为 `verify:gui-smoke / smoke:agent-service-skill-entry`。
+- 失败原因不是 22:39 的 `agentStreamSubmissionLifecycle` Vitest 断言,而是 `smoke:agent-service-skill-entry` 在第二条 Skill Forge Rust 定向测试期间超过 `1830000ms` 超时;第一条 Rust 定向测试已通过。
+- `node scripts/agent-qc-completion-audit.mjs --format json` 已保存到 `.lime/qc/completion-audit-2026-05-10-2356.json`,结果仍为 `status=incomplete`、`16/18`。
+- 目标级审计刷新为 `.lime/qc/objective-completion-audit-2026-05-10-2357.json`,`achieved=false`。
+- qcloop 官方证据仍 blocked:`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 为 `verdict=stale`;`.lime/qc/gui-owner-current.json` 为 `blocked`。
+
+### 2026-05-11 00:20 只读状态复核与 Claw streaming smoke 更新
+
+本次复核仍遵守“不推送、不中断其他进程、不覆盖官方 Evidence Pack”的约束,只更新 sidecar、docs/tests 和 Agent QC 脚本证据能力。
+
+qcloop full P0 job `1778405842243079000` 仍未自然释放:`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 显示 `verdict=stale`,`4 success / 1 running / 3 pending / 1 stale`;active item 仍是 `browser-runtime-site-adapter`,attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` 的 stdout/stderr 仍为 `0 / 0`。SQLite 最新只读快照 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json` 显示 `lock_expires_at=2026-05-11T00:31:05+08:00`,说明 lease 仍在延长。`.lime/qc/gui-owner-current.json` 仍为 `blocked`,active GUI owner 为 `1`,最长 stale 约 `23482s`。
+
+最新 GUI smoke sidecar `.lime/qc/verify-gui-smoke-current.json` 记录 `npm run verify:gui-smoke -- --reuse-running` 为 `fail`。前置阶段已经通过:DevBridge health、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、runtime tool surface 和 runtime tool surface page;默认 `smoke:claw-chat-ready-streaming` 已能自动解析 `deepseek / deepseek-v4-flash`,因此旧的 provider/model 解析缺口已解除。当前失败更深:长 turn 提交后没有在 smoke 窗口内同时观察到首个流式文本和可见“停止”按钮,证据见 `.lime/qc/verify-gui-smoke-reuse-2026-05-11-0006.log` 与 `.lime/qc/gui-evidence/claw-chat-ready-streaming/claw-chat-ready-streaming-summary.json`。
+
+另一个正在运行的显式 `deepseek-chat` 复核自然结束后,也进入同一 first-delta wait 路径,并暴露 `readPageSnapshot` 返回 `null` 时脚本会抛 `Cannot read properties of null (reading 'stopVisible')` 的证据缺口。已修复 `scripts/claw-chat-ready-streaming-smoke.mjs`:等待首增量和恢复结果时会容忍 `null` 快照;失败路径会写出 `console`、`network-invoke`、`runtime-session`、`thread-read` 与 `failureSnapshot`,避免下一次失败只留下 summary。修复后只跑了 `node --check scripts/claw-chat-ready-streaming-smoke.mjs`,未重跑完整 GUI smoke,以免抢占当前仍在运行的 Lime / Cargo / qcloop 进程。
+
+审计影响:`local-verify-gate` 仍不能关闭。当前缺口仍是 `real-qcloop-evidence` 与 `local-verify-gate`;后者现在应同时看 `.lime/qc/verify-local-current.json` 和 `.lime/qc/verify-gui-smoke-current.json`,不能再把旧的 provider/model 解析失败当成唯一原因。
+
+### 2026-05-11 00:25 GUI smoke 补充:显式 Sensenova provider 跑通
+
+00:20 后观察到另一个已在运行的 GUI smoke 自然完成,本轮没有中断或重启它。该命令使用显式 provider/model:`LIME_AGENT_QC_PROVIDER=custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed LIME_AGENT_QC_MODEL=sensenova-6.7-flash-lite npm run verify:gui-smoke -- --reuse-running --timeout-ms 240000`,结果为 `pass`,已写入 `.lime/qc/verify-gui-smoke-current.json` 与 `.lime/qc/verify-gui-smoke-2026-05-11-0025-sensenova-pass.md`。
+
+本次通过覆盖:DevBridge health、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、runtime tool surface、runtime tool surface page、`claw-chat-ready-streaming`、knowledge-gui 和 design-canvas。`claw-chat-ready-streaming` 证据显示 `verdict=pass`,断言覆盖首个流式增量、停止按钮、interrupt scoped to long turn、long turn aborted、recovery turn completed、GUI 可见恢复结果、runtime 持久化和无 runtime mock fallback。
+
+审计影响:`.lime/qc/verify-gui-smoke-current.json` 已从 fail 更新为 pass,但 `local-verify-gate` 仍不能关闭,因为它要求完整 `npm run verify:local` 当前 sidecar 为 pass;`.lime/qc/verify-local-current.json` 仍记录最新 full wrapper 为 fail。下一刀应在并发 Cargo/GUI 负载收敛后重跑完整 `npm run verify:local`,而不是只用单独 GUI smoke pass 替代本地统一门禁。
diff --git a/docs/tests/lime-agent-qc-current-blockers.md b/docs/tests/lime-agent-qc-current-blockers.md
new file mode 100644
index 000000000..c7aa028b5
--- /dev/null
+++ b/docs/tests/lime-agent-qc-current-blockers.md
@@ -0,0 +1,506 @@
+# Lime Agent QC 当前 P0 阻断记录
+
+> 本文件记录 Lime 作为样本产品执行 Agent QC P0 时发现的真实阻断。它不是发布放行说明;只要本文件中的 P0 blocker 未清空,`.lime/qc/agent-qc-evidence.json` 就不应被视为绿色发布证据。
+
+## 1. 当前结论
+
+截至 2026-05-11 01:13,本地已经具备 Agent QC 标准、manifest、Evidence Pack schema、qcloop exporter、release summary gate、completion audit、qcloop status sidecar 和 GUI smoke 能力。隔离 qcloop 已经证明部分 P0 smoke 能在 worker 内跑通;最新显式 Sensenova provider 的 `verify:gui-smoke -- --reuse-running` 已通过,并覆盖 Claw streaming / interrupt / resume,但官方 Evidence Pack 仍为失败,完整 `verify:local` 与 stale qcloop P0 owner 仍未闭环:
+
+```text
+.lime/qc/agent-qc-evidence.json status=fail scenarios=8/8
+```
+
+这说明当前不是“缺测试”,而是测试系统已经把阻断分成三类:产品深证据 blocker、qcloop worker / provider 卡住、以及 verifier 正确拒绝“只有命令通过、缺少 transcript / trace / console-network 证据”的浅层输出。不能通过修改门禁、降低证据要求或用 isolated partial evidence 把它伪装成通过。
+
+## 2. 证据来源
+
+| 证据 | 状态 | 说明 |
+| --- | --- | --- |
+| qcloop P0 v3 `1778390726823769000` | `completed` / `fail` | 覆盖 8/8 P0,2 pass / 6 fail |
+| qcloop rerun v4 `1778392677659787000` | `completed` / `fail` | 重跑 6 个失败 P0,最终 0 pass / 6 exhausted;失败集中在旧 verifier 输出读取和 qcloop worker localhost / DevBridge 权限 |
+| qcloop rerun v5 `1778398587521627000` | `completed` / sidecar `fail` | 使用带 worker preflight 和 stdout verifier 的新 payload;最终结果为 1 `failed`、5 `exhausted`、0 `success`;其中 GUI / Skill / Browser 类场景已明确输出 `QCLOOP_WORKER_RESULT=BLOCKED` |
+| `.lime/qc/agent-qc-evidence.json` | `fail` | 官方默认 Evidence Pack,覆盖 8/8,但不能发布 |
+| `.lime/qc/agent-qc-evidence.p0-v3.json` | `fail` | v3 sidecar,覆盖 8/8 |
+| `.lime/qc/agent-qc-evidence.p0-rerun-v4-running.json` | `fail` / `blocked` | v4 sidecar,当前 6 个重跑场景未完成或失败 |
+| `.lime/qc/agent-qc-evidence.p0-rerun-v4-completed.json` | `fail` | 2026-05-10 15:35 从 completed v4 job 导出的 sidecar,6/6 exhausted;不是官方发布证据 |
+| `.lime/qc/agent-qc-evidence.p0-rerun-v5-current.json` | `fail` | v5 sidecar;`tool-approval-sandbox-boundary` qcloop item 已 failed,同时 GUI / Skill / Browser 类场景因 `QCLOOP_WORKER_RESULT=BLOCKED` 保持 blocked 语义;不是官方发布证据 |
+| `.lime/qc/qcloop-executor-env-20260510.json` | env finding | qcloop serve PID `80248` 仅设置了 `QCLOOP_CODEX_BIN`,未设置 `QCLOOP_CODEX_SANDBOX=off` / bypass / approval policy;解释了 worker localhost 被 Codex 默认沙箱阻断的高概率原因 |
+| `.lime/qc/qcloop-devbridge-health-after-v5.json` | env finding | v5 completed 后宿主 shell 直连 `http://127.0.0.1:3030/health` 也失败,`lsof` 未发现 3030 listener;这是宿主 DevBridge 恢复前的历史阻断证据 |
+| `.lime/qc/qcloop-devbridge-health-restored.json` | env finding | 2026-05-10 16:59 后 headless Tauri 启动成功,宿主 `npm run bridge:health` 与 `agent-qc:qcloop-preflight -- --require-devbridge` 均为 pass;官方 evidence 未变 |
+| qcloop worker preflight `1778403715309891000` | `completed` / `blocked` sidecar | 宿主 DevBridge 已恢复后创建 1 个只读 preflight item;worker cwd/tmp 通过,但 DevBridge health 仍 `fetch failed`,输出 `QCLOOP_WORKER_RESULT=BLOCKED`;`.lime/qc/qcloop-status.worker-devbridge-preflight.json` 归类为 `blocked` |
+| isolated qcloop worker preflight `1778404260108641000` | `completed` / sidecar `complete` | 使用隔离 qcloop server `127.0.0.1:18080`、独立 DB `.lime/qc/qcloop-isolated-worker-preflight.db`、`QCLOOP_CODEX_SANDBOX=off` 后,worker preflight 通过;证明 qcloop worker 权限问题可通过正确启动环境解除 |
+| isolated `workspace-ready-session-restore` `1778404364137496000` | `completed` / sidecar `complete` | `smoke:workspace-ready` 与 `verify:gui-smoke -- --reuse-running` 均通过;只作为 sidecar,不覆盖官方 Evidence Pack |
+| isolated `browser-runtime-site-adapter` `1778404601640847000` | `completed` / sidecar `complete` | `smoke:browser-runtime` 与 `smoke:site-adapters` 均通过;只作为 sidecar,不覆盖官方 Evidence Pack |
+| isolated `skill-forge-register-bind-enable` `1778404743505029000` | `completed` / sidecar `complete` | `test:contracts` 与 `smoke:agent-service-skill-entry` 均通过;只作为 sidecar,不覆盖官方 Evidence Pack |
+| isolated `release-package-startup-smoke` v1 `1778404882904047000` | `failed` / sidecar `blocked` | 执行时宿主 `127.0.0.1:3030` 已再次断开,preflight 停在 `devbridge-health BLOCKED`,未运行版本和 GUI smoke;保留为历史环境阻断证据 |
+| isolated `release-package-startup-smoke` v2 `1778405385701480000` | `completed` / sidecar `complete` | 宿主 DevBridge 恢复后,隔离 qcloop worker preflight、`verify:app-version`、`verify:gui-smoke -- --reuse-running` 均通过;artifact scope 明确为 `source-tree-startup-smoke`,不是 installer 验证,也不覆盖官方 Evidence Pack |
+| isolated P0 full v1 `1778405842243079000` | `running` / sidecar `stale` | 已有 4/8 success:`command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`skill-forge-register-bind-enable`;`browser-runtime-site-adapter` 仍 running 且当前 attempt 无 stdout/stderr,SQLite lease 被心跳延长到 `2026-05-10T19:00:05+08:00`;后续 3 个场景 pending;只读观察,不中断 |
+| isolated no-MCP P0 full v1 `1778410893606889000` | `failed` / sidecar `blocked` | 为排查内层 `codex exec` 用户级 MCP 启动导致长期无输出的问题,启动了独立端口 `127.0.0.1:18081`;该批次因 `QCLOOP_CODEX_BIN` 指向坏 Homebrew symlink 立即失败,现按 worker CLI 环境阻断归类,不作为产品失败 |
+| isolated no-MCP P0 full v2 `1778410956075020000` | `running` / sidecar `stale` | 使用独立端口 `127.0.0.1:18082`、正确 Codex bin、`QCLOOP_CODEX_EXTRA_ARGS='--ephemeral -c mcp_servers={}'`;实测仍可能启动用户级 MCP 子进程;`command-bridge-contract` 已在 repair attempt 后 success;当前 `claw-chat-ready-streaming` running 且 9 分钟无 stdout/stderr,被 sidecar 标记 stale;后续 6 个 P0 pending;只读观察,不覆盖官方 evidence |
+| isolated MCP-disabled P0 full v1 `1778412160003934000` | `running` / sidecar `running` | 使用独立端口 `127.0.0.1:18083`、正确 Codex bin,并把 `mcp_servers.context7.command` / `mcp_servers.playwright.command` 覆盖为空;初始 `ps` 未观察到用户级 MCP 子进程;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending;只读观察,不覆盖官方 evidence |
+| isolated fast P0 full v1 `1778412499745993000` | `running` / sidecar `running` | 使用独立端口 `127.0.0.1:18084`、正确 Codex bin、MCP command 覆盖为空,并额外加 `--ignore-rules` 避免项目规则 / skill / MCP 依赖放大;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending;只读观察,不覆盖官方 evidence |
+| isolated fast-mini readonly P0 full v1 `1778412738097137000` | `running` / sidecar `running` | 使用独立端口 `127.0.0.1:18085`、`gpt-5.4-mini`、low reasoning、MCP command 覆盖为空、`--ignore-rules`、`max_qc_rounds=1` 和只读 worker prompt;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending;只读观察,不覆盖官方 evidence |
+| direct host `browser-runtime-site-adapter` check | command `pass` with cleanup warning | `agent-qc:qcloop-preflight -- --require-devbridge`、`smoke:browser-runtime`、`smoke:site-adapters` 在宿主 shell 直接通过;`smoke:browser-runtime` 输出了非阻断 cleanup warning:`close_cdp_session` 未找到刚创建的 session;这证明当前 full P0 卡点更像 qcloop worker / provider 无输出,而不是宿主 DevBridge 或产品命令不可用 |
+| direct host `harness-replay-regression` check | command `pass` with trend seed caveat | `npm run harness:eval` 与 `npm run harness:eval:trend` 在宿主 shell 直接通过;current observability gap 为 0,degraded gap 为 1,trend 样本数为 1,只能作为 seed,不能判断长期退化 |
+| direct host `workspace-ready-session-restore` / `release-package-startup-smoke` check | command `pass` | `smoke:workspace-ready`、`verify:app-version`、`verify:gui-smoke -- --reuse-running` 在宿主 shell 直接通过;验证了 workspace ready、版本一致性、DevBridge health、GUI smoke、browser runtime、site adapters、service skill entry、runtime tool surface、knowledge GUI、design canvas |
+| direct host `tool-approval-sandbox-boundary` live runtime check | command `pass` | `.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json` verdict=`pass`;denied / resolved 两条 flow 均生成 `runtime_permission_confirmation:*`,并证明 `approvalPolicy=on-request`、`sandboxPolicy=workspace-write` 已随 turn 提交;仍是 sidecar,不覆盖官方 Evidence Pack |
+| `npm run agent-qc:qcloop-status -- --job-id 1778398587521627000` | `completed` / sidecar monitoring | 最终只读检查显示 v5 已 completed / fail,`tool-approval-sandbox-boundary` failed,其他 5 个重跑项 exhausted;用于排障,不覆盖官方 evidence |
+| `.lime/qc/gui-evidence/product-backend-ux-e2e-2026-05-10.json` | `pass` + deep flow finding `fail` | 基础 GUI surface 通过,但 Claw streaming deep flow 标记为 product blocker |
+
+### 2.1 最新只读刷新(2026-05-10 19:58)
+
+| 批次 | 当前状态 | 关键含义 |
+| --- | --- | --- |
+| isolated full P0 v1 `1778405842243079000` | `running` / `stale`,4 success、1 running、3 pending | `browser-runtime-site-adapter` 已无 stdout/stderr 约 8002 秒;只读观察,不 kill、不覆盖官方 evidence |
+| no-MCP P0 v2 `1778410956075020000` | `running`,2 success、1 failed、1 running、4 pending | `tool-approval-sandbox-boundary` 已 failed;`skill-forge-register-bind-enable` running;`mcp_servers={}` 仍不足以完全证明内层 MCP 降噪 |
+| MCP-disabled P0 v1 `1778412160003934000` | `running` / `stale`,1 failed、1 running、6 pending | `command-bridge-contract` 因旧 no-change verifier 失败;`claw-chat-ready-streaming` stale 约 674 秒;后续 payload 已修正,但当前批次只读观察 |
+| fast P0 v1 `1778412499745993000` | `running`,1 success、1 running、6 pending | `--ignore-rules` 后 command bridge 已通过,`claw-chat-ready-streaming` 仍在跑 |
+| fast-mini readonly P0 v1 `1778412738097137000` | `failed`,8 exhausted | sidecar evidence 为 `fail=6 / blocked=2`。verifier 正确拒绝缺少 deep evidence 的浅层输出;同时发现 `workspace-ready-session-restore` 的 `verify:gui-smoke` 失败在 `smoke:design-canvas` 保存成功状态断言,`release-package-startup-smoke` 因 GUI smoke 未自然收口被判 blocked |
+
+这次刷新说明:当前最有价值的下一刀不是“再开一个全量 P0 批次”,而是把浅层 smoke 与深层 live transcript 拆清楚,让 qcloop worker 输出可被 verifier 审计的结构化证据;仍在 running 的批次继续只读观察。
+
+由于当前仍有多个 GUI / DevBridge 相关 qcloop 批次 running 或 stale,后续不能再并发启动新的 full P0 GUI 批次。新的 manifest 已把 `GUI session owner / isolation statement` 加入 GUI P0 evidence,并把 `parallel GUI smoke interference` 加入 failure mode;这用于区分真实产品回归和多个 worker 抢同一个 Lime GUI 会话造成的测试失真。
+
+机器前置检查:
+
+```bash
+npm run agent-qc:gui-owner-check -- --check
+```
+
+当前该命令应阻断新 GUI P0,因为仍有 active GUI owner sidecar。
+
+`workspace-ready-session-restore` 的细化证据已导出到 `.lime/qc/design-canvas-failure-fastmini-workspace-extract.json`。关键日志为:
+
+```text
+[smoke:design-canvas] stage=project-roundtrip-save-open
+[smoke:design-canvas] 工程目录保存结果 等待失败,缺少文本 "已保存图层设计工程"
+页面已在 CANVAS:DESIGN 专属 GUI SMOKE / AI 图层化设计画布,但未出现保存成功状态
+DevBridge status=ok,smoke:workspace-ready PASS
+```
+
+这说明当前不是 workspace 初始化失败,也不是 Bridge 不可用,而是 GUI ready / 设计画布导出状态没有形成可观察的保存完成证据。
+
+### 2.2 最新只读复核(2026-05-10 20:40)
+
+按用户要求,本轮没有 push、没有 commit,也没有 kill / pause / interrupt 任何 qcloop 或 Codex worker。只读刷新后,当前需要以 `1778405842243079000` 为唯一 active GUI owner 处理;其他历史批次只作为 sidecar 排障事实,不再触发新的 full GUI P0 抢占。
+
+| 检查 | 当前值 | 结论 |
+| --- | --- | --- |
+| qcloop job | `1778405842243079000` / `running` | full P0 v1 未终态 |
+| qcloop verdict | `stale` | 仍不能导出官方 pass evidence |
+| qcloop counts | 4 success / 1 running / 3 pending / 1 stale | P0 覆盖未完成 |
+| active scenario | `browser-runtime-site-adapter` | 卡在 GUI / browser runtime 类 P0 |
+| worker output | stdout/stderr `0 / 0` | 尚未开始可审查的业务命令输出 |
+| worker duration | 约 `10508s` | 已远超 stale 阈值 |
+| observed process | PID `69738`,内层 `codex exec` | stale owner 不是旧 sidecar 噪声 |
+| GUI owner gate | `ownerCount=1`、`staleOwnerCount=1`、`verdict=blocked` | 不允许启动新的 full GUI P0 |
+
+当前唯一可安全推进的是继续维护标准、sidecar 和 runbook。必须等待 PID `69738` 自然释放,或由 owner 明确确认处置后,才能按 `docs/tests/lime-agent-qc-stale-owner-intervention.md` 执行 post-stale runbook。确认前不得直接处理进程或修改 qcloop DB。
+
+### 2.3 只读复核续刷(2026-05-10 20:44)
+
+再次只读刷新后,状态未释放:`1778405842243079000` 仍为 `running` / `stale`,`browser-runtime-site-adapter` 的 `staleSeconds=10772`,stdout/stderr 仍为 `0 / 0`;`agent-qc:gui-owner-check` 仍为 `blocked`,`ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds=10772`。`ps` 仍可见 PID `69738` 处于内层 `codex exec`。本轮未执行任何中断动作。
+
+### 2.4 DB / lease 级取证(2026-05-10 20:48)
+
+新增 sidecar:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1.md`。只读 SQLite 与进程树显示:
+
+| 取证点 | 当前值 | 含义 |
+| --- | --- | --- |
+| `batch_jobs.status` | `running` | qcloop 仍认为该批次未终态 |
+| active item | `1778405842246191000` / `browser-runtime-site-adapter` | 卡住的是 browser runtime P0 |
+| item lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-10T21:02:05+08:00` | lease 仍被心跳延长 |
+| attempt | `running`,started `2026-05-10T17:45:05+08:00` | active attempt 未结束 |
+| attempt output | stdout/stderr `0 / 0` | 不是产品命令失败,而是没有可审查业务输出 |
+| process | PID `69738`,PPID `1`,PGID `69307` | worker 已成为 orphan,但仍在 qcloop process group |
+| child processes | Playwright MCP / Context7 MCP npm exec | 与 `worker user-config MCP startup no-output hang` failure mode 匹配 |
+
+这把 blocker 从“sidecar 显示 stale”升级为“DB lease、attempt、process tree 三侧一致证明 stale”。确认前仍不得处理进程或改 DB。
+
+### 2.5 qcloop runtime binary provenance(2026-05-10 20:52)
+
+新增 sidecar:`.lime/qc/qcloop-runtime-binary-provenance-18080.md`。只读 `lsof` 与 checksum 显示:
+
+| 二进制 | 路径 | 大小 | SHA-256 | 结论 |
+| --- | --- | --- | --- | --- |
+| 当前 18080 qcloop serve | `/Users/coso/Documents/dev/ai/limecloud/qcloop/qcloop` | `13321106` | `177bf7fa...35dc5fe1` | 当前正在运行 |
+| timeout-fixed sidecar | `.lime/qc/bin/qcloop-timeout-fixed` | `13426082` | `2895e33d...3d4314c7` | 已构建但未运行 |
+
+这证明 qcloop executor timeout / process-group cleanup 补丁还没有影响当前 PID `69307` 的 qcloop serve,也不会自动回收 stale PID `69738`。后续只有在 owner 释放或确认处理当前 stale owner 后,才能用 fixed binary 新启隔离 server 重跑 P0。
+
+### 2.6 lease 过期窗口后复核(2026-05-10 21:02)
+
+新增 sidecar:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1-after-expiry.md`。等待超过先前记录的 `lock_expires_at=2026-05-10T21:02:05+08:00` 后,只读复核显示:
+
+| 取证点 | 当前值 | 含义 |
+| --- | --- | --- |
+| qcloop verdict | `stale` | job 仍未终态 |
+| counts | 4 success / 1 running / 3 pending / 1 stale | P0 仍未覆盖完成 |
+| active item | `browser-runtime-site-adapter` | 仍是同一 GUI / browser P0 阻塞 |
+| current lock | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-10T21:17:05+08:00` | lease 在过期窗口后继续被延长 |
+| active attempt | `running`,stdout/stderr `0 / 0` | 仍无可审查业务输出 |
+| process | PID `69738`,PPID `1`,PGID `69307` | worker 仍存活 |
+
+这确认 stale worker 没有在 lease 过期窗口自然释放;qcloop 仍在维持该 owner。未获 owner 明确确认前,仍只能继续只读观察。
+
+### 2.7 GUI owner 机器决策输出(2026-05-10 21:08)
+
+`agent-qc:gui-owner-check -- --format json` 现在会在 stale owner 场景输出 `ownerIntervention`,当前 `.lime/qc/gui-owner-current.json` 显示:
+
+```text
+ownerIntervention.status=requires_owner_confirmation
+requiredConfirmationText=确认处理 stale GUI owner 1778405842243079000,可以终止 PID 并记录 sidecar。
+```
+
+该输出已纳入 `agent-qc:audit` 的 `stale-owner-intervention-protocol` 检查项。注意:`` 仍必须由最新 `ps` / DB 取证填入;当前确认前不得把该字段视为授权。
+
+### 2.8 stale owner watch history(2026-05-10 21:10)
+
+新增 sidecar:`.lime/qc/stale-owner-watch-history.jsonl`。该文件按 JSONL 记录只读观察序列,当前包含 3 条:
+
+1. `20:44:52`:qcloop status / GUI owner 仍 blocked,PID `69738` 存活。
+2. `20:48:00`:DB active attempt running、stdout/stderr `0 / 0`,lease 到 `21:02:05`。
+3. `21:02:46`:超过上一 lease 窗口后,lease 延长到 `21:17:05`,PID `69738` 仍存活。
+4. `21:18:00`:超过第二个 lease 窗口后,lease 再次延长到 `21:32:05`,PID `69738` 仍存活,stdout/stderr 仍为 `0 / 0`。
+
+后续 Agent 可追加该 JSONL 作为观察历史,但仍不得据此自动处理进程;处理动作仍需要 owner 明确确认。
+
+2026-05-10 21:21 后续观察已改为脚本化追加:`agent-qc:gui-owner-check -- --watch-history-output ./.lime/qc/stale-owner-watch-history.jsonl` 已写入第 5 条记录,后续无需手工拼 JSONL。
+
+2026-05-10 21:32 续刷:超过第三个 lease 窗口后,qcloop 仍为 `stale`,`browser-runtime-site-adapter staleSeconds=13657`,stdout/stderr 仍为 `0 / 0`;SQLite 显示 `lock_expires_at=2026-05-10T21:47:05+08:00`,PID `69738` 仍存活。该观察通过 `--watch-history-output` 追加,当前 watch history entries=6。
+
+### 2.9 只读续刷(2026-05-11 01:02)
+
+按用户要求,本轮仍未 push、未 commit、未 tag、未 release,也没有 kill / pause / interrupt / restart 任何 qcloop、Codex worker、Tauri、Cargo 或 GUI smoke 进程。只读刷新后的当前状态:
+
+| 检查 | 当前值 | 结论 |
+| --- | --- | --- |
+| qcloop job | `1778405842243079000` / `running` | full P0 v1 仍未终态 |
+| qcloop verdict | `stale` | 仍不能导出官方 pass evidence |
+| qcloop counts | 4 success / 1 running / 3 pending / 1 stale | P0 覆盖仍未完成 |
+| active scenario | `browser-runtime-site-adapter` | 仍卡在 GUI / browser runtime 类 P0 |
+| stale age | 约 `26202s` | 已远超 stale 阈值 |
+| GUI owner gate | `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds=26202` | 不允许启动新的 full GUI P0 |
+| DB lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:17:06+08:00` | lease 仍被续约 |
+| active attempt | `dc625f8e-b3b9-46b7-9758-4b0273438d50` / `running` | stdout/stderr 仍为空 |
+
+新增 / 刷新 sidecar:
+
+- `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json`
+- `.lime/qc/gui-owner-current.json`
+- `.lime/qc/stale-owner-watch-history.jsonl`
+- `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json`
+- `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md`
+
+这些证据只支持 owner 决策,不构成处理授权。确认前仍不得处理 PID `69738`、修改 qcloop DB、启动新的 full GUI P0,或覆盖官方 `.lime/qc/agent-qc-evidence.json`。
+
+### 2.10 `verify:local` 与 Claw streaming 复核(2026-05-11 01:02)
+
+最新完整 `npm run verify:local` 仍为失败,且已把 `.lime/qc/verify-local-current.json` 刷新到最新失败口径:
+
+```text
+status=fail
+failedStage=verify:gui-smoke / smoke:claw-chat-ready-streaming
+evidence=.lime/qc/verify-local-sensenova-2026-05-11-0023.log
+```
+
+该失败发生在完整本地门禁中的 Claw streaming deep flow:前置 GUI smoke 已通过 `smoke:agent-service-skill-entry` 与 runtime tool surface,随后 `smoke:claw-chat-ready-streaming` 在“等待 GUI 出现恢复结果”处超时。之后脚本已加固并直跑复测:
+
+| 证据 | 状态 | 含义 |
+| --- | --- | --- |
+| `.lime/qc/gui-evidence/claw-chat-ready-streaming-post-refresh-fallback-2026-05-11/claw-chat-ready-streaming-post-refresh-fallback-summary.json` | `pass` | runtime 已持久化 `复原完成` 时,刷新 / 会话恢复 fallback 可以让 GUI 重新呈现恢复结果 |
+| `.lime/qc/gui-evidence/claw-chat-ready-streaming-sensenova-session-restore-2026-05-11/claw-chat-ready-streaming-sensenova-session-restore-summary.json` | `fail` | 复测期间 DevBridge 中途不可达,恢复 turn 未进入 completed;这是环境 / runtime 稳定性证据,不能替代完整门禁 |
+| `.lime/qc/claw-chat-ready-streaming-current.json` | `mixed` | 汇总当前 Claw deep flow 的 pass / fail 侧证据 |
+
+因此当前判断不变:Claw deep flow 已从“没有足够证据”推进为“有可复核的 pass/fail 证据和更精确的失败分类”,但 `local-verify-gate` 仍不能关闭。关闭条件仍是重新跑完整 `npm run verify:local` 并得到 `status=pass`。
+
+### 2.11 GUI smoke pass 与 raw process owner 复核(2026-05-11 01:13)
+
+观察到外部启动的 GUI smoke 自然结束并通过,本轮只读取日志和证据,没有中断或重启该进程:
+
+```text
+command=LIME_AGENT_QC_PROVIDER=custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed LIME_AGENT_QC_MODEL=sensenova-6.7-flash-lite npm run verify:gui-smoke -- --reuse-running --timeout-ms 240000
+status=pass
+evidence=.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-0108.log
+```
+
+已刷新:
+
+- `.lime/qc/verify-gui-smoke-current.json`
+- `.lime/qc/verify-gui-smoke-2026-05-11-0112-sensenova-pass.md`
+- `.lime/qc/claw-chat-ready-streaming-current.json`
+
+本次 GUI smoke 通过了 DevBridge health、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、runtime tool surface、runtime tool surface page、Claw streaming/interrupt/resume、knowledge-gui、i18n patch metrics report 和 design-canvas。Claw summary 中 `recoveryVisibleSource=live-stream`、`interruptedTurnStatus=aborted`、`followTurnStatus=completed`,且 runtime 关键命令未走 mock fallback。
+
+但 `.lime/qc/gui-process-owner-current.json` 仍显示 raw process owner 为 `busy`:存在长时间 `smoke:design-canvas` 进程、stale qcloop Codex worker,以及多个 Cargo / Rust 编译进程。因此仍不应在当前窗口启动新的完整 `verify:local` 或新的 full GUI P0 qcloop 批次;`local-verify-gate` 只能在这些 owner 自然释放后用完整 `npm run verify:local` 关闭。
+
+### 2.12 只读续刷与 owner 取证脚本化(2026-05-11 01:32)
+
+按“Lime 不要推送、还有其他进程在跑”的约束,本轮仍未执行 commit / push / tag / release,也没有 kill / pause / interrupt / restart qcloop、Codex worker、Tauri、Cargo 或 GUI smoke 进程。只读刷新后的当前状态:
+
+| 检查 | 当前值 | 结论 |
+| --- | --- | --- |
+| qcloop job | `1778405842243079000` / `running` | full P0 v1 仍未终态 |
+| qcloop verdict | `stale` | 仍不能导出官方 pass evidence |
+| qcloop counts | 4 success / 1 running / 3 pending / 1 stale | P0 覆盖仍未完成 |
+| active scenario | `browser-runtime-site-adapter` | 仍卡在 browser runtime / site adapter 类 P0 |
+| stale age | 约 `28020s` | 已远超 stale 阈值 |
+| GUI owner gate | `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds≈27026` | 不允许启动新的 full GUI P0 |
+| raw process owner | `busy`,`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7` | 不允许启动完整 `verify:local` |
+| DB lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:47:06+08:00` | lease 仍被续约 |
+| active attempt | `dc625f8e-b3b9-46b7-9758-4b0273438d50` / `running` | stdout/stderr 仍为空 |
+
+新增 / 刷新 sidecar:
+
+- `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json`
+- `.lime/qc/gui-owner-current.json`
+- `.lime/qc/stale-owner-watch-history.jsonl`
+- `.lime/qc/gui-process-owner-current.json`
+- `.lime/qc/gui-process-owner-current.md`
+- `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json`
+- `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md`
+
+新增 `npm run agent-qc:process-owner-check` 与 `npm run agent-qc:qcloop-db-lease`,把 raw process owner 和 SQLite lease 只读取证脚本化,避免后续依赖临时 `ps` / `sqlite3` 片段。这些脚本只用于判断是否应该等待,不授权处理任何进程或修改 qcloop DB。
+
+## 3. P0 blocker 列表
+
+| Scenario | 当前状态 | 直接证据 | 下一步 |
+| --- | --- | --- | --- |
+| `claw-chat-ready-streaming` | product blocker;isolated full sidecar pass with shallow GUI scope | `docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/11-claw-streaming-summary.json` 标记 stop / interrupt 后长 turn 仍 completed;default worker preflight job `1778403715309891000` 仍证明内层 Codex 无法访问 DevBridge;isolated full v1 已通过 `verify:gui-smoke -- --reuse-running`,但 stdout 明确 scope 不含 live long-turn interrupt transcript | 保留 deep flow blocker;后续必须修 stop / interrupt 后端语义,并把 live long-turn transcript 纳入 qcloop / Playwright MCP 深证据 |
+| `tool-approval-sandbox-boundary` | default qcloop failed;direct-host live runtime sidecar pass | v5 attempt 1 和 repair attempt 2 的确定性 smoke 不足以通过 verifier;本轮 direct-host `smoke:agent-runtime-approval-sandbox` 已补真实 live runtime transcript:denied / resolved 两条 flow 均生成 `runtime_permission_confirmation:*`,并证明 approval / sandbox policy 进入 turn config;证据在 `.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json` | 该场景从“缺 live transcript”降级为“待 full P0 qcloop 同批次采信”;不要覆盖官方 `.lime/qc/agent-qc-evidence.json`,也不要在 stale GUI owner 未释放前重启 full GUI P0 |
+| `skill-forge-register-bind-enable` | default qcloop blocked;isolated sidecar pass | v5 多轮 worker 均停在 DevBridge preflight blocked;隔离 qcloop job `1778404743505029000` 已证明 contracts + service skill entry smoke 可通过 | 等宿主 DevBridge 稳定后,把该证据纳入新的全量 P0 qcloop 批次;仍不能单独覆盖官方 evidence |
+| `browser-runtime-site-adapter` | default qcloop blocked;isolated single sidecar pass;isolated full P0 stale | v5 多轮 worker 均停在 DevBridge preflight blocked;隔离 qcloop job `1778404601640847000` 已证明 browser runtime + site adapter smoke 可通过;full P0 v1 当前卡在内层 `codex exec` 无 stdout/stderr;宿主直接运行 preflight + browser runtime + site adapters 通过,但 browser cleanup 有非阻断 warning | 等当前 full P0 worker 自然结束;后续重跑时把 browser runtime 与 site adapter 拆成更窄 item,并把 cleanup warning 纳入 evidence |
+| `qcloop-batch-verifier-repair` | worker / verifier 协议仍在收敛 | 已发现 qcloop generic repair prompt 会要求 worker “修复目标工作区”,这不适合发布证据批次;已把 worker prompt 改为只读,并生成 `.lime/qc/qcloop-readonly-p0-v1-payload.json` / `.lime/qc/qcloop-fastmini-readonly-p0-v2-payload.json`(`max_qc_rounds=1`)待用; no-MCP P0 v2 的 `command-bridge-contract` attempt 1 真实执行通过,verifier 正确拒绝了“只给命令通过与日志路径、未逐项解释 evidence / failure mode”的输出;repair attempt 已补足并通过 | worker prompt 已强化为必须逐项列出 `evidence_required` 和 `failure_modes`;`command-bridge-contract` verifier 已允许 no-change surface evidence 使用 checked surface counts + contract pass,并已生成待用 payload `.lime/qc/qcloop-isolated-nomcp-p0-v3-after-evidence-prompt-payload.json`;继续观察后续 P0 是否复现同类证据不足 |
+| `workspace-ready-session-restore` | default qcloop blocked;isolated sidecar pass;fast-mini full P0 failed | v5 多轮 worker 未提供 workspace-ready / GUI smoke / DevBridge 可审查证据;隔离 qcloop job `1778404364137496000` 曾证明 workspace-ready + GUI smoke 可通过;但 fast-mini readonly P0 v1 中 `smoke:workspace-ready` 通过、`verify:gui-smoke` 失败在 `smoke:design-canvas` 的保存成功状态断言,命中 `ui ready false positive` 风险 | 不把旧 isolated pass 当成当前 release 证据;后续需要定位 design canvas 保存状态断言或产品状态回写,再用 qcloop deep evidence 重跑 |
+| `release-package-startup-smoke` | default qcloop blocked;isolated source-tree sidecar pass;direct host pass | v5 多轮 worker 未提供版本、GUI smoke、首屏 ready、Bridge health、waiver 证据;隔离 v2 `1778405385701480000` 已证明 source-tree startup smoke;宿主直接运行 `verify:app-version` + GUI smoke 通过 | 进入 full P0 verifier 后才能覆盖官方 evidence;发布前仍不能把 source-tree smoke 伪装成 installer artifact |
+| `harness-replay-regression` | isolated full P0 pending;direct host pass | full P0 v1 尚未调度该 item;宿主直接运行 `harness:eval` + `harness:eval:trend` 通过,但 trend 只有 1 个样本,仍不是 qcloop verifier pass | 等当前 full P0 worker 自然结束后,让该 item 进入 qcloop verifier;长期趋势需要继续积累 nightly 样本 |
+
+## 4. 已经修正的标准问题
+
+- `release-package-startup-smoke` 不再要求场景本身预先提供 `release evidence pack`。
+- release Evidence Pack 的覆盖和 pass 状态由 `agent-qc:release-summary -- --require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --check` 单独强制。
+- `agent-qc:audit` 已能区分官方 `.lime/qc/agent-qc-evidence.json` 与 sidecar evidence,避免 partial 或 fail sidecar 被误读为完成。
+- `agent-qc:export-evidence` / `agent-qc:qcloop-status` 已能识别 worker stdout 中的 `QCLOOP_WORKER_RESULT=BLOCKED`,把 qcloop `exhausted` 但实为环境权限阻断的 item 归类为 Evidence Pack `blocked`。
+- 已提交 qcloop rerun v5 `1778398587521627000`,payload 来自 `.lime/qc/qcloop-p0-rerun-v5-verifier-evidence-ready-payload.json`,覆盖当前 6 个未通过 / 未完成 P0,并确认 worker prompt 含 preflight、verifier prompt 含 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}`。
+- 已用 `npm run tauri:dev:headless` 恢复宿主 DevBridge,并把恢复证据写入 `.lime/qc/qcloop-devbridge-health-restored.json`。
+- 已提交只读 qcloop worker preflight job `1778403715309891000`。该 job 证明问题已经从“宿主 DevBridge 未启动”收敛为“qcloop 内层 worker loopback / sandbox 权限阻断”,不是产品 P0 已恢复。
+- 已启动隔离 qcloop server `127.0.0.1:18080` 使用独立 DB 和显式 Codex sandbox 配置,证明 worker 权限可恢复,并产生 4 个 P0 sidecar pass:`workspace-ready-session-restore`、`browser-runtime-site-adapter`、`skill-forge-register-bind-enable`、`release-package-startup-smoke`。
+- `release-package-startup-smoke` v2 sidecar 只证明 source-tree startup smoke,不证明 installer artifact;当前仍不能写官方 Evidence Pack,因为还没有同一批次覆盖 8/8 P0 的真实 pass。
+- 已启动 isolated P0 full v1 `1778405842243079000`。该批次目前 4/8 success,但 `browser-runtime-site-adapter` item 长时间 running 且无 stdout/stderr,`.lime/qc/qcloop-status.isolated-p0-full-v1-stale-check.json` 已标记 `stale`;只读 `ps` 显示内层 `codex exec` 仍在跑、尚未出现 npm 子命令输出,后续 3 个 P0 pending;按 qcloop 运维规则只读观察,不 kill、不 pause、不覆盖官方 evidence。
+- 已把这次真实卡点回写到机器标准:`docs/test/agent-qc-scenarios.manifest.json` 的 `qcloop-batch-verifier-repair` 增加 `stale item sidecar`、worker stdout/stderr 长度摘要,以及 `running no-output stale` / `worker lease heartbeat without stdout` 失败模式;`browser-runtime-site-adapter` 增加 cleanup warning 证据与失败模式。
+- 已强化 `agent-qc:qcloop-status` stale evidence:`scripts/lib/agent-qc-qcloop-status-core.mjs` 现在输出 item 级 `staleSeconds` 和 worker `durationSeconds`;当前 sidecar 显示 `browser-runtime-site-adapter staleSeconds=5108 stdoutLength=0 stderrLength=0`。
+- 已把 qcloop worker 执行器环境失败纳入 blocked 语义:`QCLOOP_CODEX_BIN 不可用`、`QCLOOP_CODEX_EXTRA_ARGS` 解析失败、内层认证 / sandbox 配置错误不再被误判为产品 fail;blocker 摘要不会泄露原始 stderr 路径或凭证内容。
+- 已记录 no-MCP worker 策略:隔离 qcloop server 已验证 `mcp_servers={}` 不足以覆盖嵌套 MCP 配置;当前改用 `mcp_servers..command=""` 覆盖已知 MCP command,初始 `ps` 未观察到用户级 MCP 子进程。
+- 已强化 qcloop worker prompt:最终 stdout 必须逐项列出 `evidence_required` 是否满足,并说明每个 `failure_modes` 如何被覆盖、排除或命中;不能只写“命令通过”或只给日志路径。
+- 已给 P0 manifest 增加 `evidenceLayers`,并把 qcloop item 导出为 `evidence_layers`,要求 worker 明确本次只覆盖 `deterministic-smoke`,还是已经覆盖 `gui-trace`、`runtime-transcript` 或 `release-artifact`;这防止 smoke PASS 被误读成 deep evidence PASS。
+- `agent-qc:check` 已机械强制 P0 场景必须声明合法 `evidenceLayers`,未知层级会被 `scripts/lib/agent-qc-report-core.mjs` 阻断。
+- 已新增 `agent-qc:gui-owner-check`,用于在启动新 GUI P0 前只读扫描 active GUI qcloop sidecar 并阻断并发 GUI smoke 干扰。
+- 已生成待用 payload `.lime/qc/qcloop-p0-evidence-layers-v1-payload.json`,`max_qc_rounds=1` 且 8 个 P0 item 均带 `evidence_layers`;GUI P0 均要求 `GUI session owner / isolation statement`,`workspace-ready-session-restore` / `release-package-startup-smoke` 已纳入 design canvas 工程 roundtrip 和 GUI smoke 自然收口证据;仅供当前 running/stale 批次自然结束后使用,不自动提交。
+
+## 5. 完成定义
+
+本 blocker 文档只能在以下事实同时成立后关闭:
+
+1. qcloop P0 批次覆盖全部 8 个 P0 scenario id。
+2. `.lime/qc/agent-qc-evidence.json` 的 `verdict.status` 为 `pass`。
+3. `npm run agent-qc:release-summary -- --evidence ./.lime/qc/agent-qc-evidence.json --require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --check` 通过。
+4. `npm run agent-qc:audit -- --format json` 返回 `complete`。
+
+在此之前,Lime Agent QC 整体目标不得标记完成。
+
+### 5.1 手动 Playwright Agent UI / Skills 非抢占续测(2026-05-10 20:54)
+
+新增证据目录:`.lime/qc/gui-evidence/agent-ui-manual-e2e-2026-05-10/`。
+
+| 检查 | 当前值 | 结论 |
+| --- | --- | --- |
+| qcloop owner gate | `blocked`,active owner 仍为 `1778405842243079000` | 未启动新的 full GUI P0 |
+| Playwright manual flow | 首页 / 新建任务 / Skills / Skill 补参 / Skill 搜索 / 能力草案 / 已注册能力 / 添加资料 / 高级设置 | 手动 UI 流程可交互 |
+| screenshots | `01-home.png` 到 `10-advanced-settings-open.png` | 已留图证据 |
+| console | `Errors: 0, Warnings: 0` | 无新增控制台错误 |
+| network | `194` 个非静态请求,全部 HTTP `200` | 无非静态网络失败 |
+| `Agent 1000` 标签 | 未观察到 | 本轮截图未复现该冗余标签 |
+| 新问题 | Skill 补参卡暴露 `auto_analysis/context/preference` 等内部线索;Skills 搜索空态与本地命中并存;高级设置有 `Plan` 等混合英文 / 内部词 | 不阻断官方 qcloop,但应作为 Agent UI 产品化 polish / 信息泄露后续项 |
+
+该续测只能证明当前桌面 WebView 的 Agent UI 表层可交互,不能替代官方 Evidence Pack。关闭条件仍保持:新的同批次 8/8 P0 structured qcloop Evidence Pack pass、release summary pass、completion audit complete。
+
+### 5.2 已解决:Skill 补参卡内部参考信息外露(2026-05-10 21:10)
+
+| 项 | 结果 |
+| --- | --- |
+| 原问题 | Skill 补参卡向普通用户暴露 `auto_analysis/context/preference`、`fp:*`、`-32603`、`Pexels API Key` 与 task JSON |
+| 修复 | `curatedTaskReferenceSelection` 在参考对象事实源归一化阶段脱敏标题、摘要和 tag |
+| 回归 | `CuratedTaskLauncherDialog.test.tsx` + `curatedTaskReferenceSelection.test.ts` 覆盖 UI 文本和 prompt block |
+| Playwright evidence | `.lime/qc/gui-evidence/skill-preflight-reference-sanitized-2026-05-10/summary.json`,verdict=`pass` |
+| 剩余 | 这不改变官方 qcloop Evidence Pack;整体 blocker 仍是 `real-qcloop-evidence` 未 pass |
+
+该项从 Agent UI 产品化 blocker 降级为已修复回归项。后续如果要继续治理参考对象列表,还应单独处理“任务 ID”类历史成果摘要是否默认展示给普通用户的问题。
+
+### 5.3 已解决:Skills 搜索本地命中时的全局无结果误导(2026-05-10 21:17)
+
+| 项 | 结果 |
+| --- | --- |
+| 原问题 | 搜索 `cover` 时右侧本地 Skills 有 `cover_generate` 等命中,但主区域仍显示“当前搜索下暂无结果模板 / Skill 分组” |
+| 修复 | `SkillsWorkspacePage` 在右侧有匹配结果时改用“右侧已有可继续的 Skill / 分类暂无匹配但已找到可用 Skill” |
+| 回归 | `SkillsWorkspacePage.test.tsx` 覆盖仅命中本地 Skill 的搜索 |
+| Playwright evidence | `.lime/qc/gui-evidence/skills-search-local-hit-2026-05-10/summary.json`,verdict=`pass` |
+| 剩余 | 不影响官方 qcloop Evidence Pack;整体 blocker 仍是 `real-qcloop-evidence` 未 pass |
+
+该项从产品 UI/UX blocker 降级为已修复回归项。
+
+### 5.4 已解决:高级设置 `Plan` 英文 / 内部词暴露(2026-05-10 21:28)
+
+| 项 | 结果 |
+| --- | --- |
+| 原问题 | Agent UI 输入区展开高级设置后显示 `Plan`,普通用户难以理解其含义 |
+| 修复 | `InputbarExecutionStrategySelect` 统一改为 `计划执行`,`aria-label` / `title` 改为 `开启计划执行` / `关闭计划执行` |
+| 覆盖面 | 首页空态输入区与工作区输入区共用同一个执行策略开关 |
+| 回归 | `Inputbar/index.test.tsx`、`EmptyStateComposerPanel.test.tsx`、`EmptyState.test.tsx` 覆盖用户可见中文标签和旧 `Plan` 文案不再出现 |
+| Playwright evidence | `.lime/qc/gui-evidence/advanced-settings-plan-label-cn-2026-05-10/summary.json`,verdict=`pass` |
+| 剩余 | 不改变官方 qcloop Evidence Pack;整体 blocker 仍是 `real-qcloop-evidence` 未 pass |
+
+该项从产品 UI/UX polish 降级为已修复回归项。
+
+### 5.5 已复核:Design Canvas 工程保存状态当前可观察(2026-05-10 21:35)
+
+| 项 | 结果 |
+| --- | --- |
+| 原风险 | fast-mini readonly P0 sidecar 在 `smoke:design-canvas` 的 `project-roundtrip-save-open` 阶段等待 `已保存图层设计工程` 超时 |
+| 复测 | 宿主直接执行 `npm run smoke:design-canvas -- --timeout-ms 180000` 通过 |
+| 覆盖 | DevBridge ready、设计画布打开、图层交互、工程目录保存 / 打开、平面图拆层、质量导出 manifest |
+| Evidence | `.lime/qc/gui-evidence/design-canvas-project-roundtrip-current-2026-05-10/summary.json`,verdict=`pass` |
+| 结论 | 当前 host 产品链路可用,旧 fast-mini sidecar 失败更像 qcloop worker / 并发 / 证据层问题;仍不能替代官方 full P0 qcloop pass |
+| 剩余 | 整体 blocker 仍是 `real-qcloop-evidence` 未 pass,且 active GUI owner 仍 stale |
+
+该项从“需定位的产品 UI/UX 风险”降级为当前已复核通过的 sidecar 风险;后续应在单一 GUI owner 下纳入新的 full P0 qcloop 证据,而不是单独宣称发布通过。
+
+### 5.6 已复核:Harness Replay / Eval 后端回归当前可运行(2026-05-10 21:39)
+
+| 项 | 结果 |
+| --- | --- |
+| 原风险 | full P0 中 `harness-replay-regression` 仍 pending,无法被 stale qcloop owner 调度验证 |
+| 复测 | 宿主直接执行 `npm run harness:eval:json` 与 `npm run harness:eval:trend:json`,均 exit `0` |
+| 当前 eval | `suiteCount=3`、`caseCount=2`、`readyCount=2`、`invalidCount=0`、`needsHumanReviewCount=0`、`currentObservabilityGapCaseCount=0` |
+| Trend 限制 | `sampleCount=1`,只能作为 trend seed,不能判断长期退化 |
+| 历史基线 | 未发现既有 `.lime/harness/history` / `reports` 样本;已把本轮结果记录为第一条真实 baseline,并生成 summary / trend / cleanup / dashboard 报告 |
+| Evidence | `.lime/qc/backend-evidence/harness-replay-regression-current-2026-05-10/summary.json`,verdict=`pass_with_trend_seed_limit` |
+| 剩余 | 仍需 full P0 qcloop verifier 同批次采信;当前 active GUI owner stale,不能直接启动新的 full GUI P0 |
+
+该项从“pending 未调度”降级为 direct host backend sidecar pass;由于 trend 样本不足和 qcloop 未采信,仍不能关闭 `real-qcloop-evidence` blocker。
+
+### 5.7 已复核:Approval / Sandbox live runtime transcript 当前可运行(2026-05-10 22:15)
+
+| 项 | 结果 |
+| --- | --- |
+| 原风险 | `tool-approval-sandbox-boundary` 只有 deterministic smoke 或 qcloop worker 自报 PASS,缺少真实 runtime permission request / decision transcript |
+| 初始失败 | live runtime 请求没有带 provider/model preference,导致 submit turn 没有形成权限确认 transcript;UI 侧留有 `agent-runtime-create-session-fail-2026-05-10.png` |
+| 修复 | `scripts/agent-runtime-approval-sandbox-smoke.mjs` 自动解析本地 enabled provider/model,并支持显式 `--provider-preference` / `--model-preference` |
+| 复测 | `node --check scripts/agent-runtime-approval-sandbox-smoke.mjs`、`npx vitest run scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts`、`npm run smoke:agent-runtime-approval-sandbox -- --timeout-ms 120000 --output .lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/runtime-approval-sandbox-smoke.fixed.json`、`npm run smoke:agent-runtime-tool-surface`、`npm run agent-qc:check` 均通过 |
+| Live assertions | `devBridgeHealthy`、`permissionRequestCreatedBeforeModel`、`deniedDecisionClearsPendingRequest`、`resolvedDecisionClearsPendingRequest`、`approvalPolicySubmitted`、`sandboxPolicySubmitted` 均为 `true` |
+| Evidence | `.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json`,verdict=`pass` |
+| 剩余 | 仍需 full P0 qcloop verifier 在同批次采信;当前 active GUI owner `1778405842243079000` 仍 stale,不能新开 full GUI P0 |
+
+该项从“缺 live runtime transcript”降级为 direct-host backend sidecar pass;它不改变官方 `.lime/qc/agent-qc-evidence.json` 仍为 fail,也不关闭 `real-qcloop-evidence` 和 `local-verify-gate` 两个 completion audit 缺口。
+
+### 5.8 当前门禁刷新(2026-05-10 22:20)
+
+| 门禁 | 当前值 | 结论 |
+| --- | --- | --- |
+| completion audit | `16/18`,`status=incomplete` | 缺 `real-qcloop-evidence` 与 `local-verify-gate` |
+| GUI owner | `blocked`,owner `1778405842243079000` | `browser-runtime-site-adapter` stale 约 `16482s`,3 个 GUI P0 仍未终态 |
+| qcloop status | `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 为 `verdict=stale` | 4 success / 1 running / 3 pending / 1 stale |
+| local verify | `verify:local` 当前 fail in `typecheck` | `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx(129,51)` 的 `number` / `Timeout` 类型不匹配;本轮为避免 settings-v2 高冲突未修改 |
+
+因此当前允许继续补 sidecar / 文档 / 低冲突后端证据;不允许启动新的 full GUI P0、处理中断 PID `69738`、改 qcloop DB,或把任何 sidecar pass 写成 release pass。
+
+### 5.9 当前门禁刷新(2026-05-10 22:45)
+
+| 门禁 | 当前值 | 结论 |
+| --- | --- | --- |
+| completion audit | `16/18`,`status=incomplete` | 缺 `real-qcloop-evidence` 与 `local-verify-gate` |
+| GUI owner | `blocked`,owner `1778405842243079000` | `browser-runtime-site-adapter` stale 约 `17692s`,3 个 GUI P0 仍未终态 |
+| qcloop status | `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 为 `verdict=stale` | 4 success / 1 running / 3 pending / 1 stale |
+| DB lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-10T22:56:05+08:00` | stale worker 仍在续约,不能新开 full GUI P0 |
+| local verify | `verify:local` fail in `npm test / vitest-smart batch 39/54` | `src/components/agent/chat/hooks/agentStreamSubmissionLifecycle.test.ts:98` 期望缺少 `turnId`,但实现返回 `turnId`;该文件属于当前活动工作树修改,本轮未改 |
+
+当前仍只允许只读刷新、sidecar 记录和 docs/tests runbook 同步;不允许启动新的 full GUI P0、处理中断 PID `69738`、修改 qcloop DB、覆盖官方 Evidence Pack、或执行 git commit / push / tag / release。
+
+### 5.10 当前门禁刷新(2026-05-10 23:40)
+
+| 门禁 | 当前值 | 结论 |
+| --- | --- | --- |
+| completion audit | `16/18`,`status=incomplete` | 当前 audit 仍读取旧 `verify-local-current.json`,缺 `real-qcloop-evidence` 与 `local-verify-gate` |
+| GUI owner | `blocked`,owner `1778405842243079000` | `browser-runtime-site-adapter` stale 约 `20265s`,仍不能新开 full GUI P0 |
+| qcloop status | `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 为 `verdict=stale` | 4 success / 1 running / 3 pending / 1 stale |
+| local verify | 新一轮 `verify:local` 仍在运行 | 已过前端 / contracts / Rust 主库 / GUI smoke 前半段,当前卡在 `smoke:agent-service-skill-entry` 的 Rust 定向测试编译;未判 pass/fail |
+| running evidence | `.lime/qc/verify-local-2026-05-10-2340-running.md` | 只读记录当前进程与日志,不覆盖最终 gate |
+
+当前仍只允许只读刷新、sidecar 记录和必要文档同步;不允许启动新的 full GUI P0、处理中断 PID `69738`、修改 qcloop DB、覆盖官方 Evidence Pack、或执行 git commit / push / tag / release。
+
+### 5.11 当前门禁刷新(2026-05-10 23:50)
+
+| 门禁 | 当前值 | 结论 |
+| --- | --- | --- |
+| local verify | 仍在运行 | 第一条 Skill Forge Rust 定向测试已通过,第二条 `registered_skill_becomes_ready_for_manual_enable_binding_candidate` 正等待 Cargo artifact lock |
+| lock context | 并发 Cargo 工作仍活跃 | 观察到 workspace locked test / cargo check / GUI smoke cargo run 等进程;本轮不终止任何进程 |
+| evidence | `.lime/qc/verify-local-2026-05-10-2350-cargo-lock.md` | 只读记录,不覆盖最终 `verify-local-current.json` |
+
+该状态不能关闭 `local-verify-gate`,但也不再等同于 22:39 的 Vitest 失败;最终口径以当前 wrapper 自然结束后写出的 `.lime/qc/verify-local-current.json` 为准。
+
+### 5.12 当前门禁刷新(2026-05-10 23:56)
+
+| 门禁 | 当前值 | 结论 |
+| --- | --- | --- |
+| completion audit | `16/18`,`status=incomplete` | 缺 `real-qcloop-evidence` 与 `local-verify-gate` |
+| local verify | exit `124` | 失败在 `verify:gui-smoke / smoke:agent-service-skill-entry`,第二条 Skill Forge Rust 定向测试等待 lock 后编译但超过 `1830000ms` 超时 |
+| 已通过局部 | app-version / lint / typecheck / frontend tests / contracts / Rust 主库 / workspace-ready / browser-runtime / site-adapters / Skill Forge frontend / 第一条 Rust 定向测试 | 说明 22:39 的 Vitest 失败已不是当前 active run 的失败点 |
+| qcloop status | `verdict=stale` | 4 success / 1 running / 3 pending / 1 stale |
+| GUI owner | `blocked` | stale owner `1778405842243079000`,约 `20878s` |
+| evidence | `.lime/qc/verify-local-2026-05-10-2355-gui-smoke-timeout.md`、`.lime/qc/completion-audit-2026-05-10-2356.json` | 当前仍不可发布 |
+
+`local-verify-gate` 仍不能关闭;下一刀应在不抢 qcloop GUI owner 的前提下,等并发 Cargo 工作结束后重跑 `npm run smoke:agent-service-skill-entry` 或 `npm run verify:gui-smoke -- --reuse-running`,而不是启动新的 full P0。
+
+### 2026-05-11 00:20 追加阻断:Claw streaming 首增量 / 停止按钮证据未闭环
+
+最新宿主 `verify:gui-smoke -- --reuse-running` 已进入更深层的 Claw streaming 验证。前置 GUI smoke 阶段通过,且 `smoke:claw-chat-ready-streaming` 已自动解析 `deepseek / deepseek-v4-flash`,但长 turn 提交后未能在 smoke 窗口内同时观察到首个流式文本和可见“停止”按钮。
+
+证据:
+
+- `.lime/qc/verify-gui-smoke-current.json`
+- `.lime/qc/verify-gui-smoke-reuse-2026-05-11-0006.log`
+- `.lime/qc/gui-evidence/claw-chat-ready-streaming/claw-chat-ready-streaming-summary.json`
+- `.lime/qc/smoke-claw-chat-ready-streaming-deepseek-chat-2026-05-11-0008.log`
+- `.lime/qc/gui-evidence/claw-chat-ready-streaming-deepseek-chat-2026-05-11/claw-chat-ready-streaming-deepseek-chat-summary.json`
+
+分类:
+
+| 项 | 当前结论 |
+| --- | --- |
+| provider/model 解析 | 已解除;默认 smoke 自动选中 `deepseek / deepseek-v4-flash` |
+| first streamed delta | 未闭环;默认 run 等待首个流式文本与停止按钮超时 |
+| evidence completeness | 已补脚本;失败路径后续会写 console/network/runtime/session/thread 证据 |
+| release gate | 仍阻断;不能把 GUI 主路径标记为可交付 |
+
+本轮只修复 smoke 脚本的证据采集和空快照健壮性,没有重跑完整 GUI smoke;原因是仍有其他 Lime / Cargo / qcloop 进程在运行,且用户明确要求不要推送、不要干预其他进程。关闭条件是:在无并发 GUI owner 阻断的环境下,重跑 `npm run verify:gui-smoke -- --reuse-running` 或等价 qcloop P0 item,取得 `claw-chat-ready-streaming` 的 pass summary,并包含 runtime transcript、interrupt scope、恢复 turn、console/network 摘要。
+
+### 2026-05-11 00:25 更新:Claw streaming 在显式 Sensenova provider 下通过
+
+00:20 记录的默认 deepseek 首增量失败之后,另一个已经运行中的 `verify:gui-smoke` 使用显式 `custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed / sensenova-6.7-flash-lite` 自然完成并通过。本轮只是观察和记录,没有启动、停止或重启该流程。
+
+证据:
+
+- `.lime/qc/verify-gui-smoke-current.json`
+- `.lime/qc/verify-gui-smoke-2026-05-11-0025-sensenova-pass.md`
+- `.lime/qc/verify-gui-smoke-reuse-sensenova-2026-05-11-0020.log`
+- `.lime/qc/gui-evidence/claw-chat-ready-streaming/claw-chat-ready-streaming-summary.json`
+
+当前分类调整:
+
+| 项 | 当前结论 |
+| --- | --- |
+| Claw streaming smoke | 在显式 Sensenova provider 下已通过,包含流式、停止、中断、恢复和 runtime transcript 证据 |
+| 默认 provider path | 仍有 default deepseek run 的失败历史,后续需要决定发布门禁默认 provider 是否也必须通过 |
+| `local-verify-gate` | 仍未关闭;完整 `npm run verify:local` sidecar 仍为 fail |
+| qcloop official P0 Evidence Pack | 仍未关闭;官方 `.lime/qc/agent-qc-evidence.json` 仍为 fail,且 stale qcloop GUI owner 未释放 |
+
+因此,本条从“当前 GUI smoke blocker”降级为“provider 口径 / full local verify 未闭环”缺口;它不能替代完整 `verify:local` 和真实 8/8 P0 qcloop Evidence Pack。
diff --git a/docs/tests/lime-agent-qc-evidence-contract.md b/docs/tests/lime-agent-qc-evidence-contract.md
new file mode 100644
index 000000000..e7b51a751
--- /dev/null
+++ b/docs/tests/lime-agent-qc-evidence-contract.md
@@ -0,0 +1,147 @@
+# Lime Agent QC 证据输出契约
+
+> 本文定义 Lime 样本产品在 qcloop 中运行 Agent QC 场景时,worker 必须输出的结构化证据格式。它补齐的是“命令跑过但 verifier 无法审查”的缺口:P0 发布门禁只接受可机器复核的 evidence,不接受散文式自评。
+
+## 1. 为什么需要契约
+
+最近的 P0 qcloop 批次暴露了三类典型问题:
+
+- worker 运行了正确命令,但只输出“通过”摘要,verifier 看不到 `evidenceRequired` 的逐项证据。
+- worker stdout 为空或只有日志路径,qcloop verifier 只能给出“输出格式错误”或“证据不足”。
+- deterministic smoke 通过后,被误读为已经覆盖 `gui-trace`、`runtime-transcript` 或 `release-artifact`。
+
+因此,qcloop worker 的最终 stdout 必须同时满足两层要求:
+
+1. 人能快速读懂 PASS / FAIL / BLOCKED 与关键缺口。
+2. 机器能稳定解析证据层、命令结果、artifact 路径、失败模式和发布 scope。
+
+## 2. 必须输出的 marker
+
+每个 Agent QC worker 最终 stdout 必须包含两行 marker:
+
+```text
+QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED
+QCLOOP_EVIDENCE_SUMMARY_JSON=
+```
+
+约束:
+
+- `QCLOOP_WORKER_RESULT=PASS` 只表示 worker 认为场景满足要求;仍需 verifier 独立审查。
+- `QCLOOP_WORKER_RESULT=BLOCKED` 用于 cwd、tmp、DevBridge、CLI 认证、sandbox、loopback 权限等环境阻断。
+- `QCLOOP_EVIDENCE_SUMMARY_JSON=` 必须是单行 JSON object;不要 Markdown、不要代码块、不要前后缀说明。
+- `agent-qc:export-evidence` 会解析该 marker;qcloop item 即使是 `success`,只要缺少该 JSON 或 JSON 无法解析,导出的 Evidence Pack 也必须转为 `fail`。
+- `agent-qc:release-summary --check` 会拒绝只有 `qcloop:*` 引用、缺少非 qcloop artifact / GUI owner / release scope evidenceRef 的 pass 场景。
+- JSON 中不得包含 token、API key、用户私密数据或未经脱敏的完整请求 / 响应正文。
+
+## 3. Evidence Summary JSON
+
+最小结构:
+
+```json
+{
+ "scenario_id": "workspace-ready-session-restore",
+ "result": "pass",
+ "commands": [
+ {
+ "command": "npm run verify:gui-smoke -- --reuse-running",
+ "exit_code": 0,
+ "duration_seconds": 142,
+ "stdout_artifact": ".lime/qc/gui-smoke.stdout.txt",
+ "stderr_artifact": ".lime/qc/gui-smoke.stderr.txt"
+ }
+ ],
+ "evidence_layers_covered": ["deterministic-smoke", "gui-trace"],
+ "evidence_required": [
+ {
+ "name": "DevBridge health",
+ "status": "pass",
+ "evidence": "GET http://127.0.0.1:3030/health returned ok",
+ "artifact_path": ".lime/qc/devbridge-health.json"
+ }
+ ],
+ "failure_modes": [
+ {
+ "name": "parallel GUI smoke interference",
+ "status": "excluded",
+ "evidence": "agent-qc:gui-owner-check ownerCount=0 before the run"
+ }
+ ],
+ "artifacts": [
+ {
+ "path": ".lime/qc/gui-trace/workspace-ready.trace.zip",
+ "kind": "gui-trace",
+ "redacted": true
+ }
+ ],
+ "blockers": [],
+ "gui_session_owner": "single-owner:job-1778412738097137000:item-workspace-ready-session-restore",
+ "release_scope": "source-tree-startup-smoke"
+}
+```
+
+字段口径:
+
+| 字段 | 必填 | 说明 |
+| --- | --- | --- |
+| `scenario_id` | 是 | 必须等于 manifest 中的 scenario id。 |
+| `result` | 是 | `pass` / `fail` / `blocked`。 |
+| `commands[]` | 是 | 记录实际执行命令、退出码、耗时和可审查日志路径。 |
+| `evidence_layers_covered[]` | 是 | 只能声明本次真实覆盖的层:`deterministic-smoke`、`gui-trace`、`runtime-transcript`、`release-artifact`。 |
+| `evidence_required[]` | 是 | 对 manifest 的 `evidenceRequired` 逐项给出 `pass` / `fail` / `blocked` / `missing`。 |
+| `failure_modes[]` | 是 | 对 manifest 的 `failureModes` 逐项给出 `covered` / `excluded` / `hit` / `unknown`。 |
+| `artifacts[]` | 是 | 记录截图、trace、transcript、release summary、console/network 摘要等证据路径。 |
+| `blockers[]` | 是 | 环境阻断或产品阻断;无阻断时为空数组。 |
+| `gui_session_owner` | GUI 场景必填 | 证明当前 GUI / DevBridge 会话没有被其他 qcloop worker 抢占。 |
+| `release_scope` | Release 场景必填 | 明确是 `source-tree-startup-smoke`、`installer-artifact-smoke` 还是其他 scope。 |
+
+## 4. Verifier 判定规则
+
+qcloop verifier 必须只输出单个 JSON object:
+
+```json
+{"pass": false, "feedback": "missing runtime transcript", "missingEvidence": ["runtime transcript"], "nextAction": "rerun with transcript artifact"}
+```
+
+判定规则:
+
+- stdout 缺少 `QCLOOP_EVIDENCE_SUMMARY_JSON`:`pass=false`。
+- `QCLOOP_WORKER_RESULT=PASS` 但 evidence JSON 有 `missing` / `unknown` / `blocked`:`pass=false`。
+- 命令退出码为 `0` 但 `evidenceRequired` 没逐项满足:`pass=false`。
+- 只覆盖 `deterministic-smoke`,却声明满足 `gui-trace` 或 `runtime-transcript`:`pass=false`。
+- `failure_modes` 没逐项说明覆盖、排除或命中:`pass=false`。
+- GUI 场景缺少 `GUI session owner / isolation statement`:`pass=false` 或 `blocked`,优先排查并发干扰。
+- Release 场景没有明确 `release_scope`:`pass=false`,不能把 source-tree smoke 伪装成 installer artifact 验证。
+
+## 5. 与现有入口的关系
+
+当前机器标准已经接入:
+
+```bash
+npm run agent-qc:check
+npm run agent-qc:qcloop-job -- --risk P0 --max-qc-rounds 1 --check
+npm run agent-qc:gui-owner-check -- --check
+```
+
+执行顺序:
+
+1. `agent-qc:check` 校验 manifest、P0 evidence layers、qcloop prompt 和 npm script 入口。
+2. `agent-qc:gui-owner-check -- --check` 确认没有 active GUI qcloop owner 后,才能启动新的 GUI P0 批次。
+3. `agent-qc:qcloop-job` 生成的 payload 会把 `evidence_layers` 带入 item,并把结构化证据输出契约追加到 worker prompt。
+4. qcloop worker 执行场景命令并输出 marker。
+5. qcloop verifier 审查 marker、JSON、`evidenceRequired`、`failureModes`、`issue_ledger` 和退出码。
+6. 只有 8/8 P0 item 全部 qcloop `success`,且每个 item 都包含可解析的 `QCLOOP_EVIDENCE_SUMMARY_JSON` 后,才能导出官方 `.lime/qc/agent-qc-evidence.json` 并进入 release summary gate。
+
+## 6. 失败沉淀
+
+真实失败不能只记录“本轮未过”,必须回写到长期资产:
+
+| 失败 | 回写资产 |
+| --- | --- |
+| 缺 `QCLOOP_EVIDENCE_SUMMARY_JSON` | 更新 worker prompt / payload generator / manifest 校验。 |
+| verifier 输出 Markdown 导致解析失败 | 收紧 verifier prompt,只允许单个 JSON object。 |
+| GUI owner 并发 | 保留 `agent-qc:gui-owner-check` sidecar,重跑前必须 ownerCount=0。 |
+| smoke 通过但缺 transcript | 新增 runtime transcript artifact 或 harness replay。 |
+| cleanup warning 被 exit=0 隐藏 | 在 evidence JSON 中单独记录 warning classified。 |
+| release scope 不清晰 | 明确 source-tree / installer artifact / CI artifact scope。 |
+
+关闭标准不变:P0 qcloop 8/8 success、8/8 structured evidence summary 可解析,官方 Evidence Pack `pass`,`agent-qc:release-summary --check` 通过,`agent-qc:audit` 为 `complete`。
diff --git a/docs/tests/lime-agent-qc-qcloop-operations.md b/docs/tests/lime-agent-qc-qcloop-operations.md
new file mode 100644
index 000000000..5f759a713
--- /dev/null
+++ b/docs/tests/lime-agent-qc-qcloop-operations.md
@@ -0,0 +1,469 @@
+# Lime Agent QC qcloop 运维手册
+
+> 本手册描述 Lime 作为样本产品运行 Agent QC qcloop 批次时的安全操作。目标是让测试 Agent 可以持续观察、导出证据和安排重跑,但不误杀正在运行的 worker,也不把 partial evidence 冒充成发布通过。
+
+## 1. 操作原则
+
+- **不抢占运行中的 worker**:qcloop item 仍为 `running` 时,不主动 kill / interrupt;只做只读状态查询、sidecar 导出和 blocker 记录。
+- **不覆盖官方 Evidence Pack**:只有 P0 批次覆盖全部 P0 scenario 且 `verdict.status=pass` 时,才写入 `.lime/qc/agent-qc-evidence.json`。
+- **sidecar 只用于排障**:`.lime/qc/agent-qc-evidence.*.json`、`.lime/qc/qcloop-status.*.json` 可以记录当前状态,但不能作为 release gate 证据。
+- **qcloop API 固定 IPv4 loopback**:本地调用使用 `http://127.0.0.1:8080`,避免 `localhost` 代理、IPv6 或浏览器环境差异。
+- **payload 必须显式 cwd**:qcloop 进程不一定从 Lime 仓库启动,生成 job 时必须传 `--cwd /Users/coso/Documents/dev/ai/aiclientproxy/lime`。
+
+## 2. 只读状态检查
+
+优先使用仓库脚本,而不是手写长 `curl | jq`:
+
+```bash
+npm run agent-qc:qcloop-status -- \
+ --base-url "http://127.0.0.1:8080" \
+ --job-id ""
+```
+
+输出 JSON sidecar:
+
+```bash
+npm run agent-qc:qcloop-status -- \
+ --base-url "http://127.0.0.1:8080" \
+ --job-id "" \
+ --format json \
+ --output "./.lime/qc/qcloop-status..json"
+```
+
+默认 `stale-minutes=30`。当 item 仍在 `running`,latest worker 运行超过阈值且 stdout/stderr 为空时,脚本标记为 `stale`,但不会停止进程。需要 CI 或排障脚本显式失败时,才加:
+
+```bash
+npm run agent-qc:qcloop-status -- \
+ --job-id "" \
+ --fail-on-stale
+```
+
+## 3. qcloop server 启动环境
+
+GUI / DevBridge / browser runtime 场景要求宿主机先有健康的 `127.0.0.1:3030` DevBridge listener,然后 qcloop worker 里的 Codex 也能访问该 listener。如果宿主 shell 自己也无法访问 `http://127.0.0.1:3030/health`,先恢复 DevBridge / headless Tauri,不要误判为 qcloop sandbox。只有宿主 shell 可访问而 worker 内失败时,才优先检查 qcloop serve 进程是否显式配置 Codex sandbox;这类失败表现为 worker 内 `fetch failed` / `Operation not permitted`。出现这种状态时,不要继续提交完整 P0 GUI 批次;先用只读 worker preflight 证明权限恢复。
+
+只读检查当前 qcloop serve 环境:
+
+```bash
+ps eww -p "" | tr ' ' '\n' | rg 'QCLOOP_CODEX|QCLOOP_EXECUTOR'
+```
+
+宿主 DevBridge health 检查:
+
+```bash
+curl --max-time 5 "http://127.0.0.1:3030/health"
+lsof -nP -iTCP:3030 -sTCP:LISTEN
+```
+
+推荐的本地 Agent QC 启动方式:
+
+```bash
+QCLOOP_CODEX_BIN="/Users/coso/.nvm/versions/node/v23.4.0/bin/codex" \
+QCLOOP_CODEX_SANDBOX=off \
+QCLOOP_CODEX_APPROVAL_POLICY=never \
+QCLOOP_CODEX_EXTRA_ARGS="--ephemeral -c 'mcp_servers.context7.command=\"\"' -c 'mcp_servers.playwright.command=\"\"'" \
+./qcloop serve --addr :8080
+```
+
+`QCLOOP_CODEX_EXTRA_ARGS="--ephemeral -c 'mcp_servers.context7.command=\"\"' -c 'mcp_servers.playwright.command=\"\"'"` 是当前更可靠的内层 MCP 降噪方式:它保留用户 provider / 认证配置,同时把已知用户级 MCP server 的 command 覆盖为空。`mcp_servers={}` 不足以覆盖嵌套 `mcp_servers.*` 配置;`--ignore-user-config` 也不能作为默认方案,因为它可能跳过本机 provider / 认证配置,让 worker 变成 401 或认证阻断。
+
+如果 `QCLOOP_CODEX_BIN` 指向坏 symlink(例如已删除的 Homebrew Cellar 路径),这不是产品失败,应归类为 qcloop worker 环境阻断。`agent-qc:export-evidence` 与 `agent-qc:qcloop-status` 会把 `QCLOOP_CODEX_BIN 不可用`、`QCLOOP_CODEX_EXTRA_ARGS 解析失败`、内层认证错误等归类为 `blocked`,避免把执行器配置错误写成 P0 产品缺陷。
+
+如使用 `QCLOOP_CODEX_BYPASS_SANDBOX=true`,必须确认外层环境已经隔离,因为它会让 Codex 跳过 approval 和 sandbox。当前 Lime 样本只记录该方案,不在仍有 qcloop job running 时重启 qcloop。
+
+修复后先提交一个只读 worker preflight job,目标只包含 cwd / tmp / DevBridge health,不运行产品 smoke。只有该 job 的 worker stdout 同时包含 `QCLOOP_PREFLIGHT_RESULT=PASS`、`devbridge-health PASS` 和 `QCLOOP_WORKER_RESULT=PASS`,才进入 P0 GUI / browser / release 场景重跑。
+
+如果不能重启现有 qcloop serve,可临时启动隔离 qcloop server,必须使用独立 DB 和不同端口,避免影响正在运行的 Web 面板和默认队列:
+
+```bash
+QCLOOP_CODEX_BIN="/Users/coso/Library/PhpWebStudy/env/node/bin/codex" \
+QCLOOP_CODEX_SANDBOX=off \
+QCLOOP_CODEX_APPROVAL_POLICY=never \
+QCLOOP_CODEX_CWD="/Users/coso/Documents/dev/ai/aiclientproxy/lime" \
+./qcloop --db "/Users/coso/Documents/dev/ai/aiclientproxy/lime/.lime/qc/qcloop-isolated-worker-preflight.db" \
+ serve --addr "127.0.0.1:18080" --workers 1
+```
+
+隔离 server 只用于 sidecar 诊断;除非重新跑完整 P0 并导出覆盖 8/8 P0 的官方 Evidence Pack,否则不能用 isolated partial pass 覆盖 `.lime/qc/agent-qc-evidence.json`。
+
+## 4. Worker preflight
+
+运行 qcloop P0 前,worker 应先证明自己在正确仓库、临时目录可写,并且在 GUI / DevBridge 场景中能访问本地 DevBridge:
+
+```bash
+npm run agent-qc:qcloop-preflight -- \
+ --expected-cwd "$(pwd)" \
+ --check
+
+# 需要 DevBridge / GUI / browser runtime 的场景再加:
+npm run agent-qc:qcloop-preflight -- \
+ --expected-cwd "$(pwd)" \
+ --require-devbridge \
+ --timeout-ms 15000 \
+ --check
+```
+
+如果 preflight 阻断,worker 必须在 stdout 输出 `QCLOOP_WORKER_RESULT=BLOCKED`,停止后续高成本命令,并把 cwd、health URL、失败 check、stdout/stderr 证据路径写入 stdout。这样 release gate 可以区分“产品失败”和“qcloop worker 环境没有本地 DevBridge 权限”;`agent-qc:export-evidence` 和 `agent-qc:qcloop-status` 会把这种显式 marker 归类为 Evidence Pack `blocked`,即使 qcloop item 因 verifier 多轮失败进入 `exhausted`。
+
+## 5. Sidecar evidence 导出
+
+qcloop 批次 running / blocked / failed 时,只导出 sidecar:
+
+```bash
+npm run agent-qc:export-evidence -- \
+ --base-url "http://127.0.0.1:8080" \
+ --job-id "" \
+ --output "./.lime/qc/agent-qc-evidence..json" \
+ --ref "local-qcloop-" \
+ --check
+```
+
+sidecar 用于回答:
+
+- 哪些 scenario 已经 `success`、`failed`、`exhausted`、`running`、`pending`。
+- verifier 是否拿到了 worker stdout / exit code / attempt 状态。
+- 当前 blocker 是产品缺陷、证据缺口、环境卡住,还是 qcloop 调度未完成。
+
+sidecar 不用于回答“能否发布”。发布只看官方 `.lime/qc/agent-qc-evidence.json`。
+
+## 6. Stale item 处理
+
+当 `agent-qc:qcloop-status` 标记 `stale`:
+
+1. 记录 job id、scenario id、worker started_at、stdout/stderr 长度和当前时间。
+2. 导出 `.lime/qc/qcloop-status..json` 和 sidecar Evidence Pack。
+3. 检查是否有已知长编译、GUI bridge 启动或外部运行时阻塞;不要立即归因于产品缺陷。
+4. 不主动 kill;如果必须中止,需要由任务 owner 明确确认。
+5. 当前 job 自然结束后,使用带 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}` 的新 payload 重跑失败或卡住场景。
+
+必要时只读查看 SQLite lease,确认是队列 / worker 调度卡住还是产品命令已经输出失败:
+
+```bash
+sqlite3 -readonly ".lime/qc/qcloop-isolated-worker-preflight.db" \
+ "SELECT id, status, lock_owner, lock_expires_at FROM batch_items WHERE batch_job_id='';"
+```
+
+只能读取,不要直接改 DB 状态;否则 qcloop 的 attempt / QC 证据链会失真。
+
+分类口径:
+
+| 状态 | 解释 | 下一步 |
+| --- | --- | --- |
+| `running` 且有 stdout/stderr | 仍可能在正常执行 | 继续观察,必要时导出 sidecar |
+| `running` 且长期无输出 | 疑似 worker 卡住或外部进程阻塞 | 记录 stale,不中断;等待或请求 owner 判断 |
+| `pending` | 队列尚未调度 | 不做产品结论,等待前序 item 结束 |
+| `exhausted` 且 stdout 包含 `QCLOOP_WORKER_RESULT=BLOCKED` | worker 明确证明环境 / 权限前置检查阻断 | 归类为 `blocked`,修 qcloop worker 环境后重跑,不写官方 pass |
+| `failed` 且 stderr 包含 `QCLOOP_CODEX_BIN 不可用` / 内层认证或 sandbox 配置错误 | qcloop worker 执行器环境阻断 | 归类为 `blocked`,修 CLI 路径 / 认证 / extra args 后新建隔离批次 |
+| `failed` 且 stdout 包含 `QCLOOP_WORKER_RESULT=PASS` | worker 自评通过,但 qcloop 进程或 verifier 未通过 | 归类为 `fail`,记录 `worker_self_report_pass_not_verified`,不要把自评 PASS 当成 release 证据 |
+| `exhausted` | worker 或 verifier 未满足 QC | 修产品缺陷或补 evidence,再新建重跑 job |
+| `success` | 当前 item 通过 qcloop verifier | 仍需等待全部 P0 success 才能写官方 evidence |
+
+## 7. 重跑策略
+
+重跑只针对终态失败或明确卡住后已释放的场景,避免与仍在 running 的批次抢队列。
+
+单场景重跑示例:
+
+```bash
+npm run agent-qc:qcloop-job -- \
+ --scenario "tool-approval-sandbox-boundary" \
+ --cwd "/Users/coso/Documents/dev/ai/aiclientproxy/lime" \
+ --base-url "http://127.0.0.1:8080" \
+ --output "./.lime/qc/qcloop-tool-approval-sandbox-rerun-payload.json" \
+ --check
+```
+
+全量 P0 重跑示例:
+
+```bash
+npm run agent-qc:qcloop-job -- \
+ --risk P0 \
+ --cwd "/Users/coso/Documents/dev/ai/aiclientproxy/lime" \
+ --base-url "http://127.0.0.1:8080" \
+ --output "./.lime/qc/qcloop-p0-rerun-payload.json" \
+ --check
+```
+
+提交前检查 payload:
+
+- `prompt_template` 明确要求只读执行,不修改源码、配置、文档、锁文件或 git 状态;Agent QC repair 不应该在 qcloop 内直接修代码。
+- P0 release evidence 批次默认使用 `--max-qc-rounds 1`,避免 qcloop generic repair prompt 把“补证据”误变成“改源码”;需要修复产品问题时另开人工确认的开发任务。
+- `prompt_template` 明确要求切到 Lime cwd 并打印 `pwd`。
+- `verifier_prompt_template` 包含 `{{stdout}}` 或 `{{output}}`。
+- `verifier_prompt_template` 包含 `{{attempt_status}}` 与 `{{exit_code}}`。
+- items 中 `scenario_id` 覆盖目标场景,不混入低优先级 P1/P2。
+
+## 8. 当前样本状态(2026-05-10)
+
+当前 qcloop v5 job:`1778398587521627000`(completed / fail)。
+
+宿主 DevBridge 已于 2026-05-10 16:59 后通过 `npm run tauri:dev:headless` 恢复,证据为 `.lime/qc/qcloop-devbridge-health-restored.json`。但随后创建的只读 qcloop worker preflight job `1778403715309891000` 仍为 `completed` / `blocked`:内层 worker cwd/tmp 通过,`devbridge-health` 对 `http://127.0.0.1:3030/health` 仍 `fetch failed`。因此当前主阻断已经收敛为 qcloop worker loopback / sandbox 权限,而不是宿主 DevBridge 未启动。
+
+隔离 qcloop server `127.0.0.1:18080` 使用独立 DB 与显式 Codex sandbox 配置后,worker preflight job `1778404260108641000` 已 `completed` / `success`,证明 worker 权限可以恢复。随后四个 P0 sidecar 通过:
+
+- `workspace-ready-session-restore`:job `1778404364137496000`,`smoke:workspace-ready` + `verify:gui-smoke -- --reuse-running` 通过。
+- `browser-runtime-site-adapter`:job `1778404601640847000`,`smoke:browser-runtime` + `smoke:site-adapters` 通过。
+- `skill-forge-register-bind-enable`:job `1778404743505029000`,`test:contracts` + `smoke:agent-service-skill-entry` 通过。
+- `release-package-startup-smoke`:job `1778405385701480000`,worker preflight + `verify:app-version` + `verify:gui-smoke -- --reuse-running` 通过;scope 明确为 `source-tree-startup-smoke`。
+
+`release-package-startup-smoke` isolated v1 job `1778404882904047000` 仍保留为历史 blocked 证据,因为执行时宿主 DevBridge 再次断开;v2 pass 仍不能单独覆盖官方 Evidence Pack。
+
+已启动 isolated full P0 v1 job `1778405842243079000` 作为同批次 8/8 P0 尝试。当前只读 sidecar `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 与 stale check `.lime/qc/qcloop-status.isolated-p0-full-v1-stale-check.json` 显示:
+
+- `command-bridge-contract`:success。
+- `claw-chat-ready-streaming`:success,但 stdout 明确 scope 为 GUI smoke / DevBridge / runtime surface,不包含 live long-turn interrupt transcript。
+- `tool-approval-sandbox-boundary`:success,但 stdout 明确 scope 为 deterministic smoke,不包含 live runtime transcript。
+- `skill-forge-register-bind-enable`:success。
+- `browser-runtime-site-adapter`:running,当前 attempt 无 stdout/stderr;`--stale-minutes 1` 只读检查已标记 stale;SQLite 只读检查显示 lock owner 为 `qcloop-worker-1`,lease 已被心跳延长到 `2026-05-10T19:00:05+08:00`。
+- `workspace-ready-session-restore`、`harness-replay-regression`、`release-package-startup-smoke`:pending。
+
+该批次仍未进入终态;按本手册的 stale item 规则,只能继续只读观察,不 kill、不 pause、不覆盖官方 Evidence Pack。当前卡点更像 worker / provider 调度无输出,而不是产品 smoke 已失败:只读 `ps` 可见 `codex exec` 进程仍在执行该 item,但没有 `npm run smoke:browser-runtime` / `smoke:site-adapters` 子命令输出。必须等待当前 worker 自然结束,或由 owner 明确授权后再处理。
+
+为验证是否是内层用户级 MCP server 启动导致 worker 长时间无业务 stdout,后续追加了 no-MCP-attempt 隔离批次:
+
+- `1778410893606889000`:端口 `127.0.0.1:18081`,因 `QCLOOP_CODEX_BIN` 指向坏 Homebrew symlink 立即失败;该结果归类为 worker CLI 环境阻断,不是产品 P0 失败。
+- `1778410956075020000`:端口 `127.0.0.1:18082`,使用正确 Codex bin 与 `QCLOOP_CODEX_EXTRA_ARGS='--ephemeral -c mcp_servers={}'`;实测仍可能存在用户级 MCP 子进程,因此该批次只证明 CLI 路径和认证已恢复,不证明 MCP 已完全关闭;`command-bridge-contract` 已在 repair attempt 后 success;当前 `claw-chat-ready-streaming` running 且 9 分钟无 stdout/stderr,被 sidecar 标记 stale;后续 6 个 P0 pending。
+- `1778412160003934000`:端口 `127.0.0.1:18083`,使用正确 Codex bin 与 `mcp_servers.context7.command=""` / `mcp_servers.playwright.command=""` 覆盖;初始 `ps` 未观察到用户级 MCP 子进程,`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending。已另行生成只读待用 payload `.lime/qc/qcloop-readonly-p0-v1-payload.json` / `.lime/qc/qcloop-fastmini-readonly-p0-v2-payload.json`,`max_qc_rounds=1`,暂不抢跑。
+- `1778412499745993000`:端口 `127.0.0.1:18084`,在 MCP command 覆盖基础上追加 `--ignore-rules`,避免内层 worker 读取项目规则 / skill 后进入非必要工具链;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending。已另行生成只读待用 payload `.lime/qc/qcloop-readonly-p0-v1-payload.json` / `.lime/qc/qcloop-fastmini-readonly-p0-v2-payload.json`,`max_qc_rounds=1`,暂不抢跑。
+- `1778412738097137000`:端口 `127.0.0.1:18085`,使用 `gpt-5.4-mini`、low reasoning、MCP command 覆盖、`--ignore-rules`、只读 prompt 与 `max_qc_rounds=1`,目标是在 qcloop 5 分钟 executor timeout 内完成 worker;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending。
+
+这说明 qcloop worker 侧还需要同时治理四类问题:CLI 路径 / 认证环境、用户级 MCP 启动策略或隔离配置、worker stdout 可审查性,以及 no-change 场景下 verifier 不能把“没有变更面”误判为“缺少变更面证据”。四者都是测试基础设施问题;在官方 Evidence Pack 通过前,不能把 isolated partial pass 或宿主直跑命令当作 release 证据。
+
+这类真实卡点已经回写到机器标准:
+
+- `docs/test/agent-qc-scenarios.manifest.json` 的 `qcloop-batch-verifier-repair` 要求记录 stale sidecar、worker stdout/stderr 长度摘要、worker CLI environment sidecar 和 inner Codex MCP startup policy,并把 `running no-output stale`、`worker lease heartbeat without stdout`、`worker codex binary unavailable`、`worker auth or sandbox misconfiguration`、`worker user-config MCP startup no-output hang` 作为失败模式。
+- `browser-runtime-site-adapter` 要求把 cleanup warning 单独分类,避免 `exit=0` 把清理问题完全隐藏。
+- `scripts/lib/agent-qc-qcloop-status-core.mjs` 现在会输出 item 级 `staleSeconds` 和 worker `durationSeconds`;当前 full P0 stale sidecar 显示 `browser-runtime-site-adapter staleSeconds=5108 stdoutLength=0 stderrLength=0`,可以直接用于判断“长时间无输出”而不是只看布尔 stale。
+- `scripts/lib/agent-qc-evidence-core.mjs` 现在会把 `QCLOOP_CODEX_BIN 不可用`、extra args 解析失败、内层认证 / sandbox 配置错误归类为 `blocked`,并在 blocker 摘要中使用脱敏的环境阻断说明。
+- `docs/test/agent-qc-scenarios.manifest.json` 的 qcloop worker prompt 要求最终 stdout 逐项列出 `evidence_required` 是否满足,并说明每个 `failure_modes` 如何被覆盖、排除或命中;已生成待用 payload `.lime/qc/qcloop-isolated-nomcp-p0-v3-after-evidence-prompt-payload.json`,但在 v2 仍 running 时不自动抢跑。
+- `scripts/lib/agent-qc-qcloop-job-core.mjs` 会把 manifest 的 `evidenceLayers` 写入 qcloop item 的 `evidence_layers`,worker 必须声明本次覆盖层级;只跑到 `deterministic-smoke` 时不得伪装成 `gui-trace`、`runtime-transcript` 或 `release-artifact`。
+
+2026-05-10 19:58 只读刷新显示,当前不应继续开新 full P0 抢资源:
+
+| 批次 | 只读状态 | 处理 |
+| --- | --- | --- |
+| `1778405842243079000` isolated full P0 v1 | 4 success / 1 running / 3 pending,`browser-runtime-site-adapter` stale 约 8002 秒 | 继续观察;记录 stale sidecar,不中断 |
+| `1778410956075020000` no-MCP P0 v2 | 2 success / 1 failed / 1 running / 4 pending | `tool-approval-sandbox-boundary` 已 failed;该批次不能证明 `mcp_servers={}` 足以关闭嵌套 MCP |
+| `1778412160003934000` MCP-disabled P0 v1 | 1 failed / 1 running / 6 pending,`claw-chat-ready-streaming` stale 约 674 秒 | 失败来自旧 no-change verifier;后续 payload 已修正,当前批次不抢占 |
+| `1778412499745993000` fast P0 v1 | 1 success / 1 running / 6 pending | `--ignore-rules` 后进入 `claw-chat-ready-streaming`;继续观察 |
+| `1778412738097137000` fast-mini readonly P0 v1 | failed,8 exhausted | sidecar evidence 为 6 fail / 2 blocked;verifier 正确拒绝缺 deep evidence 的浅层 smoke 摘要,并额外暴露 `workspace-ready-session-restore` 中 `smoke:design-canvas` 保存成功状态断言失败;`release-package-startup-smoke` 因 GUI smoke 未自然收口被 blocked;用于修正证据分层和产品回归,不覆盖官方 Evidence Pack |
+
+当 qcloop worker 能快速跑完确定性 smoke 但 verifier 仍拒绝时,不要把拒绝当成坏事。它说明当前输出还缺 live transcript、trace、console/network、cleanup、approval/sandbox 或 SkillTool gate 证据;应拆分并补证据,而不是降低 P0 release gate。
+
+GUI P0 需要单一 owner。只要 `.lime/qc/qcloop-status.*.json` 里仍有 GUI / DevBridge 场景 `running` 或 `stale`,不要再启动新的 full P0 GUI 批次;否则多个 worker 复用同一个 `127.0.0.1:1420` / `127.0.0.1:3030` 会话,可能互相导航或抢占按钮点击,导致 `verify:gui-smoke` 失败点失真。新的 payload 已要求 GUI 场景输出 `GUI session owner / isolation statement`,并把 `parallel GUI smoke interference` 作为 failure mode。
+
+启动新的 GUI P0 前先跑:
+
+```bash
+npm run agent-qc:gui-owner-check -- --check
+```
+
+该命令只读扫描 `.lime/qc/qcloop-status.*.json`。如果发现 active GUI owner,会非 0 退出,并列出仍在 running / stale 的 qcloop job 和场景;此时只能继续观察或等待 owner 明确处理,不能再开新的 full P0 GUI 批次。
+
+使用 `--format json` 时,报告还会在 stale owner 场景下输出 `ownerIntervention`:
+
+```json
+{
+ "ownerIntervention": {
+ "status": "requires_owner_confirmation",
+ "requiredConfirmationText": "确认处理 stale GUI owner ,可以终止 PID 并记录 sidecar。",
+ "prohibitedUntilConfirmed": [
+ "kill / pause / interrupt stale worker",
+ "modify qcloop SQLite DB",
+ "start another full GUI P0 batch",
+ "overwrite .lime/qc/agent-qc-evidence.json",
+ "git commit / push / tag / release"
+ ]
+ }
+}
+```
+
+该字段只表示“需要 owner 决策”,不是授权;真实 PID 仍必须来自最新 sidecar / DB / `ps` 取证。
+
+`workspace-ready-session-restore` 的细化日志提取见 `.lime/qc/design-canvas-failure-fastmini-workspace-extract.json`,当前失败点是 `smoke:design-canvas` 在 `project-roundtrip-save-open` 阶段等待 `已保存图层设计工程` 文本超时。后续重跑前,应先确认设计画布导出按钮点击后是否能形成可观察的保存中 / 保存成功状态。
+
+待 running/stale 批次自然结束后,可优先使用 `.lime/qc/qcloop-p0-evidence-layers-v1-payload.json` 作为下一轮只读 P0 payload。该 payload 只生成不提交,8 个 P0 item 均带 `evidence_layers`,`workspace-ready-session-restore` / `release-package-startup-smoke` 已显式要求 design canvas 工程 roundtrip 和 GUI smoke 自然收口,并保持 `max_qc_rounds=1`,避免 repair prompt 在发布证据批次中修改工作区。
+
+当前 CLI gate 复核:
+
+```bash
+npm run agent-qc:qcloop-status -- \
+ --base-url "http://127.0.0.1:18080" \
+ --job-id "1778405842243079000" \
+ --stale-minutes 1 \
+ --check-terminal \
+ --fail-on-stale
+```
+
+结果为 `QCLOOP_STATUS_CHECK_EXIT_STATUS=2`,摘要显示 `duration=67m stdout=0 stderr=0 stale=worker 运行 67 分钟且 stdout/stderr 为空`。这证明 stale gate 会以非 0 阻断,不能被误当作通过。
+
+宿主 shell 直接执行同一产品命令的结果:
+
+```bash
+npm run agent-qc:qcloop-preflight -- --expected-cwd "$(pwd)" --require-devbridge --timeout-ms 30000 --check
+npm run smoke:browser-runtime
+npm run smoke:site-adapters
+```
+
+结果为退出码 `0`:DevBridge health pass,browser runtime smoke 创建 session 并通过,site adapter catalog/list/recommend/search 通过。`smoke:browser-runtime` 同时输出一个非阻断 cleanup warning:`close_cdp_session` 未找到刚创建的 session。该 warning 应进入后续 evidence,但当前 stale 卡点本身仍更像 qcloop worker / provider 没有开始执行命令。
+
+另一个 pending P0 `harness-replay-regression` 的宿主直接检查也已通过:
+
+```bash
+npm run harness:eval
+npm run harness:eval:trend
+```
+
+结果为退出码 `0`:`harness:eval` 显示 2/2 ready、invalid=0、current observability gap=0、degraded gap=1;`harness:eval:trend` 显示 samples=1,仅能形成 trend seed。该结果证明宿主命令可跑,但不能替代 qcloop verifier。
+
+剩余 pending 的 workspace / release source-tree smoke 也已直接通过:
+
+```bash
+npm run smoke:workspace-ready
+npm run verify:app-version
+npm run verify:gui-smoke -- --reuse-running
+```
+
+结果为退出码 `0`:workspace ready、版本一致性、复用 headless Tauri 的 GUI smoke 全部通过;GUI smoke 覆盖 workspace ready、browser runtime、site adapters、service skill entry、runtime tool surface、runtime surface page、knowledge GUI、design canvas。该结果只能证明宿主 source-tree 可用,不能替代 qcloop verifier 或 installer artifact 验证。
+
+已知状态:
+
+- `claw-chat-ready-streaming`:qcloop item 为 `exhausted`,但 worker stdout 明确输出 `QCLOOP_WORKER_RESULT=BLOCKED`;sidecar Evidence Pack 归类为 `blocked`。同日 GUI deep flow 仍有 stop / interrupt 后长 turn completed 落盘的产品 blocker,需在 worker 环境连通后继续验证。
+- `tool-approval-sandbox-boundary`:当前 `failed`;worker stdout 自报 `QCLOOP_WORKER_RESULT=PASS`,确定性 smoke 通过,但 qcloop exit `-1` / verifier 仍判缺 live runtime transcript,不能作为 P0 pass。
+- `skill-forge-register-bind-enable`:当前 `exhausted`;多轮 worker 均停在 DevBridge preflight blocked,sidecar 归类为 `blocked`。
+- `browser-runtime-site-adapter`:当前 `exhausted`;多轮 worker 均停在 DevBridge preflight blocked,sidecar 归类为 `blocked`。
+- `workspace-ready-session-restore`:当前 `exhausted`;多轮 worker 未提供 workspace-ready / GUI smoke / DevBridge 可审查证据,sidecar 归类为 `blocked`。
+- `release-package-startup-smoke`:默认 v5 当前 `exhausted`,sidecar 归类为 `blocked`;隔离 v2 已通过 source-tree startup smoke,但仍需进入同一全量 P0 批次并覆盖官方 Evidence Pack。
+- v4 job `1778392677659787000` 已 `completed` / `fail`,6/6 exhausted;只保留为排障 sidecar,不再作为当前运行批次。
+
+当前关闭条件仍是:8/8 P0 scenario 全部 qcloop `success`,官方 `.lime/qc/agent-qc-evidence.json` 为 `pass`,`agent-qc:release-summary --check` 与 `agent-qc:audit` 通过。
+
+## 9. 2026-05-10 20:07 状态刷新
+
+按用户要求,本轮没有 push、没有 commit、没有 kill / pause / interrupt 任何仍在运行的 qcloop 或 Codex worker。只读刷新结果如下:
+
+| 批次 | 当前状态 | 结论 |
+| --- | --- | --- |
+| `1778405842243079000` isolated full P0 v1 | `stale`,4 success / 1 running / 3 pending,`browser-runtime-site-adapter` stale 约 8522 秒 | 仍是唯一 active GUI owner;继续观察,不启动新的 GUI P0。 |
+| `1778410956075020000` no-MCP P0 v2 | `fail`,2 success / 6 failed | 终态失败;部分失败来自 worker / verifier 证据不足或 verifier 输出格式错误。 |
+| `1778412160003934000` MCP-disabled P0 v1 | `fail`,8 failed | 终态失败;不能作为 MCP-disabled 方案有效证据。 |
+| `1778412499745993000` fast P0 v1 | `fail`,1 success / 7 failed | 终态失败;`--ignore-rules` 降噪不足以形成 P0 Evidence Pack。 |
+| `1778412738097137000` fast-mini readonly P0 v1 | `fail`,8 exhausted | verifier 正确拒绝浅层 smoke 摘要;同时暴露 design canvas / evidence artifact 缺口。 |
+
+`agent-qc:gui-owner-check` 输出 `ownerCount=1`、`verdict.status=blocked`,原因是 isolated full P0 v1 仍持有 active GUI owner。因此当前不允许启动新的 full P0 GUI 批次;下一步只能继续只读观察,或在 owner 明确处理后再重跑。
+
+本轮新增结构化证据契约,原因是多个终态失败并非单纯产品失败,而是 worker stdout 无法被 verifier 稳定审查。后续新 payload 必须让 worker 输出:
+
+```text
+QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED
+QCLOOP_EVIDENCE_SUMMARY_JSON=
+```
+
+其中 JSON 必须逐项覆盖 `evidenceRequired`、`failureModes`、`evidenceLayers`、命令退出码、artifact 路径、GUI owner 和 release scope。详见 `docs/tests/lime-agent-qc-evidence-contract.md`。
+
+2026-05-10 20:18 追加 sidecar 导出复核:使用新版 `agent-qc:export-evidence` 对 `1778405842243079000` 导出 `.lime/qc/agent-qc-evidence.isolated-p0-full-v1-current.json`,结果为 `fail`。除 running / pending 外,前 4 个 qcloop `success` item 也被降级为 `fail`,原因是 stdout 缺少 `QCLOOP_EVIDENCE_SUMMARY_JSON`,不能作为 release pass。这是预期收紧:旧批次的浅层 success 只能作为排障参考,不能覆盖新的结构化证据门禁。
+
+2026-05-10 20:20 追加 completion audit 复核:`agent-qc:audit` 已新增 `structured-evidence-contract` 检查项,要求文档、qcloop worker prompt、verifier prompt 与 exporter 同时强制 `QCLOOP_EVIDENCE_SUMMARY_JSON`。当前该项为 pass;整体 audit 变为 15/16,唯一缺口仍是官方 P0 qcloop Evidence Pack 未 pass。
+
+2026-05-10 20:21 追加 release gate 收紧:`agent-qc:release-summary --check` 已拒绝只有 `qcloop:*` 引用、缺少非 qcloop artifact / GUI owner / release scope evidenceRef 的 pass 场景。这样即使旧 exporter 或手工 evidence 把场景写成 pass,也不能只凭 qcloop item id 进入发布。
+
+2026-05-10 20:23 追加 completion audit gate 同步:`structured-evidence-contract` 现在同时检查 release summary gate 是否拒绝弱 evidenceRefs。当前证据为 `doc=true worker=true verifier=true strictJson=true exporter=true release=true`,整体仍是 15/16,唯一缺口仍是 `real-qcloop-evidence`。
+
+2026-05-10 20:25 只读进程核查:`1778405842243079000` 仍有真实内层 `codex exec` 进程存在,PID `69738`,命令仍停在 `browser-runtime-site-adapter` worker prompt,qcloop sidecar 显示该 item 已运行约 9543 秒且 stdout/stderr 仍为 0。因此当前 stale owner 不是单纯旧 sidecar 噪声;仍不能启动新的 GUI P0,也不能在未获 owner 明确确认时 kill / pause / interrupt。
+
+同轮增强 `agent-qc:gui-owner-check` 输出:报告现在包含 `staleOwnerCount` 与 `oldestStaleSeconds`,当前 `.lime/qc/gui-owner-current.json` 为 `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds=9543`,nextAction 明确为继续只读观察或由 owner 确认后处理。
+
+2026-05-10 20:26 追加 stale owner intervention artifact:新增 `docs/tests/lime-agent-qc-stale-owner-intervention.md` 与 `.lime/qc/stale-owner-intervention-request.md`。前者固化只读取证、禁止操作、owner 确认格式和确认后的最小闭环;后者记录当前 job `1778405842243079000` / PID `69738` 的干预请求,但未执行任何中断动作。`agent-qc:audit` 已新增 `stale-owner-intervention-protocol`,当前整体为 16/17,唯一缺口仍是 `real-qcloop-evidence`。
+
+2026-05-10 20:28 追加 post-stale rerun runbook:`docs/tests/lime-agent-qc-stale-owner-intervention.md` 新增 Owner 清空后的 P0 重跑步骤,并生成 `.lime/qc/post-stale-owner-rerun-plan.md`。已只生成并校验 `.lime/qc/qcloop-p0-structured-evidence-v1-payload.json`,结果 `valid=true`、`itemCount=8`、`maxQcRounds=1`,且 worker / verifier 均包含结构化 evidence 与严格 JSON 约束;未提交 qcloop job。
+
+2026-05-10 20:32 追加 qcloop executor timeout 核查:只读检查 `/Users/coso/Documents/dev/ai/limecloud/qcloop/internal/executor/codex.go` 显示 `NewCodexExecutor` 期望 `timeout: 5 * time.Minute`,`runCLICommand` 使用 `exec.CommandContext`。但当前 live PID `69738` 已运行约 165 分钟且 stdout/stderr 仍为 0,说明当前运行中的 qcloop serve / 子进程状态不能简单按源码 timeout 预期判断;可能是旧二进制、孤儿 `codex exec`、或 qcloop runner 未能回收子进程。该情况仍按 stale GUI owner 处理:未获 owner 明确确认前不 kill、不改 DB、不启动新 GUI P0;后续重跑前应确认 qcloop serve 使用带 executor timeout 的当前构建,并保留 `max_qc_rounds=1` 与 structured evidence gate。
+
+2026-05-10 20:32 追加 payload retry 控制:`agent-qc:qcloop-job` 已支持 `--max-executor-retries` 并把 `max_executor_retries` 写入 qcloop payload,校验范围为 0-5。post-stale P0 release evidence payload 已重新生成并校验:`itemCount=8`、`max_qc_rounds=1`、`max_executor_retries=0`。这样发布证据批次不会自动重试内层 CLI,避免在当前 stale owner 问题未解决时重复制造孤儿 worker;如需基础设施重试,必须由 owner 在隔离环境中明确改为 1。
+
+2026-05-10 20:34 追加 qcloop timeout issue sidecar:已生成 `.lime/qc/qcloop-executor-timeout-issue.md`,把当前 PID `69738` 长时间超过 qcloop 源码 `5 * time.Minute` Codex executor timeout 的现象、可能原因和 qcloop-side 修复建议集中记录。该文件是排障 sidecar,不代表已经修改 qcloop 或处理进程。
+
+2026-05-10 20:35 追加 qcloop 本地修复准备:在 `/Users/coso/Documents/dev/ai/limecloud/qcloop` 中准备了 executor timeout process-group cleanup 补丁,新增 Unix/Windows helper 与 Unix regression test,`go test ./internal/executor` 通过。该补丁未提交、未推送、未影响当前运行中的 qcloop serve 或 PID `69738`;要生效仍需 owner 在处理当前 stale worker 后重建/重启 qcloop。
+
+2026-05-10 20:36 追加 qcloop 全量 Go 测试:在 `/Users/coso/Documents/dev/ai/limecloud/qcloop` 执行 `go test ./...` 通过,覆盖 `internal/api`、`internal/core`、`internal/db` 与 `internal/executor`。这只验证本地 qcloop 源码补丁,不影响当前运行中的 qcloop serve / PID `69738`。
+
+2026-05-10 20:37 追加 qcloop fixed binary sidecar:已从修复后的 qcloop 源码构建 `.lime/qc/bin/qcloop-timeout-fixed`,并执行 `--help` 验证可启动。该 binary 未运行、未替换当前 qcloop serve,只作为 owner 确认处理 stale 后的候选恢复工具。
+
+2026-05-10 20:38 追加 qcloop executor race 验证:在 qcloop 仓库执行 `go test -race ./internal/executor` 通过,用于确认新的 `Start` / `Wait` / timeout goroutine 没有暴露明显竞态。
+
+2026-05-10 20:40 追加 qcloop 全量 race 验证:在 qcloop 仓库执行 `go test -race ./...` 通过,覆盖 `internal/api`、`internal/core`、`internal/db` 与 `internal/executor`。macOS linker 输出 LC_DYSYMTAB warning,但测试退出码为 0。
+
+## 10. 2026-05-10 20:40 安全继续策略
+
+当前用户明确要求 Lime 不推送,且本地仍有其他进程在跑。因此本阶段的允许动作只限于只读刷新和文档 / sidecar 同步。
+
+最新只读状态:
+
+| 项目 | 当前值 |
+| --- | --- |
+| qcloop server | `127.0.0.1:18080` |
+| qcloop job | `1778405842243079000` |
+| job verdict | `stale` |
+| counts | 8 total / 4 success / 1 running / 3 pending / 1 stale |
+| stale scenario | `browser-runtime-site-adapter` |
+| worker output | stdout/stderr `0 / 0` |
+| worker duration | 约 `10508s` |
+| observed PID | `69738` |
+| GUI owner gate | `blocked`,`ownerCount=1`,`staleOwnerCount=1` |
+
+安全动作矩阵:
+
+| 动作 | 当前是否允许 | 说明 |
+| --- | --- | --- |
+| 刷新 `agent-qc:qcloop-status` sidecar | 允许 | 只读,不改变 qcloop job |
+| 刷新 `agent-qc:gui-owner-check` sidecar | 允许 | 只读,用于阻止新 GUI P0 抢占 |
+| 更新 `docs/tests` 运行手册 | 允许 | 记录事实和 runbook,不改变运行中 job |
+| 导出 partial sidecar evidence | 允许,但不能覆盖官方 evidence | 仅用于排障 |
+| 启动新的 full GUI P0 | 禁止 | 当前 GUI owner 未释放 |
+| 覆盖 `.lime/qc/agent-qc-evidence.json` | 禁止 | 还没有 8/8 P0 structured evidence pass |
+| kill / pause / interrupt PID `69738` | 禁止,除非 owner 明确确认 | 需要按 stale owner 处置协议执行 |
+| commit / push / tag / release | 禁止 | 用户明确要求 Lime 不推送 |
+
+下一次真正能推进 release gate 的动作只有两种:
+
+1. stale worker 自然结束,且 `npm run agent-qc:gui-owner-check -- --check` 通过。
+2. owner 明确确认处理 stale GUI owner 后,按 `docs/tests/lime-agent-qc-stale-owner-intervention.md` 记录 sidecar、清 owner、再重跑 P0 structured evidence payload。
+
+在其中任一条件满足前,不能把当前目标标记为完成。
+
+2026-05-10 20:44 只读续刷显示状态未释放:`1778405842243079000` 仍为 `running` / `stale`,`browser-runtime-site-adapter staleSeconds=10772`,stdout/stderr 仍为 `0 / 0`;`agent-qc:gui-owner-check` 仍为 `blocked`,`ownerCount=1`、`staleOwnerCount=1`。本轮仍只能继续观察或等待 owner 明确确认。
+
+2026-05-10 20:48 追加 DB / lease 级取证:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1.md` 记录 SQLite 只读结果,active item `1778405842246191000` 仍为 `running`,attempt stdout/stderr 为 `0 / 0`,lease 被 `qcloop-worker-1` 延长到 `2026-05-10T21:02:05+08:00`。进程树显示 PID `69738` 已成为 PPID `1` 的 orphan,但仍在 qcloop PGID `69307` 中,并有 Playwright MCP / Context7 MCP 子进程。该证据用于支持后续 owner 决策,但不授权当前 Agent 直接处理进程。
+
+2026-05-10 20:52 追加 runtime binary provenance:`.lime/qc/qcloop-runtime-binary-provenance-18080.md` 记录当前 18080 qcloop serve PID `69307` 仍运行 `/Users/coso/Documents/dev/ai/limecloud/qcloop/qcloop`,而不是 `.lime/qc/bin/qcloop-timeout-fixed`。两者 checksum 不同;因此 qcloop timeout / process-group cleanup 补丁不会影响当前 stale worker。owner 清空当前 GUI owner 后,下一轮隔离 P0 应使用 fixed binary 或等价重建后的 qcloop binary。
+
+2026-05-10 21:02 追加 lease 过期窗口后复核:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1-after-expiry.md` 记录等待超过先前 `lock_expires_at=2026-05-10T21:02:05+08:00` 后,active item 仍为 `running`,stdout/stderr 仍为 `0 / 0`,lease 被延长到 `2026-05-10T21:17:05+08:00`。这说明 stale owner 未自然释放,qcloop 仍在续约该 worker;当前仍不能启动新 GUI P0。
+
+2026-05-10 21:10 追加 watch history:`.lime/qc/stale-owner-watch-history.jsonl` 以 JSONL 记录 stale owner 的只读观察时间线。后续只读刷新可以继续追加该文件;它只用于证明持续 heartbeat / no-output hang,不构成处理授权。
+
+2026-05-10 21:18 追加第二个 lease 窗口复核:qcloop 仍为 `stale`,stdout/stderr 仍为 `0 / 0`,lease 从 `21:17:05` 再次延长到 `21:32:05`,PID `69738` 仍存活。watch history 当前为 4 条。
+
+2026-05-10 21:27 追加脚本化 watch history:`agent-qc:gui-owner-check` 支持 `--watch-history-output `,会把当前 GUI owner report 摘要追加为 JSONL。推荐只读续刷命令:
+
+```bash
+npm run agent-qc:gui-owner-check -- \
+ --format json \
+ --output "./.lime/qc/gui-owner-current.json" \
+ --watch-history-output "./.lime/qc/stale-owner-watch-history.jsonl"
+```
+
+该参数只追加观察记录,不改变 qcloop job、DB 或进程状态。
+
+2026-05-10 21:32 追加第三个 lease 窗口复核:qcloop 仍为 `stale`,stdout/stderr 仍为 `0 / 0`,lease 从 `21:32:05` 再次延长到 `21:47:05`,PID `69738` 仍存活。watch history 当前为 6 条。
+
+2026-05-10 22:45 追加只读续刷:`qcloop-status.isolated-p0-full-v1-current.json` 仍为 `stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;active item `browser-runtime-site-adapter` stdout/stderr 仍为 `0 / 0`,stale 约 `17692s`。SQLite 只读复核显示 `lock_expires_at=2026-05-10T22:56:05+08:00`,PID `69738` 仍存活且 PPID=`1`、PGID=`69307`。已新增 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md` 并把 `.lime/qc/stale-owner-watch-history.jsonl` 追加到 14 条。未执行 kill / pause / interrupt / restart,未改 qcloop DB,未启动新 full GUI P0。
+
+2026-05-11 01:02 追加只读续刷:`qcloop-status.isolated-p0-full-v1-current.json` 仍为 `stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;active item 仍为 `browser-runtime-site-adapter`,stale 约 `26202s`。SQLite 只读复核显示 active item `1778405842246191000` 仍为 `running`,`lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:17:06+08:00`,active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` stdout/stderr 仍为空。已刷新 `.lime/qc/gui-owner-current.json`、追加 `.lime/qc/stale-owner-watch-history.jsonl`,并更新 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json` / `.md`。未执行 kill / pause / interrupt / restart,未改 qcloop DB,未启动新 full GUI P0。
+
+2026-05-11 01:13 追加 GUI smoke 复核与 raw process owner:外部启动的 `verify:gui-smoke -- --reuse-running --timeout-ms 240000` 已自然通过,证据为 `.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-0108.log` 与 `.lime/qc/verify-gui-smoke-current.json`。本次 Claw streaming / interrupt / resume summary 为 `verdict=pass`、`recoveryVisibleSource=live-stream`、`interruptedTurnStatus=aborted`、`followTurnStatus=completed`。同时新增 `.lime/qc/gui-process-owner-current.json` 作为 best-effort raw process owner snapshot;当前仍为 `busy`,包含长时间 `smoke:design-canvas`、stale qcloop Codex worker 和多个 Cargo / Rust 进程。因此仍不启动新的完整 `verify:local` 或 full GUI P0,只记录 sidecar。
+
+2026-05-11 01:23 追加只读续刷与 raw process owner 脚本化:新增 `npm run agent-qc:process-owner-check`,用于生成 `.lime/qc/gui-process-owner-current.json` 与 `.lime/qc/gui-process-owner-current.md`,避免继续依赖临时 `ps` 片段。最新只读结果仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=6`、`qcloopRelated=7`;其中仍包含长时间 `smoke:design-canvas`、stale qcloop Codex worker PID `69738`,以及 Tauri / Cargo / Rust owner。同步刷新后的 qcloop status 仍为 `stale`:job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`,`browser-runtime-site-adapter` stale 约 `27359s`,SQLite lease 被续约到 `2026-05-11T01:38:06+08:00`,active attempt stdout/stderr 仍为空。本轮仍未 kill / pause / interrupt / restart,未改 qcloop DB,未启动新的 full GUI P0 或完整 `verify:local`。
+
+2026-05-11 01:28 追加完成审计前只读续刷:`qcloop-status.isolated-p0-full-v1-current.json` 仍为 `stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;active item `browser-runtime-site-adapter` stale 约 `27786s`,worker stdout/stderr 仍为 `0 / 0`。`gui-process-owner-current.json` 仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=5`、`qcloopRelated=7`。SQLite 只读复核显示 lease 被续约到 `2026-05-11T01:43:06+08:00`。因此仍不能启动完整 `verify:local` 或新的 full GUI P0;completion audit 仍为 `incomplete`,缺口为 `real-qcloop-evidence` 与 `local-verify-gate`。
+
+2026-05-11 01:32 追加 DB lease 取证脚本化:新增 `npm run agent-qc:qcloop-db-lease`,用于从 qcloop SQLite DB 只读导出 active item、lease、attempt stdout/stderr 长度与进程快照,替代临时 `sqlite3` 片段。使用该脚本刷新后,job `1778405842243079000` 仍为 `running`,active item `1778405842246191000` 仍是 `browser-runtime-site-adapter`,`lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:47:06+08:00`,active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` stdout/stderr 仍为空。raw process owner 仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7`。本轮仍未处理 PID `69738`、未改 DB、未启动完整 `verify:local` 或新 full GUI P0。
diff --git a/docs/tests/lime-agent-qc-rollout-plan.md b/docs/tests/lime-agent-qc-rollout-plan.md
new file mode 100644
index 000000000..65c5eb923
--- /dev/null
+++ b/docs/tests/lime-agent-qc-rollout-plan.md
@@ -0,0 +1,213 @@
+# Lime Agent 运营级测试落地计划
+
+> 本文件把 `docs/tests/agent-ops-qc.md` 的标准落到 Lime 当前产品上:Lime 只是标准协议的一个实现样本,但它需要在上线运营前证明 Agent Runtime、Agent UI、GUI 桌面壳、工具调用、发布包和 qcloop 证据链都能长期自测。
+
+## 1. 建设目标
+
+Lime 不能继续依赖“人工点一遍”来判断是否可运营。目标是让每次高风险改动都能自动进入以下闭环:
+
+```text
+Diff / Release Candidate
+ -> 质量规划器识别风险
+ -> qcloop 拆成 Agent QC 场景
+ -> npm / Rust / Playwright MCP / Harness 执行
+ -> Evidence Pack 落盘
+ -> 独立 verifier 判定
+ -> 失败样本进入 replay / smoke / regression
+ -> release gate 只读证据,不读口头结论
+```
+
+完成后,人类只审核例外:高风险 waiver、语义争议、生产凭证、无法自动化的外部依赖。
+
+## 2. Lime 当前基础
+
+| 能力 | 当前入口 | 当前角色 |
+| --- | --- | --- |
+| 本地智能校验 | `npm run verify:local` | 选择前端、Rust、契约和 smoke 的最低门槛 |
+| 桥接契约 | `npm run test:contracts` | 防止前端、Rust、DevBridge、mock、治理目录漂移 |
+| GUI 冒烟 | `npm run verify:gui-smoke` | 验证 Tauri 壳、DevBridge、workspace、browser runtime 等主路径 |
+| Runtime smoke | `npm run smoke:agent-runtime-tool-surface` | 验证 tool surface、runtime strip、工作台能力摘要 |
+| Browser / Site Adapter | `npm run smoke:browser-runtime`、`npm run smoke:site-adapters` | 验证浏览器会话、adapter catalog 和清理边界 |
+| Harness eval | `npm run harness:eval`、`npm run harness:eval:trend` | 验证 replay、grader、行为趋势 |
+| Agent QC 标准 | `npm run agent-qc:check` | 校验 scenario manifest、GUI flow manifest 和 npm script 引用 |
+| qcloop 证据 | `npm run agent-qc:export-evidence` | 把 qcloop job/items 转成 Evidence Pack |
+| 发布证据 | `npm run agent-qc:release-summary` | 把 Evidence Pack 汇总到 release note 并作为硬门禁 |
+
+当前最关键的缺口不是“没有测试命令”,而是还没有把完整 P0 qcloop 批次稳定导出为 `.lime/qc/agent-qc-evidence.json`。
+
+## 3. 分阶段落地
+
+### Phase 0:证据链打底
+
+目标:所有 Agent QC 资产都可被机器校验。
+
+执行:
+
+```bash
+npm run agent-qc:report
+npm run agent-qc:gui-flow:report
+npm run agent-qc:check
+npm run agent-qc:audit
+```
+
+验收:
+
+- `docs/test/agent-qc-scenarios.manifest.json` 有 P0/P1 场景、lane、命令、证据要求和 verifier。
+- `docs/test/agent-qc-gui-flows.manifest.json` 有 GUI / Playwright MCP 步骤和断言。
+- `docs/test/agent-qc-evidence.schema.json` 固定 Evidence Pack 形状。
+- `npm run test:contracts` 已包含 `agent-qc:check`。
+
+### Phase 1:qcloop 最小真实链路
+
+目标:不启动重型 GUI、不影响其他本地进程,先证明 qcloop API -> Evidence Pack exporter 可用。
+
+建议先读取现有完成批次,或只运行轻量 P0 场景。qcloop 本地 API 建议使用 IPv4 loopback,避免 `localhost` 被代理或 IPv6 解析干扰:
+
+```bash
+npm run agent-qc:qcloop-job -- \
+ --scenario command-bridge-contract \
+ --cwd "$(pwd)" \
+ --base-url "http://127.0.0.1:8080" \
+ --output "./.lime/qc/qcloop-command-bridge-job.json" \
+ --check
+```
+
+如果只需要验证历史 qcloop 结果导出,不要写入正式发布门禁路径,先写 partial:
+
+```bash
+npm run agent-qc:export-evidence -- \
+ --base-url "http://127.0.0.1:8080" \
+ --job-id "" \
+ --output "./.lime/qc/agent-qc-evidence.partial.json" \
+ --ref "local-qcloop-partial" \
+ --check
+```
+
+验收:
+
+- partial evidence 的 `verdict.status` 能反映真实 job/item 状态。
+- scenario id 来自 `scenario_id`;历史 job 可退化读取 `name` / `entry`。
+- partial evidence 不能作为 release pass 证据。
+
+### Phase 2:P0 全量 Agent QC 批次
+
+目标:把 8 个 P0 场景全部交给 qcloop 执行,并导出正式 Evidence Pack。
+
+执行:
+
+```bash
+npm run agent-qc:qcloop-job -- \
+ --risk P0 \
+ --cwd "$(pwd)" \
+ --base-url "http://127.0.0.1:8080" \
+ --output "./.lime/qc/qcloop-p0-job.json" \
+ --check
+```
+
+外层 Agent 创建并运行 qcloop job 后导出:
+
+```bash
+npm run agent-qc:export-evidence -- \
+ --base-url "http://127.0.0.1:8080" \
+ --job-id "" \
+ --output "./.lime/qc/agent-qc-evidence.json" \
+ --ref "" \
+ --diff-base "origin/main" \
+ --check
+```
+
+验收:
+
+- `.lime/qc/agent-qc-evidence.json` 的 `verdict.status` 是 `pass`。
+- `scenarioResults` 覆盖 manifest 中所有 P0 scenario id,而不是只满足数量。
+- `npm run agent-qc:audit` 的 `real-qcloop-evidence` 通过。
+
+### Phase 3:GUI / Agent UI 证据增强
+
+目标:不只证明命令通过,还要证明 Lime 的 GUI 主路径真的可用。
+
+每个 GUI 场景至少记录:
+
+- DevBridge ready 状态。
+- workspace ready 或 session restore 状态。
+- 操作步骤和最终断言。
+- console error 摘要。
+- network error 摘要。
+- 截图、trace 或结构化快照。
+- mock fallback 是否预期。
+
+推荐路径:
+
+```bash
+npm run verify:gui-smoke
+npm run agent-qc:gui-flow:report
+```
+
+需要真实交互时,用 Playwright MCP 复用已有桌面 Chrome 会话,避免打断本地桌面工作流。证据保存到 `.lime/qc/gui-evidence/`,再由 release summary 引用。
+
+### Phase 4:Runtime / Tool / Approval 深测
+
+目标:让 Lime Agent 不是“能回答”,而是工具链、授权、sandbox 和恢复行为都可靠。
+
+重点场景:
+
+| 场景 | 必须采集的证据 |
+| --- | --- |
+| 工具调用 | tool name、input 摘要、result / error、timeline |
+| approval | request id、decision、拒绝后的恢复行为 |
+| sandbox | policy、允许/拒绝边界、危险工具未暴露证据 |
+| streaming | first token、interrupt、resume、错误转态 |
+| Skill Forge | draft、verify、register、binding、session enable、SkillTool allowlist |
+| Browser runtime | session、adapter、console/network、cleanup |
+
+失败后必须沉淀为至少一种长期资产:Vitest、Rust 定向测试、smoke、harness replay、qcloop scenario 或 Playwright MCP flow。
+
+### Phase 5:Release Gate
+
+目标:发布流程只接受证据,不接受口头结论。
+
+发布前必须生成:
+
+```bash
+npm run agent-qc:release-summary -- \
+ --evidence "./.lime/qc/agent-qc-evidence.json" \
+ --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \
+ --require-risk P0 \
+ --harness-summary "./.lime/harness/reports/harness-eval-summary.json" \
+ --harness-trend "./.lime/harness/reports/harness-eval-trend.json" \
+ --tag "" \
+ --output "./.lime/qc/release-agent-qc.md" \
+ --check
+```
+
+验收:
+
+- release workflow 在 Evidence Pack 缺失、非 pass 或 blocker 未处理时失败。
+- release note 包含质量证据摘要。
+- waiver 必须有 owner、原因、过期时间和复测计划。
+
+## 4. 让 Lime Agent 更强健的反馈闭环
+
+运营级测试的价值不只是“拦发布”,还要持续强化 Agent:
+
+| 失败类型 | 回写资产 | 强化效果 |
+| --- | --- | --- |
+| 命令合同漂移 | `test:contracts` / governance catalog | 防止桥接与 mock 分叉 |
+| GUI ready 假阳性 | `verify:gui-smoke` / Playwright flow | 防止用户首屏不可用 |
+| 工具误用 | runtime smoke / tool permission test | 防止危险工具绕过授权 |
+| streaming 卡死 | harness replay / UI regression | 防止长回答或中断后失控 |
+| Skill 误启用 | metadata builder test / Rust gate test | 防止 registered 被误认为 executable |
+| Browser session 泄漏 | browser runtime smoke | 防止后台资源泄漏 |
+| 语义退化 | harness eval / grader | 防止只看命令成功却回答变差 |
+| 发布包启动失败 | release startup smoke | 防止源码可跑但安装包不可用 |
+
+每次 P0 失败修复后,必须问一个问题:这次失败下次能否由机器先发现?如果不能,修复不算闭环。
+
+## 5. 当前执行建议
+
+在本地还有其他进程运行时,不建议直接启动完整 P0 qcloop 批次或重型 GUI release smoke。更安全的下一刀是:
+
+1. 用现有完成的 qcloop job 导出 partial evidence,验证 exporter 链路。
+2. 运行 `npm run agent-qc:check` 和 Agent QC 相关定向单测。
+3. 保持 `npm run agent-qc:audit` 为 `incomplete`,直到完整 P0 evidence 真正存在。
+4. 等本地环境空闲后再执行 Phase 2,把 `.lime/qc/agent-qc-evidence.json` 作为正式发布门禁证据。
diff --git a/docs/tests/lime-agent-qc-stale-owner-intervention.md b/docs/tests/lime-agent-qc-stale-owner-intervention.md
new file mode 100644
index 000000000..37ae957cd
--- /dev/null
+++ b/docs/tests/lime-agent-qc-stale-owner-intervention.md
@@ -0,0 +1,164 @@
+# Lime Agent QC stale GUI owner 处置协议
+
+> 本文定义当 qcloop GUI P0 owner 长时间 stale 时,测试 Agent 应如何继续推进而不越权。核心原则:先证明、再请求 owner 决策;未获明确确认前,不 kill、不 pause、不 interrupt、不改 qcloop DB。
+
+## 1. 触发条件
+
+满足任一条件时进入本协议:
+
+- `agent-qc:gui-owner-check -- --check` 返回非 0,且 `staleOwnerCount > 0`。
+- `agent-qc:qcloop-status` 显示 GUI scenario `running` 超过 stale 阈值,且 stdout / stderr 长期为 0。
+- 本机 `ps` 仍能看到对应内层 worker 进程,但该 worker 没有开始执行场景命令或没有业务输出。
+
+当前典型状态:
+
+```text
+ownerCount=1
+staleOwnerCount=1
+active scenario=browser-runtime-site-adapter
+worker stdoutLength=0
+worker stderrLength=0
+```
+
+## 2. 只读证据采集
+
+执行以下命令只读刷新 sidecar:
+
+```bash
+npm run agent-qc:qcloop-status -- \
+ --base-url "http://127.0.0.1:18080" \
+ --job-id "1778405842243079000" \
+ --stale-minutes 8 \
+ --format json \
+ --output "./.lime/qc/qcloop-status.isolated-p0-full-v1-current.json"
+
+npm run agent-qc:gui-owner-check -- \
+ --format json \
+ --output "./.lime/qc/gui-owner-current.json"
+```
+
+可选只读进程核查:
+
+```bash
+ps -eo pid,ppid,etime,stat,command | rg -i "qcloop|codex exec|browser-runtime-site-adapter|1778405842243079000|18080"
+```
+
+如果可以导出 sidecar Evidence Pack,继续导出但不要覆盖官方 evidence:
+
+```bash
+npm run agent-qc:export-evidence -- \
+ --base-url "http://127.0.0.1:18080" \
+ --job-id "1778405842243079000" \
+ --output "./.lime/qc/agent-qc-evidence.isolated-p0-full-v1-current.json" \
+ --ref "local-isolated-p0-full-v1-current" \
+ --check
+```
+
+## 3. 禁止操作
+
+未获 owner 明确确认前禁止:
+
+- `kill` / `pkill` / `killall` 内层 worker 或 qcloop serve。
+- qcloop Web/API 上执行 pause / cancel / retry。
+- 直接修改 qcloop SQLite DB 状态或 lease。
+- 启动新的 full GUI P0 批次。
+- 用 sidecar evidence 覆盖 `.lime/qc/agent-qc-evidence.json`。
+
+## 3.1 机器可读决策包
+
+当前 stale owner 的机器可读确认包位于 `.lime/qc/stale-owner-intervention-request.json`。该 sidecar 汇总 job、active item、DB lease、PID、binary provenance、证据引用、禁止动作和确认文本;它只用于 owner 决策,不等于授权。
+
+同时,`npm run agent-qc:gui-owner-check -- --format json` 会在存在 stale GUI owner 时输出 `ownerIntervention` 字段,包含 `requiredConfirmationText`、`prohibitedUntilConfirmed`、`evidenceRefs` 和下一步动作。该字段是机器可读提示,不等于 owner 已确认。
+
+需要连续观察时,加 `--watch-history-output ./.lime/qc/stale-owner-watch-history.jsonl`。该参数只追加 GUI owner report 摘要,不修改 qcloop job、SQLite DB 或进程状态。
+
+## 4. Owner 确认格式
+
+如果需要处理 stale owner,先生成干预请求,给出影响范围和建议动作。必须看到类似确认后才能继续:
+
+```text
+确认处理 stale GUI owner 1778405842243079000,可以终止 PID 并记录 sidecar。
+```
+
+确认前只允许继续完善文档、payload、审计和非侵入式 sidecar。
+
+## 5. 获得确认后的最小闭环
+
+获得确认后仍按最小动作执行:
+
+1. 再次导出 qcloop status、GUI owner report 和 Evidence sidecar。
+2. 记录要处理的 PID、job id、scenario id、stale 秒数和 stdout/stderr 长度。
+3. 使用 owner 批准的方式处理 stale worker;优先使用 qcloop 自身安全入口,只有 owner 明确授权时才终止进程。
+4. 处理后再次执行 `agent-qc:gui-owner-check -- --check`。
+5. ownerCount 回到 0 后,才允许使用结构化 evidence payload 重跑 P0。
+6. 新 P0 批次必须输出 `QCLOOP_EVIDENCE_SUMMARY_JSON=`;否则 release summary gate 会继续阻断。
+
+## 6. 关闭条件
+
+只有以下条件全部满足时,stale owner 处置才算完成:
+
+- `agent-qc:gui-owner-check -- --check` 通过。
+- 没有 GUI P0 running / stale sidecar。
+- 新 qcloop P0 payload 使用结构化 evidence 契约。
+- 官方 `.lime/qc/agent-qc-evidence.json` 只由新的 8/8 P0 pass 批次导出。
+- `agent-qc:release-summary --check` 与 `agent-qc:audit` 通过。
+
+## 7. Owner 清空后的 P0 重跑 runbook
+
+当 `agent-qc:gui-owner-check -- --check` 已通过,按以下顺序执行,不要跳步:
+
+```bash
+npm run agent-qc:gui-owner-check -- --check
+
+npm run agent-qc:qcloop-job -- \
+ --risk P0 \
+ --cwd "/Users/coso/Documents/dev/ai/aiclientproxy/lime" \
+ --max-qc-rounds 1 \
+ --max-executor-retries 0 \
+ --output "./.lime/qc/qcloop-p0-structured-evidence-v1-payload.json" \
+ --check
+```
+
+提交 qcloop job 前必须人工或 Agent 复核 payload:
+
+- `items.length=8`,覆盖全部 P0。
+- `prompt_template` 包含 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED`。
+- `prompt_template` 包含 `QCLOOP_EVIDENCE_SUMMARY_JSON=`。
+- `verifier_prompt_template` 包含 `{{stdout}}`、`{{attempt_status}}`、`{{exit_code}}`、`{{issue_ledger}}`。
+- `verifier_prompt_template` 明确只输出 `{"pass": true|false, "feedback": "..."}` JSON。
+- `max_qc_rounds=1`,避免发布证据批次 repair 轮修改工作区。
+- `max_executor_retries=0`,发布证据批次不自动重试内层 CLI,避免重复制造 stale worker;如果 owner 明确要保留基础设施重试,可在隔离环境中改为 1。
+
+新 job 运行中只读观察:
+
+```bash
+npm run agent-qc:qcloop-status -- \
+ --base-url "" \
+ --job-id "" \
+ --stale-minutes 8 \
+ --format json \
+ --output "./.lime/qc/qcloop-status..json"
+```
+
+只有新 job 全部 P0 `success`,且 `agent-qc:export-evidence` 导出的 sidecar 为 `pass`,才允许覆盖官方 evidence:
+
+```bash
+npm run agent-qc:export-evidence -- \
+ --base-url "" \
+ --job-id "" \
+ --output "./.lime/qc/agent-qc-evidence.json" \
+ --ref "local-agent-qc-p0" \
+ --check
+
+npm run agent-qc:release-summary -- \
+ --evidence "./.lime/qc/agent-qc-evidence.json" \
+ --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \
+ --require-risk P0 \
+ --tag "local-agent-qc-audit" \
+ --output "./.lime/qc/release-agent-qc.current-audit.md" \
+ --check
+
+node scripts/agent-qc-completion-audit.mjs --format json > ".lime/qc/agent-qc-audit-current.json"
+```
+
+如果 release summary 报缺少结构化 evidenceRefs,不能手工补假路径;应回到 qcloop worker stdout,确认 `QCLOOP_EVIDENCE_SUMMARY_JSON` 的 `artifacts[]`、`gui_session_owner` 和 `release_scope` 是否真实存在。
diff --git a/docs/tests/lime-agent-qc-stale-worker-analysis-2026-05-10.md b/docs/tests/lime-agent-qc-stale-worker-analysis-2026-05-10.md
new file mode 100644
index 000000000..24cea6fe7
--- /dev/null
+++ b/docs/tests/lime-agent-qc-stale-worker-analysis-2026-05-10.md
@@ -0,0 +1,138 @@
+# Lime Agent QC stale worker 根因分析(2026-05-10)
+
+> 本文件把 `1778405842243079000` 的 stale qcloop worker 取证集中成一个可复用分析样本。它不是 release waiver,也不是处理授权;它用于指导后续 Agent 判断何时可以重跑 P0、何时必须等待 owner 决策。
+
+## 1. 结论
+
+当前 Lime Agent QC 整体目标未完成的直接原因不是测试标准缺失,而是官方 P0 Evidence Pack 没有真实 8/8 pass。当前阻断项是 isolated full P0 job `1778405842243079000` 中的 `browser-runtime-site-adapter`:worker 长时间 running,但没有 stdout/stderr。
+
+截至本轮只读取证,三侧证据一致:
+
+| 证据侧 | 事实 | 结论 |
+| --- | --- | --- |
+| qcloop status sidecar | `4 success / 1 running / 3 pending / 1 stale` | P0 批次未完成 |
+| SQLite DB | active item / attempt 仍为 `running`,stdout/stderr `0 / 0` | stale 不是旧 sidecar 噪声 |
+| process tree | PID `69738` 仍存活,PPID `1`,带 MCP child processes | worker 已孤儿化且未进入产品命令输出 |
+| binary provenance | 当前 qcloop serve 不是 `.lime/qc/bin/qcloop-timeout-fixed` | 已准备的 timeout fix 未作用于当前进程 |
+
+因此不能覆盖 `.lime/qc/agent-qc-evidence.json`,不能启动新的 full GUI P0,也不能把 isolated partial pass 当作 release pass。
+
+## 2. 关键证据
+
+### 2.1 qcloop job 状态
+
+```text
+job_id=1778405842243079000
+job_status=running
+verdict=stale
+counts=8 total / 4 success / 1 running / 3 pending / 1 stale
+stale_scenario=browser-runtime-site-adapter
+worker_stdout_len=0
+worker_stderr_len=0
+```
+
+权威 sidecar:
+
+- `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json`
+- `.lime/qc/gui-owner-current.json`
+
+### 2.2 DB / lease 取证
+
+```text
+item_id=1778405842246191000
+scenario_id=browser-runtime-site-adapter
+item_status=running
+lock_owner=qcloop-worker-1
+lock_expires_at=2026-05-10T21:02:05+08:00
+attempt_id=dc625f8e-b3b9-46b7-9758-4b0273438d50
+attempt_status=running
+attempt_started_at=2026-05-10T17:45:05+08:00
+attempt_stdout_len=0
+attempt_stderr_len=0
+```
+
+权威 sidecar:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1.md`。
+
+2026-05-10 21:02 复核补充:等待超过上述 `lock_expires_at` 后,qcloop 并未自然释放该 item;只读 SQLite 显示 `lock_expires_at` 已延长到 `2026-05-10T21:17:05+08:00`,active attempt 仍为 `running` 且 stdout/stderr `0 / 0`。权威 sidecar:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1-after-expiry.md`。
+
+### 2.3 进程树取证
+
+```text
+qcloop_serve_pid=69307
+qcloop_serve_pgid=69307
+codex_worker_pid=69738
+codex_worker_ppid=1
+codex_worker_pgid=69307
+child_processes=Playwright MCP / Context7 MCP npm exec
+```
+
+这说明 worker 并非已经退出;它仍然存在,但没有开始输出 `npm run agent-qc:qcloop-preflight`、`smoke:browser-runtime` 或 `smoke:site-adapters` 的业务日志。
+
+### 2.4 qcloop binary provenance
+
+```text
+active_binary=/Users/coso/Documents/dev/ai/limecloud/qcloop/qcloop
+active_sha256=177bf7fa79212d065c5cb6cc5cdbb153d3079cec3252a157993f70ab35dc5fe1
+fixed_binary=.lime/qc/bin/qcloop-timeout-fixed
+fixed_sha256=2895e33d068a7109607d3e45b6e3bebe1807b6f71d387a3b62eb669a3d4314c7
+```
+
+权威 sidecar:`.lime/qc/qcloop-runtime-binary-provenance-18080.md`。
+
+这说明当前 qcloop serve 未使用 timeout / process-group cleanup fix。即使 fix 已在 qcloop repo 通过测试,它也不会影响当前 running job。
+
+## 3. 失败分类
+
+当前阻断应归类为:
+
+```text
+failure_mode=worker user-config MCP startup no-output hang
+scope=qcloop infrastructure / executor lifecycle
+product_verdict=unproven
+release_verdict=blocked
+```
+
+不要把它误判为:
+
+- `browser-runtime-site-adapter` 产品功能已经失败。
+- `browser-runtime-site-adapter` 产品功能已经通过。
+- qcloop full P0 已经完成。
+- 可用单场景 isolated pass 覆盖官方 Evidence Pack。
+
+当前正确口径是:产品命令在宿主 shell 里曾直接通过,但 qcloop worker 内的同批次 P0 证据未形成;release gate 必须继续阻断。
+
+## 4. 禁止动作
+
+未获 owner 明确确认前禁止:
+
+- kill / pause / interrupt PID `69738` 或 qcloop serve PID `69307`。
+- 修改 `.lime/qc/qcloop-isolated-worker-preflight.db`。
+- 启动新的 full GUI P0。
+- 覆盖 `.lime/qc/agent-qc-evidence.json`。
+- 用 isolated sidecar pass 发布 release note。
+- git commit / push / tag / release。
+
+## 5. 允许动作
+
+当前仍允许:
+
+- 只读刷新 qcloop status sidecar。
+- 只读刷新 GUI owner report。
+- 只读查询 SQLite DB。
+- 只读查询进程树和 binary provenance。
+- 更新 `.lime/qc/*` sidecar 和 `docs/tests` 分析文档。
+- 准备但不提交新的 P0 structured evidence payload。
+
+## 6. Owner 清空后的恢复路径
+
+当 stale worker 自然退出,或 owner 明确确认处理后,按以下顺序恢复:
+
+1. 重新运行 `npm run agent-qc:gui-owner-check -- --check`,确认 active GUI owner 为 0。
+2. 使用 `.lime/qc/bin/qcloop-timeout-fixed` 或等价重建后的 qcloop binary,启动隔离 qcloop server 与独立 DB。
+3. 生成或复核 `.lime/qc/qcloop-p0-structured-evidence-v1-payload.json`:8 个 P0、`max_qc_rounds=1`、`max_executor_retries=0`。
+4. 提交 P0 structured evidence job。
+5. 等待 8/8 qcloop `success`。
+6. 只在该 job 真实 pass 后导出 `.lime/qc/agent-qc-evidence.json`。
+7. 运行 release summary gate 与 completion audit。
+
+完成条件仍是:`agent-qc:audit` 返回 `complete`,且 release summary 以官方 Evidence Pack 通过。
diff --git a/package-lock.json b/package-lock.json
index d55442e55..4f29afb25 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "lime",
- "version": "1.32.0",
+ "version": "1.33.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lime",
- "version": "1.32.0",
+ "version": "1.33.0",
"dependencies": {
"@babel/standalone": "^7.29.0",
"@fabianlars/tauri-plugin-oauth": "^2",
diff --git a/package.json b/package.json
index 12d66caab..46f271a58 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
- "version": "1.32.0",
+ "version": "1.33.0",
"description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.",
"keywords": [
"ai",
@@ -64,7 +64,7 @@
"test:watch": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs",
"test:frontend": "npm run lint && npm run typecheck && npm test",
"test:bridge": "npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts",
- "test:contracts": "npm run check:agent-runtime-clients && node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs && npm run governance:modality-contracts && npm run harness:cleanup-report:check",
+ "test:contracts": "npm run check:agent-runtime-clients && node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs && npm run governance:modality-contracts && npm run harness:cleanup-report:check && npm run agent-qc:check",
"test:rust": "cargo test --manifest-path \"src-tauri/Cargo.toml\"",
"harness:analysis": "node scripts/harness-analysis-brief.mjs",
"harness:eval": "node scripts/harness-eval-runner.mjs",
@@ -78,10 +78,24 @@
"harness:cleanup-report:check": "node scripts/check-generated-slop-report.mjs --generate-current",
"harness:doc-freshness": "node scripts/check-doc-freshness.mjs",
"harness:doc-freshness:json": "node scripts/check-doc-freshness.mjs --json",
+ "agent-qc:report": "node scripts/agent-qc-report.mjs",
+ "agent-qc:report:json": "node scripts/agent-qc-report.mjs --format json",
+ "agent-qc:gui-flow:report": "node scripts/agent-qc-gui-flow-report.mjs",
+ "agent-qc:gui-flow:check": "node scripts/agent-qc-gui-flow-report.mjs --check --format json",
+ "agent-qc:check": "node scripts/agent-qc-report.mjs --check --format json && node scripts/agent-qc-gui-flow-report.mjs --check --format json",
+ "agent-qc:qcloop-job": "node scripts/agent-qc-qcloop-job.mjs",
+ "agent-qc:qcloop-status": "node scripts/agent-qc-qcloop-status.mjs",
+ "agent-qc:qcloop-db-lease": "node scripts/agent-qc-qcloop-db-lease.mjs",
+ "agent-qc:qcloop-preflight": "node scripts/agent-qc-qcloop-preflight.mjs",
+ "agent-qc:export-evidence": "node scripts/agent-qc-export-evidence.mjs",
+ "agent-qc:release-summary": "node scripts/agent-qc-release-summary.mjs",
+ "agent-qc:audit": "node scripts/agent-qc-completion-audit.mjs",
"lint:rust": "cargo clippy --manifest-path \"src-tauri/Cargo.toml\"",
"detect-translations": "tsx scripts/detect-missing-translations.ts",
"detect-translations:fix": "tsx scripts/detect-missing-translations.ts --fix",
"detect-translations:verbose": "tsx scripts/detect-missing-translations.ts --verbose",
+ "i18n:patch-report": "node scripts/i18n-patch-metrics-report.mjs",
+ "i18n:patch-report:json": "node scripts/i18n-patch-metrics-report.mjs --format json",
"verify:app-version": "node scripts/check-app-version-consistency.mjs",
"verify:tasks": "node scripts/quality-task-selector.mjs --format json",
"verify:gui-smoke": "node scripts/verify-gui-smoke.mjs",
@@ -99,6 +113,7 @@
"smoke:site-adapters": "node scripts/site-adapter-catalog-smoke.mjs",
"smoke:agent-runtime-tool-surface": "node scripts/agent-runtime-tool-surface-smoke.mjs",
"smoke:agent-runtime-tool-surface-page": "node scripts/agent-runtime-tool-surface-page-smoke.mjs",
+ "smoke:claw-chat-ready-streaming": "node scripts/claw-chat-ready-streaming-smoke.mjs",
"smoke:agent-service-skill-entry": "node scripts/agent-service-skill-entry-smoke.mjs",
"smoke:knowledge-gui": "node scripts/knowledge-gui-smoke.mjs",
"knowledge:product-e2e": "node scripts/knowledge-product-e2e.mjs",
@@ -108,7 +123,10 @@
"dev:web-bridge": "node scripts/start-web-bridge-dev.mjs",
"governance:legacy-report": "node scripts/report-legacy-surfaces.mjs",
"knowledge:provider-e2e": "node scripts/knowledge-provider-e2e.mjs",
- "knowledge:release-scope-report": "node scripts/knowledge-release-scope-report.mjs"
+ "knowledge:release-scope-report": "node scripts/knowledge-release-scope-report.mjs",
+ "smoke:agent-runtime-approval-sandbox": "node scripts/agent-runtime-approval-sandbox-smoke.mjs",
+ "agent-qc:gui-owner-check": "node scripts/agent-qc-gui-owner-check.mjs",
+ "agent-qc:process-owner-check": "node scripts/agent-qc-process-owner-check.mjs"
},
"dependencies": {
"@babel/standalone": "^7.29.0",
diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md
index 5095383d9..8135a6db7 100644
--- a/packages/lime-cli-npm/README.md
+++ b/packages/lime-cli-npm/README.md
@@ -112,7 +112,7 @@ npm run build:release -- \
```bash
npm run build:release -- \
--target-triple "aarch64-apple-darwin" \
- --version "1.32.0" \
+ --version "1.33.0" \
--out-dir "./dist"
```
diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json
index c9b2714d4..52d812722 100644
--- a/packages/lime-cli-npm/package.json
+++ b/packages/lime-cli-npm/package.json
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
- "version": "1.32.0",
+ "version": "1.33.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
diff --git a/playwright-agentui-after-newline-gate-final-20260509.json b/playwright-agentui-after-newline-gate-final-20260509.json
deleted file mode 100644
index 3f7e34c53..000000000
--- a/playwright-agentui-after-newline-gate-final-20260509.json
+++ /dev/null
@@ -1,635 +0,0 @@
-{
- "consoleErrors": null,
- "placeholderText": null,
- "hasCompletedThinking": true,
- "hasInternalSelfTalk": false,
- "bodyTail": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n1分\n新对话\n3分\n新对话\n25分\n新对话\n29分\n尽快显示“思考中”状态是为了消解用户...\n31分\n新对话\n34分\n已完成回答。如有其他问题,随时告诉我...\n39分\n首字前尽快显示思考状态能第一时间确认...\n40分\n已为你整理为三句回答,内容直接说明“...\n42分\n**三步思考:**\n1时\n查看更多对话\n归档\n开\n开源使用\n本地可用\nKnowledge Manual E2E 38362\nHarness\n新对话\n\n请用三句话说明:为什么流式输出要按换行或小批次显示?\n\n已完成思考\n\n流式输出按换行或小批次显示主要是为了避免高频更新DOM导致页面卡顿和视觉闪烁。换行符通常对应语义边界,在此处分批能保证每次显示的文本相对完整,提升阅读体验。适当小批次延迟还能模拟自然对话节奏,给用户留出理解时间,让交互更流畅自然。\n\n已完成\n·\n00:12\n保存到项目资料\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
- "summary": {
- "entries": [
- {
- "id": 1,
- "phase": "agentUi.longTask",
- "at": 739.1000000089407,
- "wallTime": 1778320226396,
- "sessionId": null,
- "workspaceId": null,
- "source": null,
- "metrics": {
- "durationMs": 294,
- "name": "self",
- "startTimeMs": 442.70000000298023,
- "usedJSHeapSize": 842309980,
- "totalJSHeapSize": 864292744
- }
- },
- {
- "id": 2,
- "phase": "agentRuntime.listSessions.start",
- "at": 1408.2000000029802,
- "wallTime": 1778320227065,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 11,
- "usedJSHeapSize": 872513737,
- "totalJSHeapSize": 890716065
- }
- },
- {
- "id": 3,
- "phase": "agentRuntime.listSessions.success",
- "at": 1754.1000000089407,
- "wallTime": 1778320227411,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 11,
- "durationMs": 346,
- "sessionsCount": 11,
- "usedJSHeapSize": 880190927,
- "totalJSHeapSize": 899842987
- }
- },
- {
- "id": 4,
- "phase": "appSidebar.recentConversations.loadBreakdown",
- "at": 1754.5,
- "wallTime": 1778320227411,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "hasMore": true,
- "limit": 11,
- "listDurationMs": 346,
- "sessionsCount": 11,
- "sortDurationMs": 0,
- "totalDurationMs": 346,
- "visibleCount": 10,
- "usedJSHeapSize": 880190927,
- "totalJSHeapSize": 899842987
- }
- },
- {
- "id": 5,
- "phase": "agentRuntime.listSessions.start",
- "at": 1777.1000000089407,
- "wallTime": 1778320227434,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 21,
- "usedJSHeapSize": 880190927,
- "totalJSHeapSize": 899842987
- }
- },
- {
- "id": 6,
- "phase": "agentRuntime.listSessions.success",
- "at": 1886.2000000029802,
- "wallTime": 1778320227543,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 21,
- "durationMs": 109,
- "sessionsCount": 21,
- "usedJSHeapSize": 886964131,
- "totalJSHeapSize": 902913803
- }
- },
- {
- "id": 7,
- "phase": "sidebar.recentConversations.loadBreakdown",
- "at": 1886.6000000089407,
- "wallTime": 1778320227543,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "auxiliaryFilterDurationMs": 0,
- "hiddenAuxiliarySessionsCount": 0,
- "limit": 21,
- "listDurationMs": 109,
- "sessionsCount": 21,
- "topicMapDurationMs": 0,
- "topicsCount": 21,
- "totalDurationMs": 109,
- "workspaceFilterDurationMs": 0,
- "workspaceSessionsCount": 21,
- "usedJSHeapSize": 886964131,
- "totalJSHeapSize": 902913803
- }
- },
- {
- "id": 8,
- "phase": "homeInput.submit",
- "at": 49389.40000000596,
- "wallTime": 1778320275047,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "hasDraftTab": false,
- "inputLength": 26,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 849131145,
- "totalJSHeapSize": 857579141
- }
- },
- {
- "id": 9,
- "phase": "homeInput.pendingShellApplied",
- "at": 49389.60000000894,
- "wallTime": 1778320275047,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 0,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 849131145,
- "totalJSHeapSize": 857579141
- }
- },
- {
- "id": 10,
- "phase": "homeInput.pendingPreviewPaint",
- "at": 49434.5,
- "wallTime": 1778320275092,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 45,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 849131145,
- "totalJSHeapSize": 857579141
- }
- },
- {
- "id": 11,
- "phase": "homeInput.sendDispatch.start",
- "at": 49434.60000000894,
- "wallTime": 1778320275092,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 45,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 849131145,
- "totalJSHeapSize": 857579141
- }
- },
- {
- "id": 12,
- "phase": "workspaceSend.plan.ready",
- "at": 49441.79999999702,
- "wallTime": 1778320275100,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 7,
- "hasPendingSessionBinding": false,
- "primedSessionId": null,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 851690732,
- "totalJSHeapSize": 859507912
- }
- },
- {
- "id": 13,
- "phase": "agentStream.assistantDraft",
- "at": 49443.90000000596,
- "wallTime": 1778320275102,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "assistantContentLength": 0,
- "expectingQueue": false,
- "hasAssistantDraftContent": false,
- "phase": "routing",
- "statusTitle": "快速响应已启用",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "homeSubmittedDeltaMs": 55,
- "usedJSHeapSize": 851690732,
- "totalJSHeapSize": 859507912
- }
- },
- {
- "id": 14,
- "phase": "agentStream.ensureSession.start",
- "at": 49445.10000000894,
- "wallTime": 1778320275103,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "hadActiveSessionBeforeEnsure": false,
- "skipSessionRestore": true,
- "skipSessionStartHooks": true,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "homeSubmittedDeltaMs": 56,
- "usedJSHeapSize": 851690732,
- "totalJSHeapSize": 859507912
- }
- },
- {
- "id": 15,
- "phase": "agentStream.assistantDraftPaint",
- "at": 49463.90000000596,
- "wallTime": 1778320275122,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "assistantContentLength": 0,
- "expectingQueue": false,
- "hasAssistantDraftContent": false,
- "phase": "routing",
- "statusTitle": "快速响应已启用",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "homeSubmittedDeltaMs": 75,
- "usedJSHeapSize": 851690732,
- "totalJSHeapSize": 859507912
- }
- },
- {
- "id": 16,
- "phase": "agentStream.ensureSession.done",
- "at": 49515.20000000298,
- "wallTime": 1778320275173,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "activeSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "durationMs": 70,
- "hadActiveSessionBeforeEnsure": false,
- "skipSessionRestore": true,
- "skipSessionStartHooks": true,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 126,
- "usedJSHeapSize": 849772078,
- "totalJSHeapSize": 863355806
- }
- },
- {
- "id": 17,
- "phase": "agentStream.request.start",
- "at": 49516.20000000298,
- "wallTime": 1778320275174,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "contentLength": 26,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "expectingQueue": false,
- "model": "deepseek-chat",
- "provider": "deepseek",
- "skipUserMessage": false,
- "systemPromptLength": 163,
- "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月9日。\n本回合是轻量首轮普通对话,请直接",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 127,
- "usedJSHeapSize": 849772078,
- "totalJSHeapSize": 863355806
- }
- },
- {
- "id": 18,
- "phase": "agentStream.listenerBound",
- "at": 49542.79999999702,
- "wallTime": 1778320275201,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 27,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "expectingQueue": false,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 154,
- "usedJSHeapSize": 849772078,
- "totalJSHeapSize": 863355806
- }
- },
- {
- "id": 19,
- "phase": "agentStream.submitDispatched",
- "at": 49543.70000000298,
- "wallTime": 1778320275202,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 28,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "expectingQueue": false,
- "listenerBoundDeltaMs": 1,
- "model": "deepseek-chat",
- "provider": "deepseek",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 155,
- "usedJSHeapSize": 849772078,
- "totalJSHeapSize": 863355806
- }
- },
- {
- "id": 20,
- "phase": "agentStream.submitAccepted",
- "at": 50006.70000000298,
- "wallTime": 1778320275665,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 490,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "submitInvokeMs": 462,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 618,
- "usedJSHeapSize": 852504645,
- "totalJSHeapSize": 863797681
- }
- },
- {
- "id": 21,
- "phase": "homeInput.sendDispatch.done",
- "at": 50007.70000000298,
- "wallTime": 1778320275665,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 618,
- "requestId": "draft-send-moy5z61z-b632176f",
- "result": true,
- "source": "empty-state",
- "usedJSHeapSize": 852504645,
- "totalJSHeapSize": 863797681
- }
- },
- {
- "id": 22,
- "phase": "agentStream.firstEvent",
- "at": 50314.5,
- "wallTime": 1778320275972,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 798,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "eventType": "runtime_status",
- "recognized": true,
- "submissionDispatchedDeltaMs": 770,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 925,
- "usedJSHeapSize": 851619235,
- "totalJSHeapSize": 864354991
- }
- },
- {
- "id": 23,
- "phase": "agentStream.firstRuntimeStatus",
- "at": 50316.10000000894,
- "wallTime": 1778320275974,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 800,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "firstEventDeltaMs": 2,
- "phase": "preparing",
- "title": "已接收请求,正在准备执行",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 927,
- "usedJSHeapSize": 851619235,
- "totalJSHeapSize": 864354991
- }
- },
- {
- "id": 24,
- "phase": "agentStream.firstThinkingDelta",
- "at": 53084.79999999702,
- "wallTime": 1778320278743,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "deltaChars": 4,
- "elapsedMs": 3569,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "firstEventDeltaMs": 2771,
- "firstRuntimeStatusDeltaMs": 2769,
- "surfaced": false,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 3696,
- "usedJSHeapSize": 856004274,
- "totalJSHeapSize": 870653242
- }
- },
- {
- "id": 25,
- "phase": "agentStream.firstTextDelta",
- "at": 62674.60000000894,
- "wallTime": 1778320288333,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "deltaChars": 1,
- "elapsedMs": 13159,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "firstEventDeltaMs": 12361,
- "firstRuntimeStatusDeltaMs": 12359,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 13286,
- "usedJSHeapSize": 856005497,
- "totalJSHeapSize": 872366473
- }
- },
- {
- "id": 26,
- "phase": "agentStream.firstTextPaint",
- "at": 62846.5,
- "wallTime": 1778320288505,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 13331,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "firstTextDeltaDeltaMs": 172,
- "renderFlushDeltaMs": 23,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 13458,
- "usedJSHeapSize": 856879995,
- "totalJSHeapSize": 872895319
- }
- },
- {
- "id": 27,
- "phase": "agentRuntime.getSession.start",
- "at": 64991.10000000894,
- "wallTime": 1778320290649,
- "sessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "workspaceId": null,
- "source": null,
- "metrics": {
- "historyLimit": 40,
- "historyOffset": null,
- "historyBeforeMessageId": null,
- "resumeSessionStartHooks": false,
- "usedJSHeapSize": 862910768,
- "totalJSHeapSize": 885006348
- }
- },
- {
- "id": 28,
- "phase": "agentRuntime.getSession.success",
- "at": 65203.29999999702,
- "wallTime": 1778320290862,
- "sessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "workspaceId": null,
- "source": null,
- "metrics": {
- "historyLimit": 40,
- "historyOffset": null,
- "historyBeforeMessageId": null,
- "resumeSessionStartHooks": false,
- "childSubagentSessionsCount": 0,
- "durationMs": 213,
- "itemsCount": 4,
- "messagesCount": 2,
- "queuedTurnsCount": 0,
- "turnsCount": 1,
- "usedJSHeapSize": 864840299,
- "totalJSHeapSize": 885057687
- }
- }
- ],
- "sessions": [
- {
- "sessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "workspaceId": null,
- "runtimeGetSessionDurationMs": 213,
- "switchStartCount": 0,
- "fetchDetailStartCount": 0,
- "fetchDetailErrorCount": 0,
- "runtimeGetSessionStartCount": 1,
- "runtimeGetSessionErrorCount": 0,
- "messageListPaintCount": 0,
- "longTaskCount": 0,
- "threadItemsScanDeferredCount": 0,
- "maxUsedJSHeapSize": 864840299,
- "phases": [
- "agentRuntime.getSession.start",
- "agentRuntime.getSession.success"
- ]
- },
- {
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "homeInputToPendingShellMs": 0,
- "homeInputToPendingPreviewPaintMs": 45,
- "homeInputToSendDispatchMs": 45,
- "homeInputToSendPlanReadyMs": 52,
- "homeInputToAssistantDraftMs": 55,
- "homeInputToAssistantDraftPaintMs": 75,
- "homeInputToStreamRequestStartMs": 127,
- "homeInputToSubmitAcceptedMs": 617,
- "homeInputToFirstEventMs": 925,
- "homeInputToFirstRuntimeStatusMs": 927,
- "homeInputToFirstThinkingDeltaMs": 3695,
- "homeInputToFirstTextDeltaMs": 13285,
- "homeInputToFirstTextPaintMs": 13457,
- "sendDispatchToSubmitAcceptedMs": 572,
- "streamSubmitDispatchedToAcceptedMs": 463,
- "submitAcceptedToFirstEventMs": 308,
- "firstEventToFirstThinkingDeltaMs": 2770,
- "firstEventToFirstTextDeltaMs": 12360,
- "firstThinkingDeltaToFirstTextDeltaMs": 9590,
- "firstTextDeltaToFirstTextPaintMs": 172,
- "streamEnsureSessionDurationMs": 70,
- "streamSubmitInvokeDurationMs": 462,
- "switchStartCount": 0,
- "fetchDetailStartCount": 0,
- "fetchDetailErrorCount": 0,
- "runtimeGetSessionStartCount": 0,
- "runtimeGetSessionErrorCount": 0,
- "messageListPaintCount": 0,
- "longTaskCount": 0,
- "threadItemsScanDeferredCount": 0,
- "maxUsedJSHeapSize": 856879995,
- "phases": [
- "homeInput.submit",
- "homeInput.pendingShellApplied",
- "homeInput.pendingPreviewPaint",
- "homeInput.sendDispatch.start",
- "workspaceSend.plan.ready",
- "agentStream.assistantDraft",
- "agentStream.ensureSession.start",
- "agentStream.assistantDraftPaint",
- "agentStream.ensureSession.done",
- "agentStream.request.start",
- "agentStream.listenerBound",
- "agentStream.submitDispatched",
- "agentStream.submitAccepted",
- "homeInput.sendDispatch.done",
- "agentStream.firstEvent",
- "agentStream.firstRuntimeStatus",
- "agentStream.firstThinkingDelta",
- "agentStream.firstTextDelta",
- "agentStream.firstTextPaint"
- ]
- }
- ]
- }
-}
\ No newline at end of file
diff --git a/playwright-agentui-after-newline-gate-immediate-20260509.json b/playwright-agentui-after-newline-gate-immediate-20260509.json
deleted file mode 100644
index d17ff0a94..000000000
--- a/playwright-agentui-after-newline-gate-immediate-20260509.json
+++ /dev/null
@@ -1,524 +0,0 @@
-{
- "placeholderText": null,
- "bodyTail": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n1分\n新对话\n3分\n新对话\n25分\n新对话\n29分\n尽快显示“思考中”状态是为了消解用户...\n31分\n新对话\n34分\n已完成回答。如有其他问题,随时告诉我...\n39分\n首字前尽快显示思考状态能第一时间确认...\n40分\n已为你整理为三句回答,内容直接说明“...\n42分\n**三步思考:**\n1时\n查看更多对话\n归档\n开\n开源使用\n本地可用\nKnowledge Manual E2E 38362\nHarness\n新对话\n\n请用三句话说明:为什么流式输出要按换行或小批次显示?\n\n思考中\n思考中\n\n我们需要\n\n稍后处理\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
- "summary": {
- "entries": [
- {
- "id": 1,
- "phase": "agentUi.longTask",
- "at": 739.1000000089407,
- "wallTime": 1778320226396,
- "sessionId": null,
- "workspaceId": null,
- "source": null,
- "metrics": {
- "durationMs": 294,
- "name": "self",
- "startTimeMs": 442.70000000298023,
- "usedJSHeapSize": 842309980,
- "totalJSHeapSize": 864292744
- }
- },
- {
- "id": 2,
- "phase": "agentRuntime.listSessions.start",
- "at": 1408.2000000029802,
- "wallTime": 1778320227065,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 11,
- "usedJSHeapSize": 872513737,
- "totalJSHeapSize": 890716065
- }
- },
- {
- "id": 3,
- "phase": "agentRuntime.listSessions.success",
- "at": 1754.1000000089407,
- "wallTime": 1778320227411,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 11,
- "durationMs": 346,
- "sessionsCount": 11,
- "usedJSHeapSize": 880190927,
- "totalJSHeapSize": 899842987
- }
- },
- {
- "id": 4,
- "phase": "appSidebar.recentConversations.loadBreakdown",
- "at": 1754.5,
- "wallTime": 1778320227411,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "hasMore": true,
- "limit": 11,
- "listDurationMs": 346,
- "sessionsCount": 11,
- "sortDurationMs": 0,
- "totalDurationMs": 346,
- "visibleCount": 10,
- "usedJSHeapSize": 880190927,
- "totalJSHeapSize": 899842987
- }
- },
- {
- "id": 5,
- "phase": "agentRuntime.listSessions.start",
- "at": 1777.1000000089407,
- "wallTime": 1778320227434,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 21,
- "usedJSHeapSize": 880190927,
- "totalJSHeapSize": 899842987
- }
- },
- {
- "id": 6,
- "phase": "agentRuntime.listSessions.success",
- "at": 1886.2000000029802,
- "wallTime": 1778320227543,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 21,
- "durationMs": 109,
- "sessionsCount": 21,
- "usedJSHeapSize": 886964131,
- "totalJSHeapSize": 902913803
- }
- },
- {
- "id": 7,
- "phase": "sidebar.recentConversations.loadBreakdown",
- "at": 1886.6000000089407,
- "wallTime": 1778320227543,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "auxiliaryFilterDurationMs": 0,
- "hiddenAuxiliarySessionsCount": 0,
- "limit": 21,
- "listDurationMs": 109,
- "sessionsCount": 21,
- "topicMapDurationMs": 0,
- "topicsCount": 21,
- "totalDurationMs": 109,
- "workspaceFilterDurationMs": 0,
- "workspaceSessionsCount": 21,
- "usedJSHeapSize": 886964131,
- "totalJSHeapSize": 902913803
- }
- },
- {
- "id": 8,
- "phase": "homeInput.submit",
- "at": 49389.40000000596,
- "wallTime": 1778320275047,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "hasDraftTab": false,
- "inputLength": 26,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 849131145,
- "totalJSHeapSize": 857579141
- }
- },
- {
- "id": 9,
- "phase": "homeInput.pendingShellApplied",
- "at": 49389.60000000894,
- "wallTime": 1778320275047,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 0,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 849131145,
- "totalJSHeapSize": 857579141
- }
- },
- {
- "id": 10,
- "phase": "homeInput.pendingPreviewPaint",
- "at": 49434.5,
- "wallTime": 1778320275092,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 45,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 849131145,
- "totalJSHeapSize": 857579141
- }
- },
- {
- "id": 11,
- "phase": "homeInput.sendDispatch.start",
- "at": 49434.60000000894,
- "wallTime": 1778320275092,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 45,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 849131145,
- "totalJSHeapSize": 857579141
- }
- },
- {
- "id": 12,
- "phase": "workspaceSend.plan.ready",
- "at": 49441.79999999702,
- "wallTime": 1778320275100,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 7,
- "hasPendingSessionBinding": false,
- "primedSessionId": null,
- "requestId": "draft-send-moy5z61z-b632176f",
- "source": "empty-state",
- "usedJSHeapSize": 851690732,
- "totalJSHeapSize": 859507912
- }
- },
- {
- "id": 13,
- "phase": "agentStream.assistantDraft",
- "at": 49443.90000000596,
- "wallTime": 1778320275102,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "assistantContentLength": 0,
- "expectingQueue": false,
- "hasAssistantDraftContent": false,
- "phase": "routing",
- "statusTitle": "快速响应已启用",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "homeSubmittedDeltaMs": 55,
- "usedJSHeapSize": 851690732,
- "totalJSHeapSize": 859507912
- }
- },
- {
- "id": 14,
- "phase": "agentStream.ensureSession.start",
- "at": 49445.10000000894,
- "wallTime": 1778320275103,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "hadActiveSessionBeforeEnsure": false,
- "skipSessionRestore": true,
- "skipSessionStartHooks": true,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "homeSubmittedDeltaMs": 56,
- "usedJSHeapSize": 851690732,
- "totalJSHeapSize": 859507912
- }
- },
- {
- "id": 15,
- "phase": "agentStream.assistantDraftPaint",
- "at": 49463.90000000596,
- "wallTime": 1778320275122,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "assistantContentLength": 0,
- "expectingQueue": false,
- "hasAssistantDraftContent": false,
- "phase": "routing",
- "statusTitle": "快速响应已启用",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "homeSubmittedDeltaMs": 75,
- "usedJSHeapSize": 851690732,
- "totalJSHeapSize": 859507912
- }
- },
- {
- "id": 16,
- "phase": "agentStream.ensureSession.done",
- "at": 49515.20000000298,
- "wallTime": 1778320275173,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "activeSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "durationMs": 70,
- "hadActiveSessionBeforeEnsure": false,
- "skipSessionRestore": true,
- "skipSessionStartHooks": true,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 126,
- "usedJSHeapSize": 849772078,
- "totalJSHeapSize": 863355806
- }
- },
- {
- "id": 17,
- "phase": "agentStream.request.start",
- "at": 49516.20000000298,
- "wallTime": 1778320275174,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "contentLength": 26,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "expectingQueue": false,
- "model": "deepseek-chat",
- "provider": "deepseek",
- "skipUserMessage": false,
- "systemPromptLength": 163,
- "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月9日。\n本回合是轻量首轮普通对话,请直接",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 127,
- "usedJSHeapSize": 849772078,
- "totalJSHeapSize": 863355806
- }
- },
- {
- "id": 18,
- "phase": "agentStream.listenerBound",
- "at": 49542.79999999702,
- "wallTime": 1778320275201,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 27,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "expectingQueue": false,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 154,
- "usedJSHeapSize": 849772078,
- "totalJSHeapSize": 863355806
- }
- },
- {
- "id": 19,
- "phase": "agentStream.submitDispatched",
- "at": 49543.70000000298,
- "wallTime": 1778320275202,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 28,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "expectingQueue": false,
- "listenerBoundDeltaMs": 1,
- "model": "deepseek-chat",
- "provider": "deepseek",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 155,
- "usedJSHeapSize": 849772078,
- "totalJSHeapSize": 863355806
- }
- },
- {
- "id": 20,
- "phase": "agentStream.submitAccepted",
- "at": 50006.70000000298,
- "wallTime": 1778320275665,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 490,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "submitInvokeMs": 462,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 618,
- "usedJSHeapSize": 852504645,
- "totalJSHeapSize": 863797681
- }
- },
- {
- "id": 21,
- "phase": "homeInput.sendDispatch.done",
- "at": 50007.70000000298,
- "wallTime": 1778320275665,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 618,
- "requestId": "draft-send-moy5z61z-b632176f",
- "result": true,
- "source": "empty-state",
- "usedJSHeapSize": 852504645,
- "totalJSHeapSize": 863797681
- }
- },
- {
- "id": 22,
- "phase": "agentStream.firstEvent",
- "at": 50314.5,
- "wallTime": 1778320275972,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 798,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "eventType": "runtime_status",
- "recognized": true,
- "submissionDispatchedDeltaMs": 770,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 925,
- "usedJSHeapSize": 851619235,
- "totalJSHeapSize": 864354991
- }
- },
- {
- "id": 23,
- "phase": "agentStream.firstRuntimeStatus",
- "at": 50316.10000000894,
- "wallTime": 1778320275974,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 800,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "firstEventDeltaMs": 2,
- "phase": "preparing",
- "title": "已接收请求,正在准备执行",
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 927,
- "usedJSHeapSize": 851619235,
- "totalJSHeapSize": 864354991
- }
- },
- {
- "id": 24,
- "phase": "agentStream.firstThinkingDelta",
- "at": 53084.79999999702,
- "wallTime": 1778320278743,
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "deltaChars": 4,
- "elapsedMs": 3569,
- "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb",
- "firstEventDeltaMs": 2771,
- "firstRuntimeStatusDeltaMs": 2769,
- "surfaced": false,
- "source": "empty-state",
- "requestId": "draft-send-moy5z61z-b632176f",
- "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6",
- "homeSubmittedDeltaMs": 3696,
- "usedJSHeapSize": 856004274,
- "totalJSHeapSize": 870653242
- }
- }
- ],
- "sessions": [
- {
- "sessionId": "draft-send-moy5z61z-b632176f",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "homeInputToPendingShellMs": 0,
- "homeInputToPendingPreviewPaintMs": 45,
- "homeInputToSendDispatchMs": 45,
- "homeInputToSendPlanReadyMs": 52,
- "homeInputToAssistantDraftMs": 55,
- "homeInputToAssistantDraftPaintMs": 75,
- "homeInputToStreamRequestStartMs": 127,
- "homeInputToSubmitAcceptedMs": 617,
- "homeInputToFirstEventMs": 925,
- "homeInputToFirstRuntimeStatusMs": 927,
- "homeInputToFirstThinkingDeltaMs": 3695,
- "sendDispatchToSubmitAcceptedMs": 572,
- "streamSubmitDispatchedToAcceptedMs": 463,
- "submitAcceptedToFirstEventMs": 308,
- "firstEventToFirstThinkingDeltaMs": 2770,
- "streamEnsureSessionDurationMs": 70,
- "streamSubmitInvokeDurationMs": 462,
- "switchStartCount": 0,
- "fetchDetailStartCount": 0,
- "fetchDetailErrorCount": 0,
- "runtimeGetSessionStartCount": 0,
- "runtimeGetSessionErrorCount": 0,
- "messageListPaintCount": 0,
- "longTaskCount": 0,
- "threadItemsScanDeferredCount": 0,
- "maxUsedJSHeapSize": 856004274,
- "phases": [
- "homeInput.submit",
- "homeInput.pendingShellApplied",
- "homeInput.pendingPreviewPaint",
- "homeInput.sendDispatch.start",
- "workspaceSend.plan.ready",
- "agentStream.assistantDraft",
- "agentStream.ensureSession.start",
- "agentStream.assistantDraftPaint",
- "agentStream.ensureSession.done",
- "agentStream.request.start",
- "agentStream.listenerBound",
- "agentStream.submitDispatched",
- "agentStream.submitAccepted",
- "homeInput.sendDispatch.done",
- "agentStream.firstEvent",
- "agentStream.firstRuntimeStatus",
- "agentStream.firstThinkingDelta"
- ]
- }
- ]
- }
-}
\ No newline at end of file
diff --git a/playwright-agentui-perf-shape-20260509.json b/playwright-agentui-perf-shape-20260509.json
deleted file mode 100644
index 0027d97ec..000000000
--- a/playwright-agentui-perf-shape-20260509.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "type": "object",
- "keys": [
- "entries",
- "clear",
- "summary"
- ],
- "sample": {}
-}
\ No newline at end of file
diff --git a/playwright-deepseek-after-reasoning-fix-20260509.json b/playwright-deepseek-after-reasoning-fix-20260509.json
deleted file mode 100644
index e566fc53f..000000000
--- a/playwright-deepseek-after-reasoning-fix-20260509.json
+++ /dev/null
@@ -1,60 +0,0 @@
-{
- "url": "http://127.0.0.1:1420/",
- "tail": [
- "Lime",
- "邀请好友",
- "搜索任务",
- "新建任务",
- "Skills",
- "灵感",
- "项目资料",
- "最近对话",
- "好",
- "17分",
- "新对话",
- "26分",
- "新对话",
- "28分",
- "新对话",
- "30分",
- "新对话",
- "33分",
- "Ran into this erro...",
- "36分",
- "好",
- "38分",
- "新对话",
- "1时",
- "好",
- "1时",
- "Ran into this erro...",
- "2时",
- "查看更多对话",
- "归档",
- "开",
- "开源使用",
- "本地可用",
- "Knowledge Manual E2E 38362",
- "Harness",
- "新对话",
- "只回答一个字:好",
- "好",
- "已完成",
- "·",
- "00:01",
- "添加资料",
- "模型",
- "deepseek-v4-pro",
- "高级设置"
- ],
- "containsReasoningHeading": false,
- "containsReasoningText": false,
- "containsRanIntoError": false,
- "containsRetryText": false,
- "projectionTail": null,
- "localStoragePref": {
- "provider": "\"deepseek\"",
- "model": "\"deepseek-v4-pro\"",
- "backup": null
- }
-}
\ No newline at end of file
diff --git a/playwright-deepseek-perf-20260509.json b/playwright-deepseek-perf-20260509.json
deleted file mode 100644
index cdcbc86f0..000000000
--- a/playwright-deepseek-perf-20260509.json
+++ /dev/null
@@ -1,477 +0,0 @@
-{
- "summary": {
- "entries": [
- {
- "id": 1,
- "phase": "homeInput.submit",
- "at": 1031750.8999999985,
- "wallTime": 1778306573822,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "hasDraftTab": false,
- "inputLength": 8,
- "requestId": "draft-send-moxxti4e-f13111a9",
- "source": "empty-state",
- "usedJSHeapSize": 404318882,
- "totalJSHeapSize": 409574158
- }
- },
- {
- "id": 2,
- "phase": "homeInput.pendingShellApplied",
- "at": 1031751.1000000015,
- "wallTime": 1778306573822,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 0,
- "requestId": "draft-send-moxxti4e-f13111a9",
- "source": "empty-state",
- "usedJSHeapSize": 404318882,
- "totalJSHeapSize": 409574158
- }
- },
- {
- "id": 3,
- "phase": "homeInput.pendingPreviewPaint",
- "at": 1031803.1000000015,
- "wallTime": 1778306573874,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 52,
- "requestId": "draft-send-moxxti4e-f13111a9",
- "source": "empty-state",
- "usedJSHeapSize": 405980766,
- "totalJSHeapSize": 413862358
- }
- },
- {
- "id": 4,
- "phase": "homeInput.sendDispatch.start",
- "at": 1031803.200000003,
- "wallTime": 1778306573874,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 52,
- "requestId": "draft-send-moxxti4e-f13111a9",
- "source": "empty-state",
- "usedJSHeapSize": 405980766,
- "totalJSHeapSize": 413862358
- }
- },
- {
- "id": 5,
- "phase": "workspaceSend.plan.ready",
- "at": 1031812.200000003,
- "wallTime": 1778306573883,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 9,
- "hasPendingSessionBinding": false,
- "primedSessionId": null,
- "requestId": "draft-send-moxxti4e-f13111a9",
- "source": "empty-state",
- "usedJSHeapSize": 405980766,
- "totalJSHeapSize": 413862358
- }
- },
- {
- "id": 6,
- "phase": "agentStream.assistantDraft",
- "at": 1031817.799999997,
- "wallTime": 1778306573888,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "assistantContentLength": 0,
- "expectingQueue": false,
- "hasAssistantDraftContent": false,
- "phase": "routing",
- "statusTitle": "快速响应已启用",
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "homeSubmittedDeltaMs": 66,
- "usedJSHeapSize": 405980766,
- "totalJSHeapSize": 413862358
- }
- },
- {
- "id": 7,
- "phase": "agentStream.ensureSession.start",
- "at": 1031818.8999999985,
- "wallTime": 1778306573890,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "hadActiveSessionBeforeEnsure": false,
- "skipSessionRestore": true,
- "skipSessionStartHooks": true,
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "homeSubmittedDeltaMs": 68,
- "usedJSHeapSize": 405980766,
- "totalJSHeapSize": 413862358
- }
- },
- {
- "id": 8,
- "phase": "agentUi.longTask",
- "at": 1031839.700000003,
- "wallTime": 1778306573910,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "durationMs": 50,
- "name": "self",
- "startTimeMs": 1031749.5,
- "usedJSHeapSize": 405980766,
- "totalJSHeapSize": 413862358
- }
- },
- {
- "id": 9,
- "phase": "agentStream.assistantDraftPaint",
- "at": 1031849.3999999985,
- "wallTime": 1778306573920,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "assistantContentLength": 0,
- "expectingQueue": false,
- "hasAssistantDraftContent": false,
- "phase": "routing",
- "statusTitle": "快速响应已启用",
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "homeSubmittedDeltaMs": 98,
- "usedJSHeapSize": 405980766,
- "totalJSHeapSize": 413862358
- }
- },
- {
- "id": 10,
- "phase": "agentStream.ensureSession.done",
- "at": 1031857.700000003,
- "wallTime": 1778306573928,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "activeSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "durationMs": 38,
- "hadActiveSessionBeforeEnsure": false,
- "skipSessionRestore": true,
- "skipSessionStartHooks": true,
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "homeSubmittedDeltaMs": 106,
- "usedJSHeapSize": 406885565,
- "totalJSHeapSize": 414776105
- }
- },
- {
- "id": 11,
- "phase": "agentStream.request.start",
- "at": 1031858.3999999985,
- "wallTime": 1778306573929,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "contentLength": 8,
- "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328",
- "expectingQueue": false,
- "model": "deepseek-chat",
- "provider": "deepseek",
- "skipUserMessage": false,
- "systemPromptLength": 163,
- "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月9日。\n本回合是轻量首轮普通对话,请直接",
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "homeSubmittedDeltaMs": 107,
- "usedJSHeapSize": 406885565,
- "totalJSHeapSize": 414776105
- }
- },
- {
- "id": 12,
- "phase": "agentStream.listenerBound",
- "at": 1031886.5,
- "wallTime": 1778306573957,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 28,
- "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328",
- "expectingQueue": false,
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "homeSubmittedDeltaMs": 135,
- "usedJSHeapSize": 406885565,
- "totalJSHeapSize": 414776105
- }
- },
- {
- "id": 13,
- "phase": "agentStream.submitDispatched",
- "at": 1031886.799999997,
- "wallTime": 1778306573957,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 28,
- "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328",
- "expectingQueue": false,
- "listenerBoundDeltaMs": 0,
- "model": "deepseek-chat",
- "provider": "deepseek",
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "homeSubmittedDeltaMs": 135,
- "usedJSHeapSize": 406885565,
- "totalJSHeapSize": 414776105
- }
- },
- {
- "id": 14,
- "phase": "agentStream.submitAccepted",
- "at": 1031961,
- "wallTime": 1778306574032,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 102,
- "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328",
- "submitInvokeMs": 74,
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "homeSubmittedDeltaMs": 210,
- "usedJSHeapSize": 409902058,
- "totalJSHeapSize": 416864258
- }
- },
- {
- "id": 15,
- "phase": "homeInput.sendDispatch.done",
- "at": 1031961.299999997,
- "wallTime": 1778306574032,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 210,
- "requestId": "draft-send-moxxti4e-f13111a9",
- "result": true,
- "source": "empty-state",
- "usedJSHeapSize": 409902058,
- "totalJSHeapSize": 416864258
- }
- },
- {
- "id": 16,
- "phase": "agentStream.firstEvent",
- "at": 1031980.5,
- "wallTime": 1778306574051,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 122,
- "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328",
- "eventType": "runtime_status",
- "recognized": true,
- "submissionDispatchedDeltaMs": 94,
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "homeSubmittedDeltaMs": 229,
- "usedJSHeapSize": 409902058,
- "totalJSHeapSize": 416864258
- }
- },
- {
- "id": 17,
- "phase": "agentStream.firstRuntimeStatus",
- "at": 1031981,
- "wallTime": 1778306574052,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 123,
- "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328",
- "firstEventDeltaMs": 1,
- "phase": "preparing",
- "title": "已接收请求,正在准备执行",
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "homeSubmittedDeltaMs": 230,
- "usedJSHeapSize": 409902058,
- "totalJSHeapSize": 416864258
- }
- },
- {
- "id": 18,
- "phase": "agentStream.firstTextDelta",
- "at": 1034232.1000000015,
- "wallTime": 1778306576303,
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "deltaChars": 1,
- "elapsedMs": 2374,
- "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328",
- "firstEventDeltaMs": 2252,
- "firstRuntimeStatusDeltaMs": 2251,
- "source": "empty-state",
- "requestId": "draft-send-moxxti4e-f13111a9",
- "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "homeSubmittedDeltaMs": 2481,
- "usedJSHeapSize": 410747015,
- "totalJSHeapSize": 420551075
- }
- },
- {
- "id": 19,
- "phase": "agentRuntime.getSession.start",
- "at": 1034933.200000003,
- "wallTime": 1778306577004,
- "sessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "workspaceId": null,
- "source": null,
- "metrics": {
- "historyLimit": 40,
- "historyOffset": null,
- "historyBeforeMessageId": null,
- "resumeSessionStartHooks": false,
- "usedJSHeapSize": 410207946,
- "totalJSHeapSize": 420553286
- }
- },
- {
- "id": 20,
- "phase": "agentRuntime.getSession.success",
- "at": 1034964.799999997,
- "wallTime": 1778306577035,
- "sessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "workspaceId": null,
- "source": null,
- "metrics": {
- "historyLimit": 40,
- "historyOffset": null,
- "historyBeforeMessageId": null,
- "resumeSessionStartHooks": false,
- "childSubagentSessionsCount": 0,
- "durationMs": 31,
- "itemsCount": 4,
- "messagesCount": 2,
- "queuedTurnsCount": 0,
- "turnsCount": 1,
- "usedJSHeapSize": 410207946,
- "totalJSHeapSize": 420553286
- }
- }
- ],
- "sessions": [
- {
- "sessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17",
- "workspaceId": null,
- "runtimeGetSessionDurationMs": 31,
- "switchStartCount": 0,
- "fetchDetailStartCount": 0,
- "fetchDetailErrorCount": 0,
- "runtimeGetSessionStartCount": 1,
- "runtimeGetSessionErrorCount": 0,
- "messageListPaintCount": 0,
- "longTaskCount": 0,
- "threadItemsScanDeferredCount": 0,
- "maxUsedJSHeapSize": 410207946,
- "phases": [
- "agentRuntime.getSession.start",
- "agentRuntime.getSession.success"
- ]
- },
- {
- "sessionId": "draft-send-moxxti4e-f13111a9",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "homeInputToPendingShellMs": 0,
- "homeInputToPendingPreviewPaintMs": 52,
- "homeInputToSendDispatchMs": 52,
- "homeInputToSendPlanReadyMs": 61,
- "homeInputToAssistantDraftMs": 67,
- "homeInputToAssistantDraftPaintMs": 99,
- "homeInputToStreamRequestStartMs": 108,
- "homeInputToSubmitAcceptedMs": 210,
- "homeInputToFirstEventMs": 230,
- "homeInputToFirstRuntimeStatusMs": 230,
- "homeInputToFirstTextDeltaMs": 2481,
- "sendDispatchToSubmitAcceptedMs": 158,
- "streamSubmitDispatchedToAcceptedMs": 74,
- "submitAcceptedToFirstEventMs": 20,
- "firstEventToFirstTextDeltaMs": 2252,
- "streamEnsureSessionDurationMs": 38,
- "streamSubmitInvokeDurationMs": 74,
- "switchStartCount": 0,
- "fetchDetailStartCount": 0,
- "fetchDetailErrorCount": 0,
- "runtimeGetSessionStartCount": 0,
- "runtimeGetSessionErrorCount": 0,
- "messageListPaintCount": 0,
- "longTaskCount": 1,
- "longTaskMaxMs": 50,
- "threadItemsScanDeferredCount": 0,
- "maxUsedJSHeapSize": 410747015,
- "phases": [
- "homeInput.submit",
- "homeInput.pendingShellApplied",
- "homeInput.pendingPreviewPaint",
- "homeInput.sendDispatch.start",
- "workspaceSend.plan.ready",
- "agentStream.assistantDraft",
- "agentStream.ensureSession.start",
- "agentUi.longTask",
- "agentStream.assistantDraftPaint",
- "agentStream.ensureSession.done",
- "agentStream.request.start",
- "agentStream.listenerBound",
- "agentStream.submitDispatched",
- "agentStream.submitAccepted",
- "homeInput.sendDispatch.done",
- "agentStream.firstEvent",
- "agentStream.firstRuntimeStatus",
- "agentStream.firstTextDelta"
- ]
- }
- ]
- },
- "current": {
- "hasRanInto": false,
- "hasPleaseRetry": false,
- "hasPermissionFallback": false,
- "hasGood": true,
- "bodyTail": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\nRan into this erro...\n17分\n好\n20分\n新对话\n52分\n好\n54分\nRan into this erro...\n1时\n新对话\n1时\n新对话\n1时\n新对话\n1时\n新对话\n2时\n新对话\n2时\n查看更多对话\n归档\n开\n开源使用\n本地可用\nKnowledge Manual E2E 38362\nHarness\n新对话\n\n只回答一个字:好\n\n已完成思考\n我们被要求只回答一个字:好。直接回复即可。\n\n好\n\n已完成\n·\n00:02\n添加资料\n模型\ndeepseek-v4-pro\n高级设置"
- }
-}
\ No newline at end of file
diff --git a/playwright-deepseek-perf-after-reasoning-fix-20260509.json b/playwright-deepseek-perf-after-reasoning-fix-20260509.json
deleted file mode 100644
index 1ec0deee5..000000000
--- a/playwright-deepseek-perf-after-reasoning-fix-20260509.json
+++ /dev/null
@@ -1,584 +0,0 @@
-{
- "entriesType": "function",
- "entriesIsArray": false,
- "entriesLength": null,
- "summaryType": "function",
- "summary": {
- "entries": [
- {
- "id": 1,
- "phase": "agentUi.longTask",
- "at": 481.20000000298023,
- "wallTime": 1778307552467,
- "sessionId": null,
- "workspaceId": null,
- "source": null,
- "metrics": {
- "durationMs": 237,
- "name": "self",
- "startTimeMs": 243.30000000447035,
- "usedJSHeapSize": 447504202,
- "totalJSHeapSize": 474277134
- }
- },
- {
- "id": 2,
- "phase": "agentRuntime.listSessions.start",
- "at": 1204,
- "wallTime": 1778307553189,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 11,
- "usedJSHeapSize": 477410622,
- "totalJSHeapSize": 498620686
- }
- },
- {
- "id": 3,
- "phase": "agentRuntime.listSessions.start",
- "at": 1211.4000000059605,
- "wallTime": 1778307553197,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 21,
- "usedJSHeapSize": 477410622,
- "totalJSHeapSize": 498620686
- }
- },
- {
- "id": 4,
- "phase": "agentRuntime.listSessions.success",
- "at": 1352.9000000059605,
- "wallTime": 1778307553338,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 11,
- "durationMs": 149,
- "sessionsCount": 11,
- "usedJSHeapSize": 482634552,
- "totalJSHeapSize": 499059748
- }
- },
- {
- "id": 5,
- "phase": "appSidebar.recentConversations.loadBreakdown",
- "at": 1353.2000000029802,
- "wallTime": 1778307553339,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "hasMore": true,
- "limit": 11,
- "listDurationMs": 150,
- "sessionsCount": 11,
- "sortDurationMs": 0,
- "totalDurationMs": 150,
- "visibleCount": 10,
- "usedJSHeapSize": 482634552,
- "totalJSHeapSize": 499059748
- }
- },
- {
- "id": 6,
- "phase": "agentRuntime.listSessions.success",
- "at": 1393.6000000014901,
- "wallTime": 1778307553379,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "archivedOnly": false,
- "includeArchived": false,
- "limit": 21,
- "durationMs": 182,
- "sessionsCount": 21,
- "usedJSHeapSize": 482634552,
- "totalJSHeapSize": 499059748
- }
- },
- {
- "id": 7,
- "phase": "sidebar.recentConversations.loadBreakdown",
- "at": 1394.1000000014901,
- "wallTime": 1778307553380,
- "sessionId": null,
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": null,
- "metrics": {
- "auxiliaryFilterDurationMs": 0,
- "hiddenAuxiliarySessionsCount": 0,
- "limit": 21,
- "listDurationMs": 182,
- "sessionsCount": 21,
- "topicMapDurationMs": 0,
- "topicsCount": 21,
- "totalDurationMs": 183,
- "workspaceFilterDurationMs": 1,
- "workspaceSessionsCount": 21,
- "usedJSHeapSize": 482634552,
- "totalJSHeapSize": 499059748
- }
- },
- {
- "id": 8,
- "phase": "homeInput.submit",
- "at": 126288.20000000298,
- "wallTime": 1778307678271,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "hasDraftTab": false,
- "inputLength": 8,
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "source": "empty-state",
- "usedJSHeapSize": 445444712,
- "totalJSHeapSize": 452310960
- }
- },
- {
- "id": 9,
- "phase": "homeInput.pendingShellApplied",
- "at": 126288.30000000447,
- "wallTime": 1778307678271,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 0,
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "source": "empty-state",
- "usedJSHeapSize": 445444712,
- "totalJSHeapSize": 452310960
- }
- },
- {
- "id": 10,
- "phase": "homeInput.pendingPreviewPaint",
- "at": 126317.5,
- "wallTime": 1778307678300,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 29,
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "source": "empty-state",
- "usedJSHeapSize": 445444712,
- "totalJSHeapSize": 452310960
- }
- },
- {
- "id": 11,
- "phase": "homeInput.sendDispatch.start",
- "at": 126317.60000000149,
- "wallTime": 1778307678301,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 30,
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "source": "empty-state",
- "usedJSHeapSize": 445444712,
- "totalJSHeapSize": 452310960
- }
- },
- {
- "id": 12,
- "phase": "workspaceSend.plan.ready",
- "at": 126323.60000000149,
- "wallTime": 1778307678307,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 6,
- "hasPendingSessionBinding": false,
- "primedSessionId": null,
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "source": "empty-state",
- "usedJSHeapSize": 445444712,
- "totalJSHeapSize": 452310960
- }
- },
- {
- "id": 13,
- "phase": "agentStream.assistantDraft",
- "at": 126325.40000000596,
- "wallTime": 1778307678308,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "assistantContentLength": 0,
- "expectingQueue": false,
- "hasAssistantDraftContent": false,
- "phase": "routing",
- "statusTitle": "快速响应已启用",
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "homeSubmittedDeltaMs": 37,
- "usedJSHeapSize": 445444712,
- "totalJSHeapSize": 452310960
- }
- },
- {
- "id": 14,
- "phase": "agentStream.ensureSession.start",
- "at": 126326.10000000149,
- "wallTime": 1778307678309,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "hadActiveSessionBeforeEnsure": false,
- "skipSessionRestore": true,
- "skipSessionStartHooks": true,
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "homeSubmittedDeltaMs": 38,
- "usedJSHeapSize": 445444712,
- "totalJSHeapSize": 452310960
- }
- },
- {
- "id": 15,
- "phase": "agentStream.assistantDraftPaint",
- "at": 126350.80000000447,
- "wallTime": 1778307678334,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "assistantContentLength": 0,
- "expectingQueue": false,
- "hasAssistantDraftContent": false,
- "phase": "routing",
- "statusTitle": "快速响应已启用",
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "homeSubmittedDeltaMs": 63,
- "usedJSHeapSize": 449665756,
- "totalJSHeapSize": 458391272
- }
- },
- {
- "id": 16,
- "phase": "agentStream.ensureSession.done",
- "at": 126360.30000000447,
- "wallTime": 1778307678343,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "activeSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "durationMs": 34,
- "hadActiveSessionBeforeEnsure": false,
- "skipSessionRestore": true,
- "skipSessionStartHooks": true,
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "homeSubmittedDeltaMs": 72,
- "usedJSHeapSize": 449665756,
- "totalJSHeapSize": 458391272
- }
- },
- {
- "id": 17,
- "phase": "agentStream.request.start",
- "at": 126360.80000000447,
- "wallTime": 1778307678344,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "contentLength": 8,
- "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871",
- "expectingQueue": false,
- "model": "deepseek-chat",
- "provider": "deepseek",
- "skipUserMessage": false,
- "systemPromptLength": 163,
- "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月9日。\n本回合是轻量首轮普通对话,请直接",
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "homeSubmittedDeltaMs": 73,
- "usedJSHeapSize": 449665756,
- "totalJSHeapSize": 458391272
- }
- },
- {
- "id": 18,
- "phase": "agentStream.listenerBound",
- "at": 126387.10000000149,
- "wallTime": 1778307678370,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 26,
- "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871",
- "expectingQueue": false,
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "homeSubmittedDeltaMs": 99,
- "usedJSHeapSize": 449665756,
- "totalJSHeapSize": 458391272
- }
- },
- {
- "id": 19,
- "phase": "agentStream.submitDispatched",
- "at": 126387.60000000149,
- "wallTime": 1778307678371,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 27,
- "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871",
- "expectingQueue": false,
- "listenerBoundDeltaMs": 1,
- "model": "deepseek-chat",
- "provider": "deepseek",
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "homeSubmittedDeltaMs": 100,
- "usedJSHeapSize": 449665756,
- "totalJSHeapSize": 458391272
- }
- },
- {
- "id": 20,
- "phase": "agentStream.submitAccepted",
- "at": 126464.80000000447,
- "wallTime": 1778307678448,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 104,
- "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871",
- "submitInvokeMs": 77,
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "homeSubmittedDeltaMs": 177,
- "usedJSHeapSize": 449486441,
- "totalJSHeapSize": 457357513
- }
- },
- {
- "id": 21,
- "phase": "homeInput.sendDispatch.done",
- "at": 126465.10000000149,
- "wallTime": 1778307678448,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "durationMs": 177,
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "result": true,
- "source": "empty-state",
- "usedJSHeapSize": 449486441,
- "totalJSHeapSize": 457357513
- }
- },
- {
- "id": 22,
- "phase": "agentStream.firstEvent",
- "at": 126486.40000000596,
- "wallTime": 1778307678469,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 125,
- "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871",
- "eventType": "runtime_status",
- "recognized": true,
- "submissionDispatchedDeltaMs": 98,
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "homeSubmittedDeltaMs": 198,
- "usedJSHeapSize": 449486441,
- "totalJSHeapSize": 457357513
- }
- },
- {
- "id": 23,
- "phase": "agentStream.firstRuntimeStatus",
- "at": 126487.5,
- "wallTime": 1778307678470,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "elapsedMs": 126,
- "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871",
- "firstEventDeltaMs": 1,
- "phase": "preparing",
- "title": "已接收请求,正在准备执行",
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "homeSubmittedDeltaMs": 199,
- "usedJSHeapSize": 449486441,
- "totalJSHeapSize": 457357513
- }
- },
- {
- "id": 24,
- "phase": "agentStream.firstTextDelta",
- "at": 128384.90000000596,
- "wallTime": 1778307680368,
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "source": "empty-state",
- "metrics": {
- "deltaChars": 1,
- "elapsedMs": 2024,
- "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871",
- "firstEventDeltaMs": 1899,
- "firstRuntimeStatusDeltaMs": 1898,
- "source": "empty-state",
- "requestId": "draft-send-moxyh6bj-26676ae0",
- "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "homeSubmittedDeltaMs": 2097,
- "usedJSHeapSize": 449919337,
- "totalJSHeapSize": 462355177
- }
- },
- {
- "id": 25,
- "phase": "agentRuntime.getSession.start",
- "at": 128537.5,
- "wallTime": 1778307680520,
- "sessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "workspaceId": null,
- "source": null,
- "metrics": {
- "historyLimit": 40,
- "historyOffset": null,
- "historyBeforeMessageId": null,
- "resumeSessionStartHooks": false,
- "usedJSHeapSize": 451476819,
- "totalJSHeapSize": 462357291
- }
- },
- {
- "id": 26,
- "phase": "agentRuntime.getSession.success",
- "at": 128573.20000000298,
- "wallTime": 1778307680556,
- "sessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "workspaceId": null,
- "source": null,
- "metrics": {
- "historyLimit": 40,
- "historyOffset": null,
- "historyBeforeMessageId": null,
- "resumeSessionStartHooks": false,
- "childSubagentSessionsCount": 0,
- "durationMs": 36,
- "itemsCount": 4,
- "messagesCount": 2,
- "queuedTurnsCount": 0,
- "turnsCount": 1,
- "usedJSHeapSize": 451476819,
- "totalJSHeapSize": 462357291
- }
- }
- ],
- "sessions": [
- {
- "sessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686",
- "workspaceId": null,
- "runtimeGetSessionDurationMs": 36,
- "switchStartCount": 0,
- "fetchDetailStartCount": 0,
- "fetchDetailErrorCount": 0,
- "runtimeGetSessionStartCount": 1,
- "runtimeGetSessionErrorCount": 0,
- "messageListPaintCount": 0,
- "longTaskCount": 0,
- "threadItemsScanDeferredCount": 0,
- "maxUsedJSHeapSize": 451476819,
- "phases": [
- "agentRuntime.getSession.start",
- "agentRuntime.getSession.success"
- ]
- },
- {
- "sessionId": "draft-send-moxyh6bj-26676ae0",
- "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd",
- "homeInputToPendingShellMs": 0,
- "homeInputToPendingPreviewPaintMs": 29,
- "homeInputToSendDispatchMs": 29,
- "homeInputToSendPlanReadyMs": 35,
- "homeInputToAssistantDraftMs": 37,
- "homeInputToAssistantDraftPaintMs": 63,
- "homeInputToStreamRequestStartMs": 73,
- "homeInputToSubmitAcceptedMs": 177,
- "homeInputToFirstEventMs": 198,
- "homeInputToFirstRuntimeStatusMs": 199,
- "homeInputToFirstTextDeltaMs": 2097,
- "sendDispatchToSubmitAcceptedMs": 147,
- "streamSubmitDispatchedToAcceptedMs": 77,
- "submitAcceptedToFirstEventMs": 22,
- "firstEventToFirstTextDeltaMs": 1899,
- "streamEnsureSessionDurationMs": 34,
- "streamSubmitInvokeDurationMs": 77,
- "switchStartCount": 0,
- "fetchDetailStartCount": 0,
- "fetchDetailErrorCount": 0,
- "runtimeGetSessionStartCount": 0,
- "runtimeGetSessionErrorCount": 0,
- "messageListPaintCount": 0,
- "longTaskCount": 0,
- "threadItemsScanDeferredCount": 0,
- "maxUsedJSHeapSize": 449919337,
- "phases": [
- "homeInput.submit",
- "homeInput.pendingShellApplied",
- "homeInput.pendingPreviewPaint",
- "homeInput.sendDispatch.start",
- "workspaceSend.plan.ready",
- "agentStream.assistantDraft",
- "agentStream.ensureSession.start",
- "agentStream.assistantDraftPaint",
- "agentStream.ensureSession.done",
- "agentStream.request.start",
- "agentStream.listenerBound",
- "agentStream.submitDispatched",
- "agentStream.submitAccepted",
- "homeInput.sendDispatch.done",
- "agentStream.firstEvent",
- "agentStream.firstRuntimeStatus",
- "agentStream.firstTextDelta"
- ]
- }
- ]
- },
- "tail": null
-}
\ No newline at end of file
diff --git a/qcloop-ledger-records-visible.png b/qcloop-ledger-records-visible.png
deleted file mode 100644
index f667b8bc4..000000000
Binary files a/qcloop-ledger-records-visible.png and /dev/null differ
diff --git a/qcloop-ledger-refined-wide.png b/qcloop-ledger-refined-wide.png
deleted file mode 100644
index 467fd2cef..000000000
Binary files a/qcloop-ledger-refined-wide.png and /dev/null differ
diff --git a/qcloop-ledger-refined.png b/qcloop-ledger-refined.png
deleted file mode 100644
index 66c331344..000000000
Binary files a/qcloop-ledger-refined.png and /dev/null differ
diff --git a/qcloop-mobile-smoke.png b/qcloop-mobile-smoke.png
deleted file mode 100644
index 66cc6b6e9..000000000
Binary files a/qcloop-mobile-smoke.png and /dev/null differ
diff --git a/scripts/agent-qc-completion-audit.mjs b/scripts/agent-qc-completion-audit.mjs
new file mode 100644
index 000000000..57af740c8
--- /dev/null
+++ b/scripts/agent-qc-completion-audit.mjs
@@ -0,0 +1,316 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import { createAgentQcGuiFlowReport } from "./lib/agent-qc-gui-flow-core.mjs";
+import { buildQCLoopJobPayload, validateQCLoopJobPayload } from "./lib/agent-qc-qcloop-job-core.mjs";
+import { createAgentQcReport } from "./lib/agent-qc-report-core.mjs";
+import {
+ buildAgentQcCompletionAudit,
+ renderAgentQcCompletionAuditMarkdown,
+} from "./lib/agent-qc-completion-audit-core.mjs";
+
+function parseArgs(argv) {
+ const result = {
+ check: false,
+ format: "markdown",
+ help: false,
+ outputPath: "",
+ };
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC Completion Audit
+
+用法:
+ npm run agent-qc:audit
+ npm run agent-qc:audit -- --format json
+ node scripts/agent-qc-completion-audit.mjs --check
+
+选项:
+ --format FMT markdown | json
+ --output PATH 写入文件;默认 stdout
+ --check 整体目标未完成时非 0 退出
+ -h, --help 显示帮助
+`);
+}
+
+function exists(filePath) {
+ return fs.existsSync(path.resolve(process.cwd(), filePath));
+}
+
+function readText(filePath) {
+ return fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8");
+}
+
+function readJson(filePath) {
+ return JSON.parse(readText(filePath));
+}
+
+function readOptionalEvidencePack(filePath) {
+ if (!exists(filePath)) {
+ return { exists: false, status: "", scenarioCount: 0, scenarioIds: [] };
+ }
+ try {
+ const pack = readJson(filePath);
+ return summarizeEvidencePack(pack, { exists: true });
+ } catch {
+ return { exists: true, status: "invalid-json", scenarioCount: 0, scenarioIds: [] };
+ }
+}
+
+function readOptionalJson(filePath, fallback) {
+ if (!exists(filePath)) {
+ return fallback;
+ }
+ try {
+ return readJson(filePath);
+ } catch {
+ return { ...fallback, status: "invalid-json" };
+ }
+}
+
+function summarizeEvidencePack(pack, overrides = {}) {
+ return {
+ ...overrides,
+ status: pack?.verdict?.status || "",
+ scenarioCount: Array.isArray(pack?.scenarioResults)
+ ? pack.scenarioResults.length
+ : 0,
+ scenarioIds: Array.isArray(pack?.scenarioResults)
+ ? pack.scenarioResults
+ .map((result) => String(result?.scenarioId || "").trim())
+ .filter(Boolean)
+ : [],
+ };
+}
+
+function readEvidenceSidecars(dirPath) {
+ const resolvedDirPath = path.resolve(process.cwd(), dirPath);
+ if (!fs.existsSync(resolvedDirPath)) {
+ return [];
+ }
+ return fs
+ .readdirSync(resolvedDirPath)
+ .filter((fileName) => /^agent-qc-evidence\..+\.json$/.test(fileName))
+ .sort()
+ .map((fileName) => {
+ const relativePath = path.posix.join(dirPath, fileName);
+ try {
+ const pack = readJson(relativePath);
+ return summarizeEvidencePack(pack, { path: relativePath });
+ } catch {
+ return { path: relativePath, status: "invalid-json", scenarioCount: 0, scenarioIds: [] };
+ }
+ });
+}
+
+function readQcloopStatusSidecars(dirPath) {
+ const resolvedDirPath = path.resolve(process.cwd(), dirPath);
+ if (!fs.existsSync(resolvedDirPath)) {
+ return [];
+ }
+ return fs
+ .readdirSync(resolvedDirPath)
+ .filter((fileName) => /^qcloop-status\..+\.json$/.test(fileName))
+ .sort()
+ .map((fileName) => {
+ const relativePath = path.posix.join(dirPath, fileName);
+ try {
+ const status = readJson(relativePath);
+ return {
+ path: relativePath,
+ verdictStatus: status?.verdict?.status || "",
+ verdictSummary: status?.verdict?.summary || "",
+ counts: status?.counts || null,
+ };
+ } catch {
+ return { path: relativePath, verdictStatus: "invalid-json", verdictSummary: "", counts: null };
+ }
+ });
+}
+
+function loadFacts() {
+ const scenarioManifest = readJson("docs/test/agent-qc-scenarios.manifest.json");
+ const guiFlowManifest = readJson("docs/test/agent-qc-gui-flows.manifest.json");
+ const evidenceSchema = readJson("docs/test/agent-qc-evidence.schema.json");
+ const packageJson = readJson("package.json");
+ const scenarioReport = createAgentQcReport({ manifest: scenarioManifest, packageJson, evidenceSchema });
+ scenarioReport.p0ScenarioIds = Array.isArray(scenarioManifest?.scenarios)
+ ? scenarioManifest.scenarios
+ .filter((scenario) => String(scenario?.risk || "").toUpperCase() === "P0")
+ .map((scenario) => String(scenario?.id || "").trim())
+ .filter(Boolean)
+ : [];
+ const guiFlowReport = createAgentQcGuiFlowReport({ flowManifest: guiFlowManifest, scenarioManifest });
+ const qcloopPayload = buildQCLoopJobPayload(scenarioManifest, { risks: ["P0"], generatedAt: "2026-05-10T00:00:00.000Z" });
+ const qcloopPayloadValidation = validateQCLoopJobPayload(qcloopPayload);
+ const evidenceCore = exists("scripts/lib/agent-qc-evidence-core.mjs")
+ ? readText("scripts/lib/agent-qc-evidence-core.mjs")
+ : "";
+ const releaseSummaryCore = exists("scripts/lib/agent-qc-release-summary-core.mjs")
+ ? readText("scripts/lib/agent-qc-release-summary-core.mjs")
+ : "";
+ const guiOwnerCore = exists("scripts/lib/agent-qc-gui-owner-core.mjs")
+ ? readText("scripts/lib/agent-qc-gui-owner-core.mjs")
+ : "";
+ const staleOwnerInterventionDoc = exists("docs/tests/lime-agent-qc-stale-owner-intervention.md")
+ ? readText("docs/tests/lime-agent-qc-stale-owner-intervention.md")
+ : "";
+ const nightly = exists(".github/workflows/harness-nightly.yml") ? readText(".github/workflows/harness-nightly.yml") : "";
+ const release = exists(".github/workflows/release.yml") ? readText(".github/workflows/release.yml") : "";
+
+ return {
+ files: {
+ agentOpsQc: exists("docs/tests/agent-ops-qc.md"),
+ p0Scenarios: exists("docs/tests/agent-qc-p0-scenarios.md"),
+ limeRolloutPlan: exists("docs/tests/lime-agent-qc-rollout-plan.md"),
+ testsReadme: exists("docs/tests/README.md"),
+ evidenceSchema: exists("docs/test/agent-qc-evidence.schema.json"),
+ qcloopJobScript: exists("scripts/agent-qc-qcloop-job.mjs"),
+ guiOwnerCheckScript: exists("scripts/agent-qc-gui-owner-check.mjs"),
+ qcloopStatusScript: exists("scripts/agent-qc-qcloop-status.mjs"),
+ qcloopPreflightScript: exists("scripts/agent-qc-qcloop-preflight.mjs"),
+ qcloopOperationsDoc: exists("docs/tests/lime-agent-qc-qcloop-operations.md"),
+ evidenceContractDoc: exists("docs/tests/lime-agent-qc-evidence-contract.md"),
+ staleOwnerInterventionDoc: exists("docs/tests/lime-agent-qc-stale-owner-intervention.md"),
+ exportEvidenceScript: exists("scripts/agent-qc-export-evidence.mjs"),
+ releaseSummaryScript: exists("scripts/agent-qc-release-summary.mjs"),
+ realGuiEvidence: exists(".lime/qc/gui-evidence"),
+ },
+ realEvidencePack: readOptionalEvidencePack(".lime/qc/agent-qc-evidence.json"),
+ localVerify: readOptionalJson(".lime/qc/verify-local-current.json", {
+ exists: false,
+ status: "",
+ failedStage: "",
+ error: "",
+ }),
+ guiSmoke: readOptionalJson(".lime/qc/verify-gui-smoke-current.json", {
+ exists: false,
+ status: "",
+ failedStage: "",
+ error: "",
+ }),
+ realEvidenceSidecars: readEvidenceSidecars(".lime/qc"),
+ qcloopStatusSidecars: readQcloopStatusSidecars(".lime/qc"),
+ scenarioReport,
+ guiFlowReport,
+ qcloopPayload: {
+ valid: qcloopPayloadValidation.valid,
+ itemCount: qcloopPayload.items.length,
+ verifierHasWorkerOutput:
+ qcloopPayload.verifier_prompt_template.includes("{{stdout}}") ||
+ qcloopPayload.verifier_prompt_template.includes("{{output}}"),
+ verifierHasAttemptStatus: qcloopPayload.verifier_prompt_template.includes(
+ "{{attempt_status}}",
+ ),
+ verifierHasExitCode: qcloopPayload.verifier_prompt_template.includes("{{exit_code}}"),
+ workerPromptHasPreflight:
+ qcloopPayload.prompt_template.includes("agent-qc:qcloop-preflight") &&
+ qcloopPayload.prompt_template.includes("--require-devbridge"),
+ workerPromptHasStructuredEvidence:
+ qcloopPayload.prompt_template.includes("QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED") &&
+ qcloopPayload.prompt_template.includes("QCLOOP_EVIDENCE_SUMMARY_JSON="),
+ verifierRequiresStructuredEvidence:
+ qcloopPayload.verifier_prompt_template.includes("QCLOOP_EVIDENCE_SUMMARY_JSON"),
+ verifierRequiresStrictJson:
+ qcloopPayload.verifier_prompt_template.includes('{"pass": true|false') &&
+ qcloopPayload.verifier_prompt_template.includes("不要 Markdown"),
+ },
+ structuredEvidence: {
+ exporterParsesSummary:
+ evidenceCore.includes("collectQCLoopEvidenceSummaries") &&
+ evidenceCore.includes("qcloop:evidence_summary_missing") &&
+ evidenceCore.includes("qcloop:evidence_summary_invalid_json"),
+ releaseSummaryRejectsWeakRefs:
+ releaseSummaryCore.includes("weakEvidenceScenarioIds") &&
+ releaseSummaryCore.includes("hasStructuredEvidenceRef") &&
+ releaseSummaryCore.includes("缺少结构化 evidenceRefs"),
+ },
+ staleOwnerIntervention: {
+ guiOwnerReportHasDecisionPacket:
+ guiOwnerCore.includes("ownerIntervention") &&
+ guiOwnerCore.includes("requiredConfirmationText") &&
+ guiOwnerCore.includes("prohibitedUntilConfirmed"),
+ guiOwnerReportHasWatchHistory:
+ guiOwnerCore.includes("createAgentQcGuiOwnerWatchEntry") &&
+ exists("scripts/agent-qc-gui-owner-check.mjs") &&
+ readText("scripts/agent-qc-gui-owner-check.mjs").includes("--watch-history-output"),
+ docMentionsDecisionPacket:
+ staleOwnerInterventionDoc.includes("ownerIntervention") &&
+ staleOwnerInterventionDoc.includes("stale-owner-intervention-request.json"),
+ docMentionsWatchHistory:
+ staleOwnerInterventionDoc.includes("watch-history-output") ||
+ staleOwnerInterventionDoc.includes("stale-owner-watch-history.jsonl"),
+ },
+ nightly: {
+ hasAgentQcReport: nightly.includes("agent-qc-report"),
+ hasGuiFlowReport: nightly.includes("agent-qc-gui-flow-report"),
+ hasReleasePreview: nightly.includes("release-agent-qc-preview"),
+ },
+ release: {
+ hasHardGate:
+ release.includes("agent-qc-release-summary") &&
+ release.includes("--check") &&
+ !release.includes("--allow-missing-evidence"),
+ requiresP0ScenarioCoverage:
+ release.includes("--require-scenario-manifest") &&
+ release.includes("docs/test/agent-qc-scenarios.manifest.json") &&
+ release.includes("--require-risk") &&
+ release.includes("P0"),
+ },
+ };
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+ const audit = buildAgentQcCompletionAudit(loadFacts());
+ const content =
+ options.format === "json"
+ ? `${JSON.stringify(audit, null, 2)}\n`
+ : renderAgentQcCompletionAuditMarkdown(audit);
+ writeOutput(options.outputPath, content);
+ if (options.check && audit.status !== "complete") {
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/agent-qc-export-evidence.mjs b/scripts/agent-qc-export-evidence.mjs
new file mode 100644
index 000000000..93c6ae857
--- /dev/null
+++ b/scripts/agent-qc-export-evidence.mjs
@@ -0,0 +1,215 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import {
+ buildAgentQcEvidencePack,
+ validateEvidencePackShape,
+} from "./lib/agent-qc-evidence-core.mjs";
+
+function parseArgs(argv) {
+ const result = {
+ baseUrl: "http://127.0.0.1:8080",
+ changedFiles: [],
+ check: false,
+ diffBase: "",
+ format: "json",
+ help: false,
+ itemsJson: "",
+ jobId: "",
+ jobJson: "",
+ outputPath: "",
+ ref: "",
+ repo: "lime",
+ riskTags: [],
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--base-url" && argv[index + 1]) {
+ result.baseUrl = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--job-id" && argv[index + 1]) {
+ result.jobId = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--job-json" && argv[index + 1]) {
+ result.jobJson = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--items-json" && argv[index + 1]) {
+ result.itemsJson = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--repo" && argv[index + 1]) {
+ result.repo = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--ref" && argv[index + 1]) {
+ result.ref = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--diff-base" && argv[index + 1]) {
+ result.diffBase = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--changed-file" && argv[index + 1]) {
+ result.changedFiles.push(String(argv[index + 1]).trim());
+ index += 1;
+ continue;
+ }
+ if (arg === "--risk-tag" && argv[index + 1]) {
+ result.riskTags.push(String(argv[index + 1]).trim());
+ index += 1;
+ continue;
+ }
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC Evidence Export
+
+用法:
+ npm run agent-qc:export-evidence -- --job-id "qcloop-job-id" --output ./.lime/qc/evidence.json
+ node scripts/agent-qc-export-evidence.mjs --job-json ./job.json --items-json ./items.json --check
+
+选项:
+ --job-id ID 从 qcloop HTTP API 读取 /api/jobs/:id 和 /api/items?job_id=:id
+ --base-url URL qcloop API 地址,默认 http://127.0.0.1:8080
+ --job-json PATH 离线 job JSON 文件
+ --items-json PATH 离线 items JSON 文件
+ --output PATH 写入 Evidence Pack;默认 stdout
+ --repo NAME subject.repo,默认 lime
+ --ref REF subject.ref
+ --diff-base REF subject.diffBase
+ --changed-file PATH 追加 changedFiles,可重复
+ --risk-tag TAG 追加 riskTags,可重复
+ --check 只校验输出形状,非法时非 0 退出
+ --format FMT 输出格式:json | summary
+ -h, --help 显示帮助
+`);
+}
+
+function readJsonFile(filePath) {
+ return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"));
+}
+
+async function readJsonUrl(url) {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`请求失败 ${response.status}: ${url}`);
+ }
+ return response.json();
+}
+
+async function loadQCLoopData(options) {
+ if (options.jobJson || options.itemsJson) {
+ if (!options.jobJson || !options.itemsJson) {
+ throw new Error("离线模式必须同时提供 --job-json 与 --items-json。");
+ }
+ return {
+ job: readJsonFile(options.jobJson),
+ items: readJsonFile(options.itemsJson),
+ };
+ }
+
+ if (!options.jobId) {
+ throw new Error("必须提供 --job-id,或同时提供 --job-json / --items-json。");
+ }
+
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
+ return {
+ job: await readJsonUrl(`${baseUrl}/api/jobs/${encodeURIComponent(options.jobId)}`),
+ items: await readJsonUrl(`${baseUrl}/api/items/?job_id=${encodeURIComponent(options.jobId)}`),
+ };
+}
+
+function renderSummary(pack, validation) {
+ const lines = [
+ `runId=${pack.runId}`,
+ `status=${pack.verdict.status}`,
+ `scenarios=${pack.scenarioResults.length}`,
+ `valid=${validation.valid}`,
+ `summary=${pack.verdict.summary}`,
+ ];
+ if (validation.issues.length > 0) {
+ lines.push(`issues=${validation.issues.join("; ")}`);
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const { job, items } = await loadQCLoopData(options);
+ const pack = buildAgentQcEvidencePack({
+ job,
+ items,
+ options: {
+ repo: options.repo,
+ ref: options.ref,
+ diffBase: options.diffBase,
+ changedFiles: options.changedFiles,
+ riskTags: options.riskTags,
+ },
+ });
+ const validation = validateEvidencePackShape(pack);
+ const content = options.format === "summary" ? renderSummary(pack, validation) : `${JSON.stringify(pack, null, 2)}\n`;
+ writeOutput(options.outputPath, content);
+
+ if (options.check && !validation.valid) {
+ for (const issue of validation.issues) {
+ console.error(`[agent-qc-evidence] ${issue}`);
+ }
+ process.exit(1);
+ }
+}
+
+main().catch((error) => {
+ console.error(`[agent-qc-evidence] ${error.message}`);
+ process.exit(1);
+});
diff --git a/scripts/agent-qc-gui-flow-report.mjs b/scripts/agent-qc-gui-flow-report.mjs
new file mode 100644
index 000000000..b7b056318
--- /dev/null
+++ b/scripts/agent-qc-gui-flow-report.mjs
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import {
+ createAgentQcGuiFlowReport,
+ renderAgentQcGuiFlowMarkdown,
+} from "./lib/agent-qc-gui-flow-core.mjs";
+
+const DEFAULT_FLOW_MANIFEST_PATH = "docs/test/agent-qc-gui-flows.manifest.json";
+const DEFAULT_SCENARIO_MANIFEST_PATH = "docs/test/agent-qc-scenarios.manifest.json";
+
+function parseArgs(argv) {
+ const result = {
+ check: false,
+ flowManifestPath: DEFAULT_FLOW_MANIFEST_PATH,
+ format: "markdown",
+ help: false,
+ outputPath: "",
+ scenarioManifestPath: DEFAULT_SCENARIO_MANIFEST_PATH,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--flow-manifest" && argv[index + 1]) {
+ result.flowManifestPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--scenario-manifest" && argv[index + 1]) {
+ result.scenarioManifestPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC GUI Flow Report
+
+用法:
+ npm run agent-qc:gui-flow:report
+ npm run agent-qc:gui-flow:check
+
+选项:
+ --flow-manifest PATH GUI flow manifest,默认 docs/test/agent-qc-gui-flows.manifest.json
+ --scenario-manifest PATH Agent QC scenario manifest,默认 docs/test/agent-qc-scenarios.manifest.json
+ --format FMT markdown | json
+ --output PATH 写入文件;默认 stdout
+ --check 非法时非 0 退出
+ -h, --help 显示帮助
+`);
+}
+
+function readJsonFile(filePath) {
+ return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"));
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const report = createAgentQcGuiFlowReport({
+ flowManifest: readJsonFile(options.flowManifestPath),
+ scenarioManifest: readJsonFile(options.scenarioManifestPath),
+ });
+ const content =
+ options.format === "json"
+ ? `${JSON.stringify(report, null, 2)}\n`
+ : renderAgentQcGuiFlowMarkdown(report);
+
+ writeOutput(options.outputPath, content);
+
+ if (options.check && !report.valid) {
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/agent-qc-gui-owner-check.mjs b/scripts/agent-qc-gui-owner-check.mjs
new file mode 100644
index 000000000..3aba11955
--- /dev/null
+++ b/scripts/agent-qc-gui-owner-check.mjs
@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import {
+ createAgentQcGuiOwnerWatchEntry,
+ createAgentQcGuiOwnerReport,
+ renderAgentQcGuiOwnerSummary,
+} from "./lib/agent-qc-gui-owner-core.mjs";
+
+function parseArgs(argv) {
+ const result = {
+ check: false,
+ format: "summary",
+ help: false,
+ manifestPath: "docs/test/agent-qc-scenarios.manifest.json",
+ maxActiveOwners: 0,
+ outputPath: "",
+ statusDir: ".lime/qc",
+ watchHistoryOutputPath: "",
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--manifest" && argv[index + 1]) {
+ result.manifestPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--status-dir" && argv[index + 1]) {
+ result.statusDir = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--max-active-owners" && argv[index + 1]) {
+ result.maxActiveOwners = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--watch-history-output" && argv[index + 1]) {
+ result.watchHistoryOutputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC GUI Owner Check
+
+用法:
+ npm run agent-qc:gui-owner-check
+ npm run agent-qc:gui-owner-check -- --check
+ node scripts/agent-qc-gui-owner-check.mjs --format json --output ./.lime/qc/gui-owner-current.json
+
+选项:
+ --manifest PATH scenario manifest,默认 docs/test/agent-qc-scenarios.manifest.json
+ --status-dir PATH qcloop status sidecar 目录,默认 .lime/qc
+ --max-active-owners N 允许的 active GUI owner 数,默认 0
+ --format FMT summary | json
+ --output PATH 写入文件;默认 stdout
+ --watch-history-output PATH 追加 JSONL 观察记录
+ --check active owner 超过上限时非 0 退出
+ -h, --help 显示帮助
+`);
+}
+
+function readJsonFile(filePath) {
+ return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"));
+}
+
+function readQcloopStatusSidecars(statusDir) {
+ const resolvedDir = path.resolve(process.cwd(), statusDir);
+ if (!fs.existsSync(resolvedDir)) {
+ return [];
+ }
+ return fs
+ .readdirSync(resolvedDir)
+ .filter((fileName) => /^qcloop-status\..+\.json$/.test(fileName))
+ .sort()
+ .map((fileName) => {
+ const relativePath = path.posix.join(statusDir, fileName);
+ try {
+ return { path: relativePath, status: readJsonFile(relativePath) };
+ } catch {
+ return { path: relativePath, status: { job: { id: relativePath, status: "invalid-json" }, items: [] } };
+ }
+ });
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+function appendJsonLine(outputPath, entry) {
+ if (!outputPath) {
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.appendFileSync(resolvedOutputPath, `${JSON.stringify(entry)}\n`, "utf8");
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const report = createAgentQcGuiOwnerReport({
+ manifest: readJsonFile(options.manifestPath),
+ statusSidecars: readQcloopStatusSidecars(options.statusDir),
+ maxActiveOwners: options.maxActiveOwners,
+ });
+ const content =
+ options.format === "json"
+ ? `${JSON.stringify(report, null, 2)}\n`
+ : renderAgentQcGuiOwnerSummary(report);
+ writeOutput(options.outputPath, content);
+ appendJsonLine(options.watchHistoryOutputPath, createAgentQcGuiOwnerWatchEntry(report));
+
+ if (options.check && report.verdict.status !== "pass") {
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/agent-qc-process-owner-check.mjs b/scripts/agent-qc-process-owner-check.mjs
new file mode 100755
index 000000000..96f5e7b3a
--- /dev/null
+++ b/scripts/agent-qc-process-owner-check.mjs
@@ -0,0 +1,318 @@
+#!/usr/bin/env node
+
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+const GUI_OWNER_PATTERNS = [
+ "verify:gui-smoke",
+ "smoke:workspace-ready",
+ "smoke:browser-runtime",
+ "smoke:site-adapters",
+ "smoke:agent-service-skill-entry",
+ "smoke:agent-runtime-tool-surface",
+ "smoke:knowledge-gui",
+ "smoke:design-canvas",
+ "claw-chat-ready-streaming",
+ "browser-runtime-site-adapter",
+ "workspace-ready-session-restore",
+ "release-package-startup-smoke",
+];
+
+const CARGO_OWNER_PATTERNS = [
+ "cargo ",
+ "cargo-fmt",
+ "rustc ",
+ "clippy-driver",
+ "tauri dev",
+];
+
+const QCLOOP_OWNER_PATTERNS = [
+ "qcloop --db",
+ "./qcloop --db",
+ "qcloop serve",
+ "qcloop-worker",
+ "qcloop_worker_result",
+ "agent qc p0",
+ "只读执行 lime agent qc p0",
+];
+
+function parseArgs(argv) {
+ const result = {
+ check: false,
+ format: "summary",
+ help: false,
+ markdownOutputPath: "",
+ maxActiveGuiSmoke: 0,
+ maxCargoOrRust: 0,
+ maxQcloopRelated: 0,
+ outputPath: "",
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--markdown-output" && argv[index + 1]) {
+ result.markdownOutputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--max-active-gui-smoke" && argv[index + 1]) {
+ result.maxActiveGuiSmoke = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--max-cargo-or-rust" && argv[index + 1]) {
+ result.maxCargoOrRust = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--max-qcloop-related" && argv[index + 1]) {
+ result.maxQcloopRelated = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC Process Owner Check
+
+用法:
+ npm run agent-qc:process-owner-check
+ node scripts/agent-qc-process-owner-check.mjs --format json --output ./.lime/qc/gui-process-owner-current.json --markdown-output ./.lime/qc/gui-process-owner-current.md
+
+选项:
+ --format FMT summary | json,默认 summary
+ --output PATH 写入 JSON 或 summary;默认 stdout
+ --markdown-output PATH 额外写入 Markdown 摘要
+ --max-active-gui-smoke N 允许的 GUI smoke / deep flow owner 数,默认 0
+ --max-cargo-or-rust N 允许的 Cargo / Rust 构建进程数,默认 0
+ --max-qcloop-related N 允许的 qcloop 相关进程数,默认 0
+ --check owner 超过上限时非 0 退出
+ -h, --help 显示帮助
+`);
+}
+
+function parseUnixPsLine(line) {
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+)$/.exec(line);
+ if (!match) {
+ return null;
+ }
+ return {
+ pid: Number(match[1]),
+ ppid: Number(match[2]),
+ pgid: Number(match[3]),
+ stat: match[4],
+ etime: match[5],
+ command: match[6].trim(),
+ };
+}
+
+function collectUnixProcesses() {
+ const output = execFileSync("ps", ["-axo", "pid,ppid,pgid,stat,etime,command"], {
+ encoding: "utf8",
+ });
+ return output
+ .split(/\r?\n/)
+ .slice(1)
+ .map(parseUnixPsLine)
+ .filter(Boolean)
+ .filter((entry) => entry.pid !== process.pid && entry.command.length > 0);
+}
+
+function collectWindowsProcesses() {
+ const output = execFileSync(
+ "powershell.exe",
+ [
+ "-NoProfile",
+ "-Command",
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress",
+ ],
+ { encoding: "utf8" },
+ ).trim();
+ const parsed = output ? JSON.parse(output) : [];
+ return (Array.isArray(parsed) ? parsed : [parsed])
+ .map((entry) => ({
+ pid: Number(entry?.ProcessId || 0),
+ ppid: Number(entry?.ParentProcessId || 0),
+ pgid: null,
+ stat: "unknown",
+ etime: "unknown",
+ command: String(entry?.CommandLine || "").trim(),
+ }))
+ .filter((entry) => entry.pid > 0 && entry.pid !== process.pid && entry.command.length > 0);
+}
+
+function collectProcesses() {
+ return process.platform === "win32" ? collectWindowsProcesses() : collectUnixProcesses();
+}
+
+function commandHasAny(command, patterns) {
+ const normalized = String(command || "").toLowerCase();
+ return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
+}
+
+function uniqueByPid(entries) {
+ const seen = new Set();
+ const result = [];
+ for (const entry of entries) {
+ if (seen.has(entry.pid)) {
+ continue;
+ }
+ seen.add(entry.pid);
+ result.push(entry);
+ }
+ return result.sort((left, right) => left.pid - right.pid);
+}
+
+function createReport({
+ generatedAt = new Date().toISOString(),
+ maxActiveGuiSmoke = 0,
+ maxCargoOrRust = 0,
+ maxQcloopRelated = 0,
+} = {}) {
+ const processes = collectProcesses();
+ const activeGuiSmokeProcesses = uniqueByPid(
+ processes.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)),
+ );
+ const qcloopProcesses = uniqueByPid(
+ processes.filter((entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS)),
+ );
+ const cargoProcesses = uniqueByPid(
+ processes.filter((entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS)),
+ );
+
+ const counts = {
+ activeGuiSmoke: activeGuiSmokeProcesses.length,
+ cargoOrRust: cargoProcesses.length,
+ qcloopRelated: qcloopProcesses.length,
+ };
+ const busy =
+ counts.activeGuiSmoke > maxActiveGuiSmoke ||
+ counts.cargoOrRust > maxCargoOrRust ||
+ counts.qcloopRelated > maxQcloopRelated;
+
+ return {
+ schemaVersion: "v1",
+ generatedAt,
+ platform: process.platform,
+ maxActiveGuiSmoke,
+ maxCargoOrRust,
+ maxQcloopRelated,
+ verdict: {
+ status: busy ? "busy" : "pass",
+ summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}`,
+ nextAction: busy
+ ? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion."
+ : "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.",
+ },
+ activeGuiSmokeProcesses,
+ qcloopProcesses,
+ cargoProcesses,
+ guardrails: [
+ "best-effort process snapshot only",
+ "do not kill or restart listed processes from this sidecar",
+ "use this sidecar to decide whether heavy GUI or verify gates should wait",
+ ],
+ };
+}
+
+function renderSummary(report) {
+ const lines = [
+ `status=${report.verdict.status}`,
+ `summary=${report.verdict.summary}`,
+ `generatedAt=${report.generatedAt}`,
+ `platform=${report.platform}`,
+ ];
+ return `${lines.join("\n")}\n`;
+}
+
+function renderMarkdown(report) {
+ const lines = [
+ "# Agent QC raw process owner snapshot",
+ "",
+ `- Generated at: ${report.generatedAt}`,
+ `- Status: ${report.verdict.status}`,
+ `- Summary: ${report.verdict.summary}`,
+ `- Next action: ${report.verdict.nextAction}`,
+ "",
+ "## Active GUI smoke / deep flow processes",
+ "",
+ ];
+ appendProcessList(lines, report.activeGuiSmokeProcesses);
+ lines.push("", "## qcloop related processes", "");
+ appendProcessList(lines, report.qcloopProcesses);
+ lines.push("", "## Cargo / Rust processes", "");
+ appendProcessList(lines, report.cargoProcesses);
+ lines.push("", "## Guardrails", "");
+ for (const guardrail of report.guardrails) {
+ lines.push(`- ${guardrail}`);
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+function appendProcessList(lines, entries) {
+ if (!entries?.length) {
+ lines.push("- none");
+ return;
+ }
+ for (const entry of entries) {
+ lines.push(
+ `- pid=${entry.pid} ppid=${entry.ppid} pgid=${entry.pgid} stat=${entry.stat} etime=${entry.etime} command=${entry.command}`,
+ );
+ }
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const report = createReport(options);
+ const content =
+ options.format === "json"
+ ? `${JSON.stringify(report, null, 2)}\n`
+ : renderSummary(report);
+ writeOutput(options.outputPath, content);
+ if (options.markdownOutputPath) {
+ writeOutput(options.markdownOutputPath, renderMarkdown(report));
+ }
+
+ if (options.check && report.verdict.status !== "pass") {
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/agent-qc-qcloop-db-lease.mjs b/scripts/agent-qc-qcloop-db-lease.mjs
new file mode 100755
index 000000000..8bd49128a
--- /dev/null
+++ b/scripts/agent-qc-qcloop-db-lease.mjs
@@ -0,0 +1,258 @@
+#!/usr/bin/env node
+
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+function parseArgs(argv) {
+ const result = {
+ checkTerminal: false,
+ dbPath: ".lime/qc/qcloop-isolated-worker-preflight.db",
+ format: "summary",
+ help: false,
+ jobId: "",
+ markdownOutputPath: "",
+ outputPath: "",
+ processOwnerPath: ".lime/qc/gui-process-owner-current.json",
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--db" && argv[index + 1]) {
+ result.dbPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--job-id" && argv[index + 1]) {
+ result.jobId = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--process-owner" && argv[index + 1]) {
+ result.processOwnerPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--markdown-output" && argv[index + 1]) {
+ result.markdownOutputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--check-terminal") {
+ result.checkTerminal = true;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC qcloop DB Lease Snapshot
+
+用法:
+ npm run agent-qc:qcloop-db-lease -- --job-id
+ node scripts/agent-qc-qcloop-db-lease.mjs --db ./.lime/qc/qcloop-isolated-worker-preflight.db --job-id --format json --output ./.lime/qc/qcloop-db-lease-current.json
+
+选项:
+ --db PATH qcloop SQLite DB,默认 .lime/qc/qcloop-isolated-worker-preflight.db
+ --job-id ID qcloop job id,必填
+ --process-owner PATH raw process owner sidecar,默认 .lime/qc/gui-process-owner-current.json
+ --format FMT summary | json,默认 summary
+ --output PATH 写入文件;默认 stdout
+ --markdown-output PATH 额外写入 Markdown 摘要
+ --check-terminal job 未终态或仍有 running item 时非 0 退出
+ -h, --help 显示帮助
+`);
+}
+
+function sqlQuote(value) {
+ return `'${String(value).replaceAll("'", "''")}'`;
+}
+
+function sqliteJson(dbPath, sql) {
+ const output = execFileSync("sqlite3", ["-readonly", "-json", dbPath, sql], {
+ encoding: "utf8",
+ }).trim();
+ return output ? JSON.parse(output) : [];
+}
+
+function readJsonIfExists(filePath) {
+ const resolved = path.resolve(process.cwd(), filePath);
+ if (!fs.existsSync(resolved)) {
+ return null;
+ }
+ return JSON.parse(fs.readFileSync(resolved, "utf8"));
+}
+
+function extractScenarioId(itemPreview) {
+ return String(itemPreview || "").match(/"scenario_id"\s*:\s*"([^"]+)"/)?.[1] || null;
+}
+
+function createDbLeaseReport({
+ dbPath,
+ generatedAt = new Date().toISOString(),
+ jobId,
+ processOwnerPath,
+}) {
+ if (!jobId) {
+ throw new Error("--job-id is required");
+ }
+ const quotedJobId = sqlQuote(jobId);
+ const jobs = sqliteJson(
+ dbPath,
+ `select id, name, status, created_at, finished_at from batch_jobs where id=${quotedJobId}`,
+ );
+ const items = sqliteJson(
+ dbPath,
+ `select id, batch_job_id, status, current_attempt_no, current_qc_no, tokens_used, lock_owner, lock_expires_at, queued_at, last_error, created_at, finished_at, substr(item_value,1,700) as item_preview from batch_items where batch_job_id=${quotedJobId} order by created_at, id`,
+ );
+ const attempts = sqliteJson(
+ dbPath,
+ `select attempts.id, attempts.batch_item_id, attempts.attempt_no, attempts.run_no, attempts.attempt_type, attempts.status, attempts.exit_code, attempts.tokens_used, attempts.started_at, attempts.finished_at, length(attempts.stdout) as stdout_len, length(attempts.stderr) as stderr_len, substr(attempts.stdout,1,700) as stdout_preview, substr(attempts.stderr,1,700) as stderr_preview from attempts join batch_items on batch_items.id=attempts.batch_item_id where batch_items.batch_job_id=${quotedJobId} order by attempts.started_at, attempts.id`,
+ );
+ const processOwner = readJsonIfExists(processOwnerPath) || { qcloopProcesses: [] };
+ const processSnapshot = (processOwner.qcloopProcesses || []).filter((entry) => {
+ const command = String(entry?.command || "");
+ return command.includes(jobId) || command.includes("18080") || command.includes("browser-runtime-site-adapter");
+ });
+ const activeItem = items.find((item) => item.status === "running") || null;
+ const activeAttempt = activeItem
+ ? attempts.find((attempt) => attempt.batch_item_id === activeItem.id && attempt.status === "running") || null
+ : null;
+ const observedWorker = processSnapshot.find((entry) =>
+ String(entry?.command || "").includes("browser-runtime-site-adapter"),
+ ) || null;
+ const observedQcloop = processSnapshot.find((entry) =>
+ String(entry?.command || "").includes("serve --addr 127.0.0.1:18080"),
+ ) || null;
+ const terminalStatuses = new Set(["completed", "failed", "cancelled", "canceled"]);
+ const jobStatus = jobs[0]?.status || "unknown";
+ const terminal = terminalStatuses.has(String(jobStatus).toLowerCase()) && !activeItem;
+
+ return {
+ schemaVersion: "v1",
+ generatedAt,
+ jobId,
+ dbPath,
+ processOwnerPath,
+ summary: {
+ status: jobStatus,
+ terminal,
+ activeItemId: activeItem?.id || null,
+ activeScenario: extractScenarioId(activeItem?.item_preview),
+ lockOwner: activeItem?.lock_owner || null,
+ lockExpiresAt: activeItem?.lock_expires_at || null,
+ activeAttemptId: activeAttempt?.id || null,
+ activeAttemptStatus: activeAttempt?.status || null,
+ stdoutLength: activeAttempt?.stdout_len ?? null,
+ stderrLength: activeAttempt?.stderr_len ?? null,
+ observedWorkerPid: observedWorker?.pid || null,
+ observedQcloopPid: observedQcloop?.pid || null,
+ finding: activeItem
+ ? "qcloop DB lease, active attempt, and process snapshot still show a running no-output stale GUI owner; no intervention was performed."
+ : "No running item observed in this DB snapshot; verify qcloop status sidecar before changing gates.",
+ },
+ jobs,
+ items,
+ attempts,
+ processSnapshot,
+ guardrails: [
+ "read-only DB observation only",
+ "do not kill / pause / interrupt stale worker without owner confirmation",
+ "do not modify qcloop SQLite DB",
+ "do not start another full GUI P0 batch while GUI owner is blocked",
+ "do not overwrite .lime/qc/agent-qc-evidence.json before a real 8/8 P0 pass",
+ ],
+ };
+}
+
+function renderSummary(report) {
+ const summary = report.summary;
+ return [
+ `status=${summary.status}`,
+ `terminal=${summary.terminal}`,
+ `activeItem=${summary.activeItemId || "none"}`,
+ `activeScenario=${summary.activeScenario || "none"}`,
+ `lockOwner=${summary.lockOwner || "none"}`,
+ `lockExpiresAt=${summary.lockExpiresAt || "none"}`,
+ `activeAttempt=${summary.activeAttemptId || "none"}:${summary.activeAttemptStatus || "none"}`,
+ `stdoutStderr=${summary.stdoutLength ?? "null"}/${summary.stderrLength ?? "null"}`,
+ `observedWorkerPid=${summary.observedWorkerPid || "none"}`,
+ `observedQcloopPid=${summary.observedQcloopPid || "none"}`,
+ `finding=${summary.finding}`,
+ ].join("\n") + "\n";
+}
+
+function renderMarkdown(report) {
+ const summary = report.summary;
+ return [
+ "# qcloop DB lease snapshot",
+ "",
+ `- Generated at: ${report.generatedAt}`,
+ `- Job: ${report.jobId}`,
+ `- DB: ${report.dbPath}`,
+ `- Status: ${summary.status}`,
+ `- Terminal: ${summary.terminal}`,
+ `- Active item: ${summary.activeItemId || "none"}`,
+ `- Active scenario: ${summary.activeScenario || "none"}`,
+ `- Lock owner: ${summary.lockOwner || "none"}`,
+ `- Lock expires at: ${summary.lockExpiresAt || "none"}`,
+ `- Active attempt: ${summary.activeAttemptId || "none"} / ${summary.activeAttemptStatus || "none"}`,
+ `- stdout/stderr length: ${summary.stdoutLength ?? "null"} / ${summary.stderrLength ?? "null"}`,
+ `- Observed worker PID: ${summary.observedWorkerPid || "none"}`,
+ `- Observed qcloop PID: ${summary.observedQcloopPid || "none"}`,
+ "",
+ "## Finding",
+ "",
+ summary.finding,
+ "",
+ "## Guardrails",
+ "",
+ ...report.guardrails.map((guardrail) => `- ${guardrail}`),
+ "",
+ ].join("\n");
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+ const report = createDbLeaseReport(options);
+ const content = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderSummary(report);
+ writeOutput(options.outputPath, content);
+ if (options.markdownOutputPath) {
+ writeOutput(options.markdownOutputPath, renderMarkdown(report));
+ }
+ if (options.checkTerminal && !report.summary.terminal) {
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/agent-qc-qcloop-job.mjs b/scripts/agent-qc-qcloop-job.mjs
new file mode 100644
index 000000000..d64f7f014
--- /dev/null
+++ b/scripts/agent-qc-qcloop-job.mjs
@@ -0,0 +1,195 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import {
+ buildQCLoopJobPayload,
+ renderQCLoopCurl,
+ validateQCLoopJobPayload,
+} from "./lib/agent-qc-qcloop-job-core.mjs";
+
+const DEFAULT_MANIFEST_PATH = "docs/test/agent-qc-scenarios.manifest.json";
+
+function parseArgs(argv) {
+ const result = {
+ baseUrl: "http://127.0.0.1:8080",
+ check: false,
+ cwd: process.cwd(),
+ executorProvider: "codex",
+ executionMode: "standard",
+ format: "json",
+ help: false,
+ includeAll: false,
+ manifestPath: DEFAULT_MANIFEST_PATH,
+ maxQcRounds: 0,
+ maxExecutorRetries: undefined,
+ name: "",
+ outputPath: "",
+ risks: [],
+ scenarioIds: [],
+ tokenBudgetPerItem: 0,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--manifest" && argv[index + 1]) {
+ result.manifestPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--risk" && argv[index + 1]) {
+ result.risks.push(String(argv[index + 1]).trim());
+ index += 1;
+ continue;
+ }
+ if (arg === "--scenario" && argv[index + 1]) {
+ result.scenarioIds.push(String(argv[index + 1]).trim());
+ index += 1;
+ continue;
+ }
+ if (arg === "--all") {
+ result.includeAll = true;
+ continue;
+ }
+ if (arg === "--name" && argv[index + 1]) {
+ result.name = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--cwd" && argv[index + 1]) {
+ result.cwd = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--executor-provider" && argv[index + 1]) {
+ result.executorProvider = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--execution-mode" && argv[index + 1]) {
+ result.executionMode = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--max-qc-rounds" && argv[index + 1]) {
+ result.maxQcRounds = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--max-executor-retries" && argv[index + 1]) {
+ result.maxExecutorRetries = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--token-budget-per-item" && argv[index + 1]) {
+ result.tokenBudgetPerItem = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--base-url" && argv[index + 1]) {
+ result.baseUrl = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC qcloop Job Payload
+
+用法:
+ npm run agent-qc:qcloop-job -- --risk P0 --output ./tmp/qcloop-p0-job.json
+ node scripts/agent-qc-qcloop-job.mjs --scenario command-bridge-contract --format curl
+
+选项:
+ --risk RISK 选择风险等级,可重复;默认 P0
+ --scenario ID 选择指定 scenario,可重复;优先级高于 --risk
+ --all 选择全部 scenario
+ --name NAME qcloop job 名称
+ --cwd PATH worker 目标仓库目录,默认当前目录
+ --executor-provider NAME qcloop executor_provider,默认 codex
+ --execution-mode MODE standard | goal_assisted,默认 standard
+ --max-qc-rounds N 覆盖 max_qc_rounds
+ --max-executor-retries N 覆盖 max_executor_retries,0-5
+ --token-budget-per-item N 覆盖 token_budget_per_item
+ --format FMT json | curl
+ --base-url URL curl 模式 qcloop API 地址,默认 http://127.0.0.1:8080
+ --output PATH 写入文件;默认 stdout
+ --check payload 非法时非 0 退出
+ -h, --help 显示帮助
+`);
+}
+
+function readJsonFile(filePath) {
+ return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"));
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const manifest = readJsonFile(options.manifestPath);
+ const payload = buildQCLoopJobPayload(manifest, {
+ includeAll: options.includeAll,
+ risks: options.risks.length > 0 ? options.risks : ["P0"],
+ scenarioIds: options.scenarioIds,
+ name: options.name,
+ cwd: options.cwd,
+ executorProvider: options.executorProvider,
+ executionMode: options.executionMode,
+ maxQcRounds: options.maxQcRounds,
+ maxExecutorRetries: options.maxExecutorRetries,
+ tokenBudgetPerItem: options.tokenBudgetPerItem,
+ });
+ const validation = validateQCLoopJobPayload(payload);
+ const content =
+ options.format === "curl"
+ ? renderQCLoopCurl(payload, { baseUrl: options.baseUrl })
+ : `${JSON.stringify({ ...payload, _validation: validation }, null, 2)}\n`;
+
+ writeOutput(options.outputPath, content);
+
+ if (options.check && !validation.valid) {
+ for (const issue of validation.issues) {
+ console.error(`[agent-qc-qcloop-job] ${issue}`);
+ }
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/agent-qc-qcloop-preflight.mjs b/scripts/agent-qc-qcloop-preflight.mjs
new file mode 100644
index 000000000..41db4de16
--- /dev/null
+++ b/scripts/agent-qc-qcloop-preflight.mjs
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+
+import { buildQCLoopPreflightReport } from "./lib/agent-qc-qcloop-preflight-core.mjs";
+
+function parseArgs(argv) {
+ const result = {
+ check: false,
+ expectedCwd: "",
+ format: "summary",
+ help: false,
+ healthUrl: "http://127.0.0.1:3030/health",
+ requireDevBridge: false,
+ timeoutMs: 15000,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--expected-cwd" && argv[index + 1]) {
+ result.expectedCwd = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--health-url" && argv[index + 1]) {
+ result.healthUrl = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--timeout-ms" && argv[index + 1]) {
+ result.timeoutMs = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--require-devbridge") {
+ result.requireDevBridge = true;
+ continue;
+ }
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC qcloop Worker Preflight
+
+用法:
+ npm run agent-qc:qcloop-preflight -- --check
+ npm run agent-qc:qcloop-preflight -- --require-devbridge --timeout-ms 15000 --check
+
+选项:
+ --expected-cwd PATH 预期仓库 cwd;不传则只检查 cwd 存在
+ --require-devbridge 要求 http://127.0.0.1:3030/health 可访问
+ --health-url URL DevBridge health 地址,默认 http://127.0.0.1:3030/health
+ --timeout-ms N DevBridge health 超时,默认 15000
+ --format FMT summary | json
+ --check preflight blocked 时非 0 退出
+ -h, --help 显示帮助
+`);
+}
+
+function checkTmpWritable() {
+ const filePath = path.join(os.tmpdir(), `lime-agent-qc-preflight-${process.pid}-${Date.now()}.tmp`);
+ try {
+ fs.writeFileSync(filePath, "ok", "utf8");
+ fs.unlinkSync(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function checkDevBridgeHealth({ url, timeoutMs }) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 15000);
+ try {
+ const response = await fetch(url, { signal: controller.signal });
+ const body = await response.text();
+ let status = response.ok ? "ok" : `http-${response.status}`;
+ try {
+ const parsed = JSON.parse(body);
+ status = parsed?.status || status;
+ } catch {
+ // health body 不是 JSON 时保留 HTTP 状态。
+ }
+ return { ok: response.ok, status, url };
+ } catch (error) {
+ return { ok: false, error: `${error.name || "Error"}: ${error.message}`, url };
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function renderSummary(report) {
+ const lines = [
+ `status=${report.status}`,
+ `summary=${report.summary}`,
+ `QCLOOP_PREFLIGHT_RESULT=${report.status === "pass" ? "PASS" : "BLOCKED"}`,
+ ];
+ for (const check of report.checks) {
+ lines.push(`- ${check.id} ${check.passed ? "PASS" : "BLOCKED"}: ${check.detail}`);
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const devBridge = options.requireDevBridge
+ ? await checkDevBridgeHealth({ url: options.healthUrl, timeoutMs: options.timeoutMs })
+ : null;
+ const report = buildQCLoopPreflightReport({
+ cwd: process.cwd(),
+ expectedCwd: options.expectedCwd,
+ tmpWritable: checkTmpWritable(),
+ devBridge,
+ });
+ const content = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderSummary(report);
+ process.stdout.write(content);
+
+ if (options.check && report.status !== "pass") {
+ process.exit(1);
+ }
+}
+
+main().catch((error) => {
+ console.error(`[agent-qc-qcloop-preflight] ${error.message}`);
+ process.exit(1);
+});
diff --git a/scripts/agent-qc-qcloop-status.mjs b/scripts/agent-qc-qcloop-status.mjs
new file mode 100644
index 000000000..2f15b6acb
--- /dev/null
+++ b/scripts/agent-qc-qcloop-status.mjs
@@ -0,0 +1,237 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import {
+ buildQCLoopStatusReport,
+ validateQCLoopStatusReport,
+} from "./lib/agent-qc-qcloop-status-core.mjs";
+
+function parseArgs(argv) {
+ const result = {
+ baseUrl: "http://127.0.0.1:8080",
+ checkTerminal: false,
+ failOnStale: false,
+ format: "summary",
+ help: false,
+ itemsJson: "",
+ jobId: "",
+ jobJson: "",
+ outputPath: "",
+ staleMinutes: 30,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--base-url" && argv[index + 1]) {
+ result.baseUrl = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--job-id" && argv[index + 1]) {
+ result.jobId = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--job-json" && argv[index + 1]) {
+ result.jobJson = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--items-json" && argv[index + 1]) {
+ result.itemsJson = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--stale-minutes" && argv[index + 1]) {
+ result.staleMinutes = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--check-terminal") {
+ result.checkTerminal = true;
+ continue;
+ }
+ if (arg === "--fail-on-stale") {
+ result.failOnStale = true;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC qcloop Status
+
+用法:
+ npm run agent-qc:qcloop-status -- --job-id "qcloop-job-id"
+ npm run agent-qc:qcloop-status -- --job-id "qcloop-job-id" --format json --output ./.lime/qc/qcloop-status.json
+ node scripts/agent-qc-qcloop-status.mjs --job-json ./job.json --items-json ./items.json
+
+选项:
+ --job-id ID 从 qcloop HTTP API 读取 /api/jobs/:id 和 /api/items?job_id=:id
+ --base-url URL qcloop API 地址,默认 http://127.0.0.1:8080
+ --job-json PATH 离线 job JSON 文件
+ --items-json PATH 离线 items JSON 文件
+ --output PATH 写入报告;默认 stdout
+ --stale-minutes N running item 超过 N 分钟且无 stdout/stderr 时标记 stale,默认 30
+ --format FMT 输出格式:summary | json
+ --check-terminal job 未 complete 或存在失败 / 卡住 item 时非 0 退出
+ --fail-on-stale 只要存在 stale item 就非 0 退出
+ -h, --help 显示帮助
+`);
+}
+
+function readJsonFile(filePath) {
+ return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"));
+}
+
+async function readJsonUrl(url) {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`请求失败 ${response.status}: ${url}`);
+ }
+ return response.json();
+}
+
+async function loadQCLoopData(options) {
+ if (options.jobJson || options.itemsJson) {
+ if (!options.jobJson || !options.itemsJson) {
+ throw new Error("离线模式必须同时提供 --job-json 与 --items-json。");
+ }
+ return {
+ job: readJsonFile(options.jobJson),
+ items: readJsonFile(options.itemsJson),
+ };
+ }
+
+ if (!options.jobId) {
+ throw new Error("必须提供 --job-id,或同时提供 --job-json / --items-json。");
+ }
+
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
+ return {
+ job: await readJsonUrl(`${baseUrl}/api/jobs/${encodeURIComponent(options.jobId)}`),
+ items: await readJsonUrl(`${baseUrl}/api/items/?job_id=${encodeURIComponent(options.jobId)}`),
+ };
+}
+
+function renderItemLine(item) {
+ const parts = [
+ `- ${item.scenarioId}`,
+ `status=${item.qcloopStatus}`,
+ `worker=${item.worker.status}`,
+ `attempt=${item.currentAttemptNo}`,
+ `qc=${item.currentQcNo}`,
+ ];
+ if (item.worker.durationMinutes !== null) {
+ parts.push(`duration=${item.worker.durationMinutes}m`);
+ }
+ parts.push(`stdout=${item.worker.stdoutLength}`);
+ parts.push(`stderr=${item.worker.stderrLength}`);
+ if (item.stale) {
+ parts.push(`stale=${item.staleReasons.join("; ")}`);
+ }
+ if (item.qc.feedback) {
+ parts.push(`feedback=${item.qc.feedback.replace(/\s+/g, " ").slice(0, 180)}`);
+ }
+ return parts.join(" ");
+}
+
+function renderSummary(report, validation) {
+ const lines = [
+ `job=${report.job.id}`,
+ `name=${report.job.name}`,
+ `status=${report.job.status}`,
+ `verdict=${report.verdict.status}`,
+ `items=${report.counts.total} terminal=${report.counts.terminal} nonTerminal=${report.counts.nonTerminal}`,
+ `success=${report.counts.success} failed=${report.counts.failed} exhausted=${report.counts.exhausted} running=${report.counts.running} pending=${report.counts.pending} stale=${report.counts.stale}`,
+ `summary=${report.verdict.summary}`,
+ `nextAction=${report.verdict.nextAction}`,
+ `valid=${validation.valid}`,
+ ];
+ if (validation.issues.length > 0) {
+ lines.push(`issues=${validation.issues.join("; ")}`);
+ }
+
+ const activeItems = report.items.filter(
+ (item) =>
+ !item.terminal ||
+ item.stale ||
+ item.qcloopStatus === "failed" ||
+ item.qcloopStatus === "exhausted",
+ );
+ if (activeItems.length > 0) {
+ lines.push("items:");
+ for (const item of activeItems) {
+ lines.push(renderItemLine(item));
+ }
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const { job, items } = await loadQCLoopData(options);
+ const report = buildQCLoopStatusReport({
+ job,
+ items,
+ options: {
+ staleMinutes: options.staleMinutes,
+ },
+ });
+ const validation = validateQCLoopStatusReport(report);
+ const content = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderSummary(report, validation);
+ writeOutput(options.outputPath, content);
+
+ if (!validation.valid) {
+ for (const issue of validation.issues) {
+ console.error(`[agent-qc-qcloop-status] ${issue}`);
+ }
+ process.exit(1);
+ }
+ if (options.failOnStale && report.counts.stale > 0) {
+ process.exit(2);
+ }
+ if (options.checkTerminal && report.verdict.status !== "complete") {
+ process.exit(3);
+ }
+}
+
+main().catch((error) => {
+ console.error(`[agent-qc-qcloop-status] ${error.message}`);
+ process.exit(1);
+});
diff --git a/scripts/agent-qc-release-summary.mjs b/scripts/agent-qc-release-summary.mjs
new file mode 100644
index 000000000..eaeea83db
--- /dev/null
+++ b/scripts/agent-qc-release-summary.mjs
@@ -0,0 +1,184 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import {
+ buildAgentQcReleaseSummary,
+ renderAgentQcReleaseMarkdown,
+ validateReleaseSummary,
+} from "./lib/agent-qc-release-summary-core.mjs";
+
+function parseArgs(argv) {
+ const result = {
+ check: false,
+ evidencePaths: [],
+ format: "markdown",
+ harnessSummaryPath: "",
+ harnessTrendPath: "",
+ help: false,
+ outputPath: "",
+ requireEvidence: true,
+ requiredRisks: [],
+ requiredScenarioManifestPath: "",
+ tag: "",
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--evidence" && argv[index + 1]) {
+ result.evidencePaths.push(String(argv[index + 1]).trim());
+ index += 1;
+ continue;
+ }
+ if (arg === "--harness-summary" && argv[index + 1]) {
+ result.harnessSummaryPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--harness-trend" && argv[index + 1]) {
+ result.harnessTrendPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--tag" && argv[index + 1]) {
+ result.tag = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--require-scenario-manifest" && argv[index + 1]) {
+ result.requiredScenarioManifestPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--require-risk" && argv[index + 1]) {
+ result.requiredRisks.push(String(argv[index + 1]).trim());
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+ if (arg === "--allow-missing-evidence") {
+ result.requireEvidence = false;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC Release Summary
+
+用法:
+ npm run agent-qc:release-summary -- --evidence ./.lime/qc/agent-qc-evidence.json --require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --tag v1.2.3
+ node scripts/agent-qc-release-summary.mjs --evidence evidence.json --harness-summary summary.json --harness-trend trend.json --output release-qc.md --check
+
+选项:
+ --evidence PATH Agent QC Evidence Pack,可重复
+ --harness-summary PATH harness-eval-summary.json
+ --harness-trend PATH harness-eval-trend.json
+ --tag TAG release tag
+ --require-scenario-manifest PATH 要求 Evidence Pack 覆盖该 manifest 中的场景
+ --require-risk RISK 搭配 --require-scenario-manifest 使用,可重复;默认 P0
+ --output PATH 写入文件;默认 stdout
+ --format FMT markdown | json
+ --check 非 pass 或缺证据时非 0 退出
+ --allow-missing-evidence 允许无 Evidence Pack,适合本地预览,不适合发布门禁
+ -h, --help 显示帮助
+`);
+}
+
+function readJsonFile(filePath) {
+ return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"));
+}
+
+function readOptionalJson(filePath) {
+ if (!filePath) {
+ return null;
+ }
+ return readJsonFile(filePath);
+}
+
+function loadRequiredScenarioIds(manifestPath, risks) {
+ if (!manifestPath) {
+ return [];
+ }
+ const manifest = readJsonFile(manifestPath);
+ const requiredRisks = new Set((risks.length > 0 ? risks : ["P0"]).map((risk) => risk.toUpperCase()));
+ return Array.isArray(manifest?.scenarios)
+ ? manifest.scenarios
+ .filter((scenario) => requiredRisks.has(String(scenario?.risk || "").toUpperCase()))
+ .map((scenario) => String(scenario?.id || "").trim())
+ .filter(Boolean)
+ : [];
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const evidencePacks = options.evidencePaths.map((sourcePath) => ({
+ sourcePath,
+ pack: readJsonFile(sourcePath),
+ }));
+ const requiredScenarioIds = loadRequiredScenarioIds(
+ options.requiredScenarioManifestPath,
+ options.requiredRisks,
+ );
+ const summary = buildAgentQcReleaseSummary({
+ evidencePacks,
+ harnessSummary: readOptionalJson(options.harnessSummaryPath),
+ harnessTrend: readOptionalJson(options.harnessTrendPath),
+ requiredScenarioIds,
+ tag: options.tag,
+ });
+ const validation = validateReleaseSummary(summary, {
+ requireEvidence: options.requireEvidence,
+ });
+ const content =
+ options.format === "json"
+ ? `${JSON.stringify({ ...summary, validation }, null, 2)}\n`
+ : renderAgentQcReleaseMarkdown(summary);
+
+ writeOutput(options.outputPath, content);
+
+ if (options.check && !validation.valid) {
+ for (const issue of validation.issues) {
+ console.error(`[agent-qc-release] ${issue}`);
+ }
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/agent-qc-report.mjs b/scripts/agent-qc-report.mjs
new file mode 100644
index 000000000..20a956385
--- /dev/null
+++ b/scripts/agent-qc-report.mjs
@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import {
+ createAgentQcReport,
+ readJsonFile,
+ renderAgentQcMarkdownReport,
+} from "./lib/agent-qc-report-core.mjs";
+
+const DEFAULT_MANIFEST_PATH = "docs/test/agent-qc-scenarios.manifest.json";
+const DEFAULT_SCHEMA_PATH = "docs/test/agent-qc-evidence.schema.json";
+
+function parseArgs(argv) {
+ const result = {
+ check: false,
+ format: "markdown",
+ help: false,
+ manifestPath: DEFAULT_MANIFEST_PATH,
+ outputPath: "",
+ schemaPath: DEFAULT_SCHEMA_PATH,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--manifest" && argv[index + 1]) {
+ result.manifestPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--schema" && argv[index + 1]) {
+ result.schemaPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime Agent QC 场景报告
+
+用法:
+ npm run agent-qc:report
+ npm run agent-qc:report:json
+ npm run agent-qc:check
+ node scripts/agent-qc-report.mjs --manifest docs/test/agent-qc-scenarios.manifest.json
+
+选项:
+ --check 如果 manifest 或 evidence schema 不合法,以非 0 退出
+ --format FMT 输出格式:markdown | json
+ --manifest PATH Agent QC manifest 路径
+ --schema PATH Evidence schema 路径
+ --output PATH 写入报告文件;默认输出到 stdout
+ -h, --help 显示帮助
+`);
+}
+
+function resolvePath(targetPath) {
+ return path.resolve(process.cwd(), targetPath);
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+
+ const resolvedOutputPath = resolvePath(outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const manifest = readJsonFile(resolvePath(options.manifestPath));
+ const schema = readJsonFile(resolvePath(options.schemaPath));
+ const packageJson = readJsonFile(resolvePath("package.json"));
+ const report = createAgentQcReport({ manifest, packageJson, evidenceSchema: schema });
+
+ const content =
+ options.format === "json"
+ ? `${JSON.stringify(report, null, 2)}\n`
+ : renderAgentQcMarkdownReport(report);
+
+ writeOutput(options.outputPath, content);
+
+ if (options.check && !report.valid) {
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/agent-runtime-approval-sandbox-smoke.mjs b/scripts/agent-runtime-approval-sandbox-smoke.mjs
new file mode 100644
index 000000000..51afd1947
--- /dev/null
+++ b/scripts/agent-runtime-approval-sandbox-smoke.mjs
@@ -0,0 +1,652 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+import {
+ buildApprovalSandboxSmokeEvidence,
+ renderApprovalSandboxTranscriptLines,
+} from "./lib/agent-runtime-approval-sandbox-smoke-core.mjs";
+import { runVitestSmoke } from "./lib/vitest-smoke-runner.mjs";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const rootDir = path.resolve(__dirname, "..");
+const DEFAULT_OUTPUT = path.join(
+ rootDir,
+ ".lime/qc/runtime-approval-sandbox-smoke.json",
+);
+const DEFAULT_HEALTH_URL = "http://127.0.0.1:3030/health";
+const DEFAULT_INVOKE_URL = "http://127.0.0.1:3030/invoke";
+const DEFAULT_TIMEOUT_MS = 60_000;
+const DEFAULT_INTERVAL_MS = 1_000;
+const DEFAULT_PROVIDER_PREFERENCE =
+ process.env.LIME_AGENT_QC_PROVIDER ||
+ process.env.LIME_E2E_PROVIDER ||
+ process.env.LIME_DEFAULT_PROVIDER ||
+ "";
+const DEFAULT_MODEL_PREFERENCE =
+ process.env.LIME_AGENT_QC_MODEL ||
+ process.env.LIME_E2E_MODEL ||
+ process.env.LIME_DEFAULT_MODEL ||
+ "";
+const APPROVAL_POLICY = "on-request";
+const SANDBOX_POLICY = "workspace-write";
+const PROVIDER_PICK_ORDER = ["deepseek", "doubao", "lime-hub"];
+
+function printHelp() {
+ console.log(`
+Lime Agent Runtime Approval / Sandbox Smoke
+
+用途:
+ 生成 tool / approval / sandbox 边界的确定性 smoke 证据,补齐 qcloop P0 场景的可审查摘要。
+
+用法:
+ node scripts/agent-runtime-approval-sandbox-smoke.mjs [选项]
+
+选项:
+ --output 写入 evidence JSON,默认 ./.lime/qc/runtime-approval-sandbox-smoke.json
+ --health-url DevBridge health 地址,默认 ${DEFAULT_HEALTH_URL}
+ --invoke-url DevBridge invoke 地址,默认 ${DEFAULT_INVOKE_URL}
+ --timeout-ms live runtime 等待超时,默认 ${DEFAULT_TIMEOUT_MS}
+ --interval-ms live runtime 轮询间隔,默认 ${DEFAULT_INTERVAL_MS}
+ --provider-preference live runtime 使用的 provider 偏好;默认读取 LIME_AGENT_QC_PROVIDER / LIME_E2E_PROVIDER,未设置时自动选本地启用 provider
+ --model-preference live runtime 使用的 model 偏好;默认读取 LIME_AGENT_QC_MODEL / LIME_E2E_MODEL,未设置时自动选 provider 的首个自定义模型
+ --skip-live-runtime 跳过 DevBridge live runtime transcript,仅生成确定性投影摘要
+ --no-write 只运行校验并打印摘要,不写 evidence JSON
+ -h, --help 显示帮助
+`);
+}
+
+function parseArgs(argv) {
+ const options = {
+ output: DEFAULT_OUTPUT,
+ healthUrl: DEFAULT_HEALTH_URL,
+ invokeUrl: DEFAULT_INVOKE_URL,
+ timeoutMs: DEFAULT_TIMEOUT_MS,
+ intervalMs: DEFAULT_INTERVAL_MS,
+ providerPreference: DEFAULT_PROVIDER_PREFERENCE,
+ modelPreference: DEFAULT_MODEL_PREFERENCE,
+ liveRuntime: true,
+ write: true,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--output" && argv[index + 1]) {
+ options.output = path.resolve(rootDir, String(argv[index + 1]));
+ index += 1;
+ continue;
+ }
+ if (arg === "--no-write") {
+ options.write = false;
+ continue;
+ }
+ if (arg === "--health-url" && argv[index + 1]) {
+ options.healthUrl = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--invoke-url" && argv[index + 1]) {
+ options.invokeUrl = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--timeout-ms" && argv[index + 1]) {
+ options.timeoutMs = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--interval-ms" && argv[index + 1]) {
+ options.intervalMs = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--provider-preference" && argv[index + 1]) {
+ options.providerPreference = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--model-preference" && argv[index + 1]) {
+ options.modelPreference = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--skip-live-runtime" || arg === "--no-live-runtime") {
+ options.liveRuntime = false;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ printHelp();
+ process.exit(0);
+ }
+ }
+
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 10_000) {
+ throw new Error("--timeout-ms 必须是 >= 10000 的数字");
+ }
+ if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
+ throw new Error("--interval-ms 必须是 >= 100 的数字");
+ }
+
+ return options;
+}
+
+function runVitest(label, args) {
+ return runVitestSmoke({
+ rootDir,
+ label,
+ args,
+ logPrefix: "smoke:agent-runtime-approval-sandbox",
+ });
+}
+
+function writeEvidence(outputPath, evidence) {
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+ fs.writeFileSync(outputPath, `${JSON.stringify(evidence, null, 2)}\n`);
+ return outputPath;
+}
+
+function writeEvidenceWithFallback(outputPath, evidence) {
+ try {
+ return writeEvidence(outputPath, evidence);
+ } catch (error) {
+ if (outputPath !== DEFAULT_OUTPUT) {
+ throw error;
+ }
+ const fallbackPath = path.join(os.tmpdir(), "lime-runtime-approval-sandbox-smoke.json");
+ const writtenPath = writeEvidence(fallbackPath, evidence);
+ console.warn(
+ `[smoke:agent-runtime-approval-sandbox] 默认 evidence 写入失败,已回退到 ${writtenPath}: ${error.message}`,
+ );
+ return writtenPath;
+ }
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function assert(condition, message) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+async function waitForHealth(options) {
+ const startedAt = Date.now();
+ let lastError = null;
+
+ while (Date.now() - startedAt < options.timeoutMs) {
+ try {
+ const response = await fetch(options.healthUrl, {
+ method: "GET",
+ signal: AbortSignal.timeout(Math.min(options.intervalMs, 5_000)),
+ });
+ const text = await response.text();
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ const payload = text ? JSON.parse(text) : {};
+ console.log(
+ `[smoke:agent-runtime-approval-sandbox] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
+ payload?.status ? ` status=${payload.status}` : ""
+ }`,
+ );
+ return payload;
+ } catch (error) {
+ lastError = error;
+ await sleep(options.intervalMs);
+ }
+ }
+
+ const detail =
+ lastError instanceof Error
+ ? lastError.message
+ : String(lastError || "unknown error");
+ throw new Error(
+ `[smoke:agent-runtime-approval-sandbox] DevBridge 未就绪,无法采集 live runtime transcript: ${detail}`,
+ );
+}
+
+async function invoke(options, cmd, args, timeoutMs = options.timeoutMs) {
+ const response = await fetch(options.invokeUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ cmd, args }),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ const text = await response.text();
+ if (!response.ok) {
+ throw new Error(`${cmd} HTTP ${response.status}: ${text}`);
+ }
+ const payload = text ? JSON.parse(text) : null;
+ if (payload?.error) {
+ throw new Error(`${cmd} error: ${payload.error}`);
+ }
+ return payload?.result;
+}
+
+function normalizeProviderId(provider) {
+ return String(provider?.id || provider?.provider_id || provider?.providerId || "")
+ .trim();
+}
+
+function providerEnabled(provider) {
+ return provider?.enabled !== false;
+}
+
+function pickModelPreference(provider) {
+ const candidates = [
+ ...(Array.isArray(provider?.custom_models) ? provider.custom_models : []),
+ ...(Array.isArray(provider?.customModels) ? provider.customModels : []),
+ ...(Array.isArray(provider?.models) ? provider.models : []),
+ ]
+ .map((value) =>
+ typeof value === "string"
+ ? value
+ : String(value?.name || value?.id || value?.model || "").trim(),
+ )
+ .filter(Boolean);
+
+ return (
+ candidates.find((value) => /flash|mini|lite/i.test(value)) ||
+ candidates[0] ||
+ ""
+ );
+}
+
+function pickProvider(providers, preferredProviderId) {
+ const enabled = providers.filter((provider) => providerEnabled(provider));
+ if (preferredProviderId) {
+ return (
+ enabled.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
+ providers.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
+ null
+ );
+ }
+
+ for (const providerId of PROVIDER_PICK_ORDER) {
+ const match = enabled.find((provider) => normalizeProviderId(provider) === providerId);
+ if (match) {
+ return match;
+ }
+ }
+
+ return enabled[0] || null;
+}
+
+async function resolveProviderPreference(options) {
+ const explicitProvider = String(options.providerPreference || "").trim();
+ const explicitModel = String(options.modelPreference || "").trim();
+ if (explicitProvider && explicitModel) {
+ return {
+ providerPreference: explicitProvider,
+ modelPreference: explicitModel,
+ source: "explicit",
+ };
+ }
+
+ const providers = await invoke(options, "get_api_key_providers", {}, 30_000);
+ const selected = pickProvider(Array.isArray(providers) ? providers : [], explicitProvider);
+ const providerId = normalizeProviderId(selected);
+ if (!providerId) {
+ throw new Error(
+ "[smoke:agent-runtime-approval-sandbox] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider",
+ );
+ }
+
+ let providerDetail = selected;
+ try {
+ providerDetail =
+ (await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) ||
+ selected;
+ } catch (error) {
+ console.warn(
+ `[smoke:agent-runtime-approval-sandbox] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`,
+ );
+ }
+
+ const modelPreference = explicitModel || pickModelPreference(providerDetail);
+ if (!modelPreference) {
+ throw new Error(
+ `[smoke:agent-runtime-approval-sandbox] provider ${providerId} 缺少可用模型;请传 --model-preference`,
+ );
+ }
+
+ return {
+ providerPreference: providerId,
+ modelPreference,
+ source: explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider",
+ };
+}
+
+function pickPermissionState(threadRead) {
+ return threadRead?.permission_state || threadRead?.permissionState || {};
+}
+
+function pendingRequests(threadRead) {
+ return threadRead?.pending_requests || threadRead?.pendingRequests || [];
+}
+
+function latestTurnStatus(threadRead) {
+ return (
+ threadRead?.diagnostics?.latest_turn_status ||
+ threadRead?.diagnostics?.latestTurnStatus ||
+ threadRead?.runtime_summary?.latestTurnStatus ||
+ threadRead?.runtimeSummary?.latestTurnStatus ||
+ threadRead?.status ||
+ null
+ );
+}
+
+function permissionField(permissionState, snakeKey, camelKey) {
+ return permissionState?.[snakeKey] ?? permissionState?.[camelKey] ?? null;
+}
+
+function summarizeThreadRead(threadRead) {
+ const permissionState = pickPermissionState(threadRead);
+ const requests = pendingRequests(threadRead);
+ return {
+ threadStatus: threadRead?.status || null,
+ latestTurnStatus: latestTurnStatus(threadRead),
+ primaryBlockingKind:
+ threadRead?.diagnostics?.primary_blocking_kind ||
+ threadRead?.diagnostics?.primaryBlockingKind ||
+ null,
+ primaryBlockingSummary:
+ threadRead?.diagnostics?.primary_blocking_summary ||
+ threadRead?.diagnostics?.primaryBlockingSummary ||
+ null,
+ pendingRequestCount: requests.length,
+ permissionStatus: permissionField(permissionState, "status", "status"),
+ confirmationStatus: permissionField(
+ permissionState,
+ "confirmation_status",
+ "confirmationStatus",
+ ),
+ confirmationRequestId: permissionField(
+ permissionState,
+ "confirmation_request_id",
+ "confirmationRequestId",
+ ),
+ confirmationSource: permissionField(
+ permissionState,
+ "confirmation_source",
+ "confirmationSource",
+ ),
+ askProfileKeys:
+ permissionField(permissionState, "ask_profile_keys", "askProfileKeys") || [],
+ requiredProfileKeys:
+ permissionField(
+ permissionState,
+ "required_profile_keys",
+ "requiredProfileKeys",
+ ) || [],
+ };
+}
+
+async function waitForThreadRead(options, sessionId, predicate, label) {
+ const startedAt = Date.now();
+ let lastSummary = null;
+
+ while (Date.now() - startedAt < options.timeoutMs) {
+ const threadRead = await invoke(options, "agent_runtime_get_thread_read", {
+ sessionId,
+ });
+ lastSummary = summarizeThreadRead(threadRead);
+ if (predicate(threadRead, lastSummary)) {
+ return { threadRead, summary: lastSummary };
+ }
+ await sleep(options.intervalMs);
+ }
+
+ throw new Error(
+ `[smoke:agent-runtime-approval-sandbox] ${label} 超时,最后线程摘要: ${JSON.stringify(lastSummary)}`,
+ );
+}
+
+function buildRuntimeContractMetadata() {
+ return {
+ harness: {
+ browser_assist: {
+ runtime_contract: {
+ contract_key: "browser_control",
+ routing_slot: "browser_reasoning_model",
+ execution_profile: {
+ profile_key: "browser_control_profile",
+ },
+ executor_adapter: {
+ adapter_key: "browser:browser_assist",
+ },
+ executor_binding: {
+ executor_kind: "browser",
+ binding_key: "browser_assist",
+ },
+ },
+ },
+ },
+ };
+}
+
+async function runPermissionDecisionFlow(options, workspaceId, providerPreference, decision) {
+ const confirmed = decision === "resolved";
+ const responseLabel = confirmed ? "允许本次执行" : "拒绝";
+ const sessionId = await invoke(options, "agent_runtime_create_session", {
+ workspaceId,
+ name: `Agent QC approval ${decision} ${Date.now()}`,
+ runStartHooks: false,
+ });
+ const turnId = `qc-approval-${decision}-${Date.now()}-${process.pid}`;
+ const eventName = `aster_stream_${sessionId}_${turnId}`;
+ const submittedPolicies = {
+ approvalPolicy: APPROVAL_POLICY,
+ sandboxPolicy: SANDBOX_POLICY,
+ };
+
+ await invoke(options, "agent_runtime_submit_turn", {
+ request: {
+ message:
+ "Agent QC runtime permission confirmation smoke:应在模型执行前创建真实权限确认请求。",
+ session_id: sessionId,
+ workspace_id: workspaceId,
+ event_name: eventName,
+ turn_id: turnId,
+ turn_config: {
+ provider_preference: providerPreference.providerPreference,
+ model_preference: providerPreference.modelPreference,
+ approval_policy: APPROVAL_POLICY,
+ sandbox_policy: SANDBOX_POLICY,
+ metadata: buildRuntimeContractMetadata(),
+ },
+ skip_pre_submit_resume: true,
+ },
+ });
+
+ const requested = await waitForThreadRead(
+ options,
+ sessionId,
+ (_threadRead, summary) =>
+ summary.confirmationStatus === "requested" &&
+ summary.pendingRequestCount > 0 &&
+ String(summary.confirmationRequestId || "").includes(turnId),
+ `等待 ${decision} flow 进入权限确认 requested`,
+ );
+
+ const requestId = requested.summary.confirmationRequestId;
+ assert(requestId, `${decision} flow 缺少 confirmationRequestId`);
+
+ await invoke(options, "agent_runtime_respond_action", {
+ request: {
+ session_id: sessionId,
+ request_id: requestId,
+ action_type: "elicitation",
+ confirmed,
+ response: JSON.stringify({ answer: responseLabel }),
+ user_data: { answer: responseLabel },
+ event_name: eventName,
+ action_scope: {
+ session_id: sessionId,
+ thread_id: sessionId,
+ turn_id: turnId,
+ },
+ },
+ });
+
+ const completed = await waitForThreadRead(
+ options,
+ sessionId,
+ (_threadRead, summary) =>
+ summary.pendingRequestCount === 0 &&
+ summary.confirmationStatus === decision,
+ `等待 ${decision} flow 写回确认结果`,
+ );
+
+ return {
+ decision: confirmed ? "resolved" : "denied",
+ sessionId,
+ turnId,
+ requestId,
+ providerPreference,
+ submittedPolicies,
+ before: requested.summary,
+ respond: {
+ confirmed,
+ responseLabel,
+ },
+ after: completed.summary,
+ };
+}
+
+async function collectLiveRuntimeTranscript(options) {
+ const health = await waitForHealth(options);
+ const providerPreference = await resolveProviderPreference(options);
+ console.log(
+ `[smoke:agent-runtime-approval-sandbox] live runtime provider: provider=${providerPreference.providerPreference} model=${providerPreference.modelPreference} source=${providerPreference.source}`,
+ );
+ const workspace = await invoke(options, "get_or_create_default_project");
+ const workspaceId = workspace?.id;
+ assert(workspaceId, "get_or_create_default_project 缺少 workspace id");
+
+ const deniedFlow = await runPermissionDecisionFlow(
+ options,
+ workspaceId,
+ providerPreference,
+ "denied",
+ );
+ const resolvedFlow = await runPermissionDecisionFlow(
+ options,
+ workspaceId,
+ providerPreference,
+ "resolved",
+ );
+ const flows = [deniedFlow, resolvedFlow];
+
+ return {
+ kind: "devbridge-runtime-permission-confirmation",
+ health,
+ workspaceId,
+ providerPreference,
+ flows,
+ assertions: {
+ devBridgeHealthy: health?.status === "ok" || Boolean(health),
+ permissionRequestCreatedBeforeModel: flows.every(
+ (flow) =>
+ flow.before.permissionStatus === "requires_confirmation" &&
+ flow.before.confirmationStatus === "requested" &&
+ flow.before.pendingRequestCount > 0 &&
+ flow.before.latestTurnStatus === "failed" &&
+ String(flow.before.confirmationRequestId || "").includes(flow.turnId),
+ ),
+ deniedDecisionClearsPendingRequest:
+ deniedFlow.after.confirmationStatus === "denied" &&
+ deniedFlow.after.pendingRequestCount === 0,
+ resolvedDecisionClearsPendingRequest:
+ resolvedFlow.after.confirmationStatus === "resolved" &&
+ resolvedFlow.after.pendingRequestCount === 0,
+ approvalPolicySubmitted: flows.every(
+ (flow) => flow.submittedPolicies.approvalPolicy === APPROVAL_POLICY,
+ ),
+ sandboxPolicySubmitted: flows.every(
+ (flow) => flow.submittedPolicies.sandboxPolicy === SANDBOX_POLICY,
+ ),
+ },
+ };
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ const commandResults = [];
+
+ commandResults.push(
+ runVitest("submit preferences 应透传 approval / sandbox policy", [
+ "src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts",
+ "src/components/agent/chat/hooks/agentStreamUserInputSubmission.test.ts",
+ "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts",
+ "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts",
+ ]),
+ );
+
+ commandResults.push(
+ runVitest("runtime projection 应保留 permission / tool lifecycle", [
+ "src/components/agent/chat/projection/agentUiEventProjection.test.ts",
+ "-t",
+ "runtime permission metadata|工具输入、成功输出和失败输出|plan approval",
+ ]),
+ );
+
+ commandResults.push(
+ runVitest("消息与时间线不应把权限确认误渲染为失败", [
+ "src/components/agent/chat/components/AgentThreadTimeline.test.tsx",
+ "src/components/agent/chat/components/MessageList.test.tsx",
+ "-t",
+ "运行时权限确认",
+ ]),
+ );
+
+ commandResults.push(
+ runVitest("Harness 面板应展示工具权限、待审批与 sandbox 来源", [
+ "src/components/agent/chat/components/HarnessStatusPanel.test.tsx",
+ "-t",
+ "存在工具库存时应展示工具与权限区块及来源统计|待审批区块应通过 artifact protocol",
+ ]),
+ );
+
+ const liveRuntimeTranscript = options.liveRuntime
+ ? await collectLiveRuntimeTranscript(options)
+ : null;
+
+ const evidence = buildApprovalSandboxSmokeEvidence({
+ commandResults,
+ generatedAt: new Date().toISOString(),
+ liveRuntimeTranscript,
+ });
+
+ if (options.liveRuntime && !evidence.coverage.liveRuntimeTranscript) {
+ throw new Error(
+ "[smoke:agent-runtime-approval-sandbox] live runtime transcript 未满足发布门禁断言",
+ );
+ }
+
+ for (const line of renderApprovalSandboxTranscriptLines(evidence)) {
+ console.log(line);
+ }
+
+ if (options.write) {
+ const evidencePath = writeEvidenceWithFallback(options.output, evidence);
+ console.log(`\n[smoke:agent-runtime-approval-sandbox] evidence: ${evidencePath}`);
+ }
+
+ console.log("\n[smoke:agent-runtime-approval-sandbox] 通过");
+}
+
+main().catch((error) => {
+ console.error(
+ error instanceof Error ? error.message : String(error || "unknown error"),
+ );
+ process.exit(1);
+});
diff --git a/scripts/agent-runtime-tool-surface-smoke.mjs b/scripts/agent-runtime-tool-surface-smoke.mjs
index 4a4bf8306..cc7ff9075 100644
--- a/scripts/agent-runtime-tool-surface-smoke.mjs
+++ b/scripts/agent-runtime-tool-surface-smoke.mjs
@@ -1,36 +1,21 @@
#!/usr/bin/env node
-import { spawnSync } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
+import { runVitestSmoke } from "./lib/vitest-smoke-runner.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..");
-const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
function runVitest(label, args) {
- console.log(`\n[smoke:agent-runtime-tool-surface] > ${label}`);
- const result = spawnSync(
- npmCommand,
- ["exec", "--", "vitest", "run", ...args],
- {
- cwd: rootDir,
- stdio: "inherit",
- env: process.env,
- },
- );
-
- if (result.error) {
- throw result.error;
- }
-
- if (typeof result.status === "number" && result.status !== 0) {
- const error = new Error(`[smoke:agent-runtime-tool-surface] ${label} 失败`);
- error.exitCode = result.status;
- throw error;
- }
+ return runVitestSmoke({
+ rootDir,
+ label,
+ args,
+ logPrefix: "smoke:agent-runtime-tool-surface",
+ });
}
function main() {
@@ -50,6 +35,9 @@ function main() {
"src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts",
]);
+ console.log(
+ '[smoke:agent-runtime-tool-surface] surface.summary: runtime inventory/strip/harness inventory 已一致透传;unsafeToolExposed=false; sources=runtime_tools|persisted_tools|default_policy',
+ );
console.log("\n[smoke:agent-runtime-tool-surface] 通过");
}
diff --git a/scripts/agent-service-skill-entry-smoke.mjs b/scripts/agent-service-skill-entry-smoke.mjs
index 3ea290fe4..ff71b500f 100644
--- a/scripts/agent-service-skill-entry-smoke.mjs
+++ b/scripts/agent-service-skill-entry-smoke.mjs
@@ -35,7 +35,55 @@ function runVitest(label, args) {
}
}
+function runCargoTest(label, args) {
+ console.log(`\n[smoke:agent-service-skill-entry] > ${label}`);
+ const result = spawnSync("cargo", ["test", ...args], {
+ cwd: path.join(rootDir, "src-tauri"),
+ stdio: "inherit",
+ env: process.env,
+ });
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ if (typeof result.status === "number" && result.status !== 0) {
+ const error = new Error(
+ `[smoke:agent-service-skill-entry] ${label} 失败`,
+ );
+ error.exitCode = result.status;
+ throw error;
+ }
+}
+
function main() {
+ runVitest("Skill Forge 前端 metadata 与工作台显式启用链路", [
+ "src/lib/api/capabilityDrafts.test.ts",
+ "src/lib/api/agentRuntime/inventoryClient.test.ts",
+ "src/components/agent/chat/utils/workspaceSkillBindingsMetadata.test.ts",
+ "src/components/agent/chat/utils/harnessRequestMetadata.test.ts",
+ "src/features/capability-drafts/workspaceSkillAgentAutomationDraft.test.ts",
+ ]);
+
+ [
+ "register_capability_draft_persists_readonly_http_preflight_provenance",
+ "registered_skill_becomes_ready_for_manual_enable_binding_candidate",
+ "explicit_runtime_enable_projects_ready_binding_allowlist",
+ "registered_skill_without_verification_provenance_is_blocked",
+ "execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs",
+ "should_project_workspace_skill_runtime_enable_as_callable_scope",
+ "allowlisted_session_should_preserve_workspace_skill_source_metadata",
+ "disabled_session_should_fail_execute",
+ ].forEach((testName) => {
+ runCargoTest(`Skill Forge Rust 定向测试: ${testName}`, [
+ "--manifest-path",
+ "Cargo.toml",
+ testName,
+ "--",
+ "--exact",
+ ]);
+ });
+
runVitest("服务技能入口路由与挂起参数", [
"src/components/skills/SkillsWorkspacePage.test.tsx",
"src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx",
diff --git a/scripts/browser-runtime-smoke.mjs b/scripts/browser-runtime-smoke.mjs
index 4d6027a75..f1315d4cd 100644
--- a/scripts/browser-runtime-smoke.mjs
+++ b/scripts/browser-runtime-smoke.mjs
@@ -25,7 +25,9 @@ const INVOKE_RETRY_DELAY_MS = 1_000;
const POST_HEALTH_SETTLE_MS = 3_000;
const POST_LAUNCH_SETTLE_MS = 1_500;
const READ_PAGE_TIMEOUT_MS = 45_000;
-const SMOKE_PROFILE_KEY = "smoke-browser-runtime";
+const DEFAULT_SMOKE_PROFILE_KEY =
+ process.env.LIME_BROWSER_RUNTIME_SMOKE_PROFILE_KEY ||
+ `smoke-browser-runtime-${process.pid}`;
function printHelp() {
console.log(`
@@ -43,6 +45,7 @@ Lime Browser Runtime Smoke
--timeout-ms 等待健康检查超时,默认 90000
--interval-ms 健康检查轮询间隔,默认 1000
--launch-url 启动浏览器会话的 URL,默认使用内置 data: 测试页
+ --profile-key smoke 专用浏览器 profile key,默认按进程隔离
--open-window 显式打开浏览器窗口
--headless 以无界面浏览器会话执行 smoke,避免弹出空白 Chrome
--stream-mode events | frames | both,默认 both
@@ -85,6 +88,11 @@ function parseArgs(argv) {
index += 1;
continue;
}
+ if (arg === "--profile-key" && argv[index + 1]) {
+ options.profileKey = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
if (arg === "--open-window") {
options.openWindow = true;
continue;
@@ -111,6 +119,9 @@ function parseArgs(argv) {
if (!options.launchUrl) {
throw new Error("--launch-url 不能为空");
}
+ if (!options.profileKey) {
+ options.profileKey = DEFAULT_SMOKE_PROFILE_KEY;
+ }
return options;
}
@@ -239,8 +250,9 @@ async function main() {
await waitForHealth(options);
await sleep(POST_HEALTH_SETTLE_MS);
- const profileKey = SMOKE_PROFILE_KEY;
+ const profileKey = options.profileKey;
let sessionId = null;
+ let cleanupStatus = "skipped";
try {
await closeSmokeProfileSession(
@@ -310,6 +322,38 @@ async function main() {
"browser_execute_action 未返回对应的 target_id",
);
+ const consoleResult = await invoke(options, "browser_execute_action", {
+ request: {
+ profile_key: profileKey,
+ action: "read_console_messages",
+ args: { since: 0 },
+ timeout_ms: Math.min(options.timeoutMs, READ_PAGE_TIMEOUT_MS),
+ },
+ });
+ assert(
+ consoleResult?.success === true,
+ "browser_execute_action(read_console_messages) 未成功",
+ );
+ const consoleCount = Array.isArray(consoleResult?.data?.messages)
+ ? consoleResult.data.messages.length
+ : 0;
+
+ const networkResult = await invoke(options, "browser_execute_action", {
+ request: {
+ profile_key: profileKey,
+ action: "read_network_requests",
+ args: { since: 0 },
+ timeout_ms: Math.min(options.timeoutMs, READ_PAGE_TIMEOUT_MS),
+ },
+ });
+ assert(
+ networkResult?.success === true,
+ "browser_execute_action(read_network_requests) 未成功",
+ );
+ const networkCount = Array.isArray(networkResult?.data?.events)
+ ? networkResult.data.events.length
+ : 0;
+
const auditLogs = await invoke(options, "get_browser_action_audit_logs", {
limit: 10,
});
@@ -344,7 +388,7 @@ async function main() {
);
console.log(
- `[smoke:browser-runtime] 通过 session=${sessionId} target=${sessionState.target_id} profile=${profileKey}`,
+ `[smoke:browser-runtime] 通过 session=${sessionId} target=${sessionState.target_id} profile=${profileKey} consoleEvents=${consoleCount} networkEvents=${networkCount}`,
);
} finally {
if (sessionId) {
@@ -354,7 +398,9 @@ async function main() {
session_id: sessionId,
},
});
+ cleanupStatus = "pass";
} catch (error) {
+ cleanupStatus = "failed";
console.warn(
`[smoke:browser-runtime] 清理会话失败: ${
error instanceof Error ? error.message : String(error)
@@ -368,6 +414,9 @@ async function main() {
profileKey,
"关闭 smoke profile 失败",
);
+ console.log(
+ `[smoke:browser-runtime] cleanup=${cleanupStatus} session=${sessionId ?? "none"} profile=${profileKey}`,
+ );
}
}
diff --git a/scripts/claw-chat-ready-streaming-smoke.mjs b/scripts/claw-chat-ready-streaming-smoke.mjs
new file mode 100644
index 000000000..15f2debcb
--- /dev/null
+++ b/scripts/claw-chat-ready-streaming-smoke.mjs
@@ -0,0 +1,1393 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { chromium } from "playwright";
+
+const DEFAULTS = {
+ appUrl: "http://127.0.0.1:1420/",
+ healthUrl: "http://127.0.0.1:3030/health",
+ invokeUrl: "http://127.0.0.1:3030/invoke",
+ timeoutMs: 180_000,
+ intervalMs: 1_000,
+ providerPreference:
+ process.env.LIME_AGENT_QC_PROVIDER ||
+ process.env.LIME_E2E_PROVIDER ||
+ process.env.LIME_DEFAULT_PROVIDER ||
+ "",
+ modelPreference:
+ process.env.LIME_AGENT_QC_MODEL ||
+ process.env.LIME_E2E_MODEL ||
+ process.env.LIME_DEFAULT_MODEL ||
+ "",
+ evidenceDir: path.join(
+ process.cwd(),
+ ".lime",
+ "qc",
+ "gui-evidence",
+ "claw-chat-ready-streaming",
+ ),
+ prefix: "claw-chat-ready-streaming",
+};
+
+const POST_HEALTH_SETTLE_MS = 1_500;
+const ONBOARDING_VERSION = "1.1.0";
+const LONG_PROMPT = [
+ "E2E 中断测试:请输出 160 行。",
+ "每一行都必须使用格式:中断测试第 N 行。",
+ "从 1 开始递增,不要合并行,不要提前总结。",
+ "如果收到停止请求应立即停止,不要补完剩余行。",
+].join("\n");
+const RECOVERY_EXPECTED_TEXT = "复原完成";
+const RECOVERY_PROMPT =
+ "停止后恢复测试:这是一个新的独立回合,请忽略上一条输出 160 行的要求。只输出“复原完成”这四个字,不要输出行号、解释或其他内容。";
+const FAST_RESPONSE_MODE_STORAGE_KEY = "lime:agent-fast-response-mode";
+const TASK_CENTER_OPEN_TASK_EVENT = "lime:task-center:open-task";
+
+function printHelp() {
+ console.log(`
+Lime Claw Chat Ready Streaming Smoke
+
+用途:
+ 通过真实 Claw 聊天页面验证 workspace ready、流式增量、中断与恢复下一轮,
+ 并输出 GUI / runtime / console / network 四类证据。
+
+用法:
+ npm run smoke:claw-chat-ready-streaming
+ npm run smoke:claw-chat-ready-streaming -- --provider-preference deepseek --model-preference deepseek-v4-flash
+
+选项:
+ --app-url 前端地址,默认 http://127.0.0.1:1420/
+ --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
+ --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
+ --timeout-ms 总超时,默认 180000
+ --interval-ms 轮询间隔,默认 1000
+ --provider-preference 可选,显式指定 provider
+ --model-preference 可选,显式指定 model
+ --evidence-dir 证据目录,默认 .lime/qc/gui-evidence/claw-chat-ready-streaming
+ --prefix 证据文件前缀,默认 claw-chat-ready-streaming
+ -h, --help 显示帮助
+`);
+}
+
+function parseArgs(argv) {
+ const options = { ...DEFAULTS };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--app-url" && argv[index + 1]) {
+ options.appUrl = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--health-url" && argv[index + 1]) {
+ options.healthUrl = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--invoke-url" && argv[index + 1]) {
+ options.invokeUrl = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--timeout-ms" && argv[index + 1]) {
+ options.timeoutMs = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--interval-ms" && argv[index + 1]) {
+ options.intervalMs = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (arg === "--provider-preference" && argv[index + 1]) {
+ options.providerPreference = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--model-preference" && argv[index + 1]) {
+ options.modelPreference = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--evidence-dir" && argv[index + 1]) {
+ options.evidenceDir = path.resolve(String(argv[index + 1]).trim());
+ index += 1;
+ continue;
+ }
+ if (arg === "--prefix" && argv[index + 1]) {
+ options.prefix = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+ if (arg === "--help" || arg === "-h") {
+ printHelp();
+ process.exit(0);
+ }
+ }
+
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 30_000) {
+ throw new Error("--timeout-ms 必须是 >= 30000 的数字");
+ }
+ if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
+ throw new Error("--interval-ms 必须是 >= 100 的数字");
+ }
+ if (!options.appUrl) {
+ throw new Error("--app-url 不能为空");
+ }
+ if (!options.evidenceDir) {
+ throw new Error("--evidence-dir 不能为空");
+ }
+ if (!options.prefix) {
+ throw new Error("--prefix 不能为空");
+ }
+
+ return options;
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function assert(condition, message) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+function logStage(label) {
+ console.log(`[smoke:claw-chat-ready-streaming] stage=${label}`);
+}
+
+async function waitForHealth(options) {
+ const startedAt = Date.now();
+ let lastError = null;
+
+ while (Date.now() - startedAt < options.timeoutMs) {
+ try {
+ const response = await fetch(options.healthUrl, { method: "GET" });
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ console.log(
+ `[smoke:claw-chat-ready-streaming] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
+ payload?.status ? ` status=${payload.status}` : ""
+ }`,
+ );
+ return payload;
+ } catch (error) {
+ lastError = error;
+ await sleep(options.intervalMs);
+ }
+ }
+
+ const detail =
+ lastError instanceof Error
+ ? lastError.message
+ : String(lastError || "unknown error");
+ throw new Error(
+ `[smoke:claw-chat-ready-streaming] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`,
+ );
+}
+
+async function invoke(options, cmd, args, timeoutMs = options.timeoutMs) {
+ const response = await fetch(options.invokeUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ cmd, args }),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+
+ const text = await response.text();
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}; ${text}`);
+ }
+
+ const payload = text ? JSON.parse(text) : null;
+ if (payload?.error) {
+ throw new Error(String(payload.error));
+ }
+
+ return payload?.result;
+}
+
+async function restoreOriginalProviderConfig(options, originalProviderConfig, sessionId) {
+ if (
+ !originalProviderConfig?.providerName ||
+ !originalProviderConfig?.modelName
+ ) {
+ return;
+ }
+
+ const providerSelector =
+ originalProviderConfig.providerSelector || originalProviderConfig.providerName;
+ const request = {
+ provider_id:
+ providerSelector && providerSelector !== originalProviderConfig.providerName
+ ? providerSelector
+ : undefined,
+ provider_name: originalProviderConfig.providerName,
+ model_name: originalProviderConfig.modelName,
+ };
+
+ await invoke(
+ options,
+ "aster_agent_configure_provider",
+ {
+ request,
+ sessionId: sessionId || "agent-qc-provider-restore",
+ },
+ 45_000,
+ );
+}
+
+async function waitForCondition(label, predicate, timeoutMs, intervalMs) {
+ const startedAt = Date.now();
+ let lastValue = null;
+
+ while (Date.now() - startedAt < timeoutMs) {
+ lastValue = await predicate();
+ if (lastValue) {
+ return lastValue;
+ }
+ await sleep(intervalMs);
+ }
+
+ throw new Error(
+ `[smoke:claw-chat-ready-streaming] ${label} 超时,最后结果: ${JSON.stringify(lastValue)}`,
+ );
+}
+
+
+function normalizeProviderId(provider) {
+ return String(provider?.id || provider?.provider_id || provider?.providerId || "").trim();
+}
+
+function providerEnabled(provider) {
+ return provider?.enabled !== false;
+}
+
+function pickModelPreference(provider) {
+ const candidates = [
+ provider?.default_model,
+ provider?.defaultModel,
+ ...(Array.isArray(provider?.custom_models) ? provider.custom_models : []),
+ ...(Array.isArray(provider?.customModels) ? provider.customModels : []),
+ ...(Array.isArray(provider?.models) ? provider.models : []),
+ ]
+ .map((value) =>
+ typeof value === "string"
+ ? value
+ : String(value?.name || value?.id || value?.model || "").trim(),
+ )
+ .filter(Boolean);
+
+ return (
+ candidates.find((value) => /flash|mini|lite/i.test(value)) ||
+ candidates[0] ||
+ ""
+ );
+}
+
+function pickProvider(providers, preferredProviderId) {
+ const enabled = providers.filter((provider) => providerEnabled(provider));
+ if (preferredProviderId) {
+ return (
+ enabled.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
+ enabled.find((provider) => provider?.provider_name === preferredProviderId) ||
+ providers.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
+ providers.find((provider) => provider?.provider_name === preferredProviderId) ||
+ null
+ );
+ }
+
+ for (const providerId of ["deepseek", "doubao", "lime-hub"]) {
+ const match = enabled.find((provider) => normalizeProviderId(provider) === providerId);
+ if (match) {
+ return match;
+ }
+ }
+
+ return enabled[0] || null;
+}
+
+async function resolveProviderPreference(options, agentStatus, providers) {
+ const explicitProvider = String(options.providerPreference || "").trim();
+ const explicitModel = String(options.modelPreference || "").trim();
+ if (explicitProvider && explicitModel) {
+ return {
+ providerPreference: explicitProvider,
+ modelPreference: explicitModel,
+ source: "explicit",
+ };
+ }
+
+ const statusProvider = String(
+ agentStatus?.provider_selector || agentStatus?.provider_name || "",
+ ).trim();
+ const statusModel = String(agentStatus?.model_name || "").trim();
+ if (!explicitProvider && !explicitModel && statusProvider && statusModel) {
+ return {
+ providerPreference: statusProvider,
+ modelPreference: statusModel,
+ source: "agent-status",
+ };
+ }
+
+ const selected = pickProvider(
+ Array.isArray(providers) ? providers : [],
+ explicitProvider || statusProvider,
+ );
+ const providerId = normalizeProviderId(selected) || String(selected?.provider_name || "").trim();
+ if (!providerId) {
+ throw new Error(
+ "[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider",
+ );
+ }
+
+ let providerDetail = selected;
+ try {
+ providerDetail =
+ (await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) ||
+ selected;
+ } catch (error) {
+ console.warn(
+ `[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`,
+ );
+ }
+
+ const modelPreference = explicitModel || statusModel || pickModelPreference(providerDetail);
+ if (!modelPreference) {
+ throw new Error(
+ `[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用模型;请传 --model-preference`,
+ );
+ }
+
+ return {
+ providerPreference: providerId,
+ modelPreference,
+ source: explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider",
+ };
+}
+
+function textOfMessage(message) {
+ if (!message || !Array.isArray(message.content)) {
+ return "";
+ }
+ return message.content
+ .map((part) => {
+ if (part && typeof part.text === "string") {
+ return part.text;
+ }
+ if (part && typeof part.output === "string") {
+ return part.output;
+ }
+ return "";
+ })
+ .filter(Boolean)
+ .join("\n");
+}
+
+function allAssistantText(session) {
+ return (session?.messages || [])
+ .filter((message) => message?.role === "assistant")
+ .map(textOfMessage)
+ .join("\n");
+}
+
+function allAgentItemText(session) {
+ return (session?.items || [])
+ .filter((item) => item?.type === "agent_message")
+ .map((item) => {
+ if (typeof item?.text === "string") {
+ return item.text;
+ }
+ if (typeof item?.content === "string") {
+ return item.content;
+ }
+ return "";
+ })
+ .filter(Boolean)
+ .join("\n");
+}
+
+function findTurn(session, turnId) {
+ return (session?.turns || []).find((turn) => turn?.id === turnId) || null;
+}
+
+function queuedTurnCount(session) {
+ return Array.isArray(session?.queued_turns) ? session.queued_turns.length : 0;
+}
+
+function normalizeConsoleLine(item) {
+ const location =
+ item.location && item.location.url
+ ? ` @ ${item.location.url}:${item.location.lineNumber ?? ""}`
+ : "";
+ return `[${item.type}] ${item.text}${location}`;
+}
+
+function buildPageSnapshotScript(recoveryExpectedText) {
+ return `(() => {
+ const textareas = Array.from(
+ document.querySelectorAll('textarea[name="agent-chat-message"]'),
+ );
+ const textarea = textareas.at(-1) ?? null;
+ const stopButton =
+ Array.from(document.querySelectorAll("button")).find(
+ (button) => button.getAttribute("aria-label") === "停止",
+ ) ?? null;
+ const sendButton =
+ Array.from(document.querySelectorAll("button")).find(
+ (button) => button.getAttribute("title") === "发送",
+ ) ?? null;
+ const bodyText = document.body?.innerText || "";
+ const streamLinePattern = /中断测试第\\s*\\d+\\s*行/;
+ const finalLinePattern = /中断测试第\\s*160\\s*行/;
+ const recoveryExpected = ${JSON.stringify(recoveryExpectedText)};
+ const streamText = bodyText
+ .split("\\n")
+ .filter((line) => streamLinePattern.test(line))
+ .join("\\n");
+ return {
+ href: window.location.href,
+ ready: Boolean(textarea) && !textarea.disabled,
+ hasTextarea: Boolean(textarea),
+ textareaValue: textarea ? textarea.value : "",
+ sendDisabled: Boolean(sendButton?.disabled),
+ stopVisible: Boolean(stopButton),
+ stoppedMarker: bodyText.includes("用户已停止当前执行"),
+ recoveryVisible: bodyText.includes(recoveryExpected),
+ streamLineCount: streamText ? streamText.split("\\n").filter(Boolean).length : 0,
+ streamTextLength: streamText.length,
+ finalLineSeen: finalLinePattern.test(streamText),
+ buttons: Array.from(document.querySelectorAll("button")).map((button) => ({
+ text: (button.textContent || "").trim(),
+ aria: button.getAttribute("aria-label"),
+ title: button.getAttribute("title"),
+ disabled: Boolean(button.disabled),
+ })),
+ };
+ })()`;
+}
+
+async function readPageSnapshot(page) {
+ return page
+ .evaluate(buildPageSnapshotScript(RECOVERY_EXPECTED_TEXT))
+ .catch(() => null);
+}
+
+function isLikelyDetachedBlankTaskSnapshot(snapshot) {
+ return (
+ Boolean(snapshot?.ready) &&
+ !snapshot?.stopVisible &&
+ !snapshot?.stoppedMarker &&
+ !snapshot?.recoveryVisible &&
+ Number(snapshot?.streamLineCount || 0) === 0 &&
+ Number(snapshot?.streamTextLength || 0) === 0
+ );
+}
+
+async function dispatchTaskCenterOpenTask(page, sessionId, workspaceId) {
+ return page
+ .evaluate(
+ ({ eventName, sessionId: targetSessionId, workspaceId: targetWorkspaceId }) => {
+ const normalizedSessionId = String(targetSessionId || "").trim();
+ if (!normalizedSessionId) {
+ return { dispatched: false, reason: "missing-session-id" };
+ }
+
+ const event = new CustomEvent(eventName, {
+ cancelable: true,
+ detail: {
+ sessionId: normalizedSessionId,
+ workspaceId: targetWorkspaceId || null,
+ source: "conversation_shelf",
+ },
+ });
+ const notCanceled = window.dispatchEvent(event);
+ return { dispatched: true, canceled: !notCanceled };
+ },
+ {
+ eventName: TASK_CENTER_OPEN_TASK_EVENT,
+ sessionId,
+ workspaceId,
+ },
+ )
+ .catch((error) => ({
+ dispatched: false,
+ reason: error instanceof Error ? error.message : String(error),
+ }));
+}
+
+function writeJsonFile(filePath, value) {
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
+}
+
+function consoleNetworkSummary(consoleMessages, failedRequests) {
+ const consoleErrors = consoleMessages.filter(
+ (item) => item.type === "error" || item.type === "pageerror",
+ );
+ const consoleWarnings = consoleMessages.filter(
+ (item) => item.type === "warning",
+ );
+ const mockFallbackLines = consoleMessages
+ .filter((item) => item.text.includes("[Mock]"))
+ .map(normalizeConsoleLine);
+
+ return {
+ consoleErrors,
+ consoleWarnings,
+ mockFallbackLines,
+ networkErrorTop: Object.entries(
+ failedRequests.reduce((acc, item) => {
+ const key = `${item.failure} ${item.url}`;
+ acc[key] = (acc[key] || 0) + 1;
+ return acc;
+ }, {}),
+ )
+ .sort((left, right) => right[1] - left[1])
+ .slice(0, 12),
+ };
+}
+
+function writeConsoleNetworkEvidence(
+ evidenceDir,
+ prefix,
+ consoleMessages,
+ invokes,
+ failedRequests,
+) {
+ const summary = consoleNetworkSummary(consoleMessages, failedRequests);
+ writeJsonFile(path.join(evidenceDir, `${prefix}-network-invoke.json`), {
+ invokes,
+ failedRequests,
+ });
+ fs.writeFileSync(
+ path.join(evidenceDir, `${prefix}-console.txt`),
+ [
+ `Errors: ${summary.consoleErrors.length}`,
+ `Warnings: ${summary.consoleWarnings.length}`,
+ `MockLines: ${summary.mockFallbackLines.length}`,
+ "",
+ ...consoleMessages.map(normalizeConsoleLine),
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ return summary;
+}
+
+function buildStorageBootstrapScript(storageMarker, storageOverrides) {
+ return `(() => {
+ const marker = ${JSON.stringify(storageMarker)};
+ const overrides = ${JSON.stringify(storageOverrides)};
+ const keys = Object.keys(overrides);
+ if (!window.sessionStorage.getItem(marker)) {
+ const original = {};
+ for (const key of keys) {
+ original[key] = window.localStorage.getItem(key);
+ }
+ window.sessionStorage.setItem(marker, JSON.stringify(original));
+ }
+ for (const [key, value] of Object.entries(overrides)) {
+ window.localStorage.setItem(key, String(value));
+ }
+ return true;
+ })()`;
+}
+
+function buildRestoreLocalStorageScript(storageMarker) {
+ return `((marker) => {
+ const raw = window.sessionStorage.getItem(marker);
+ if (!raw) return false;
+ const original = JSON.parse(raw);
+ for (const [key, value] of Object.entries(original)) {
+ if (value === null) {
+ window.localStorage.removeItem(key);
+ } else {
+ window.localStorage.setItem(key, String(value));
+ }
+ }
+ window.sessionStorage.removeItem(marker);
+ return true;
+ })(${JSON.stringify(storageMarker)})`;
+}
+
+async function launchPlaywrightContext() {
+ const userDataDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), `lime-claw-chat-ready-streaming-${process.pid}-`),
+ );
+ const launchOptions = {
+ headless: true,
+ viewport: { width: 1440, height: 960 },
+ };
+
+ try {
+ const context = await chromium.launchPersistentContext(userDataDir, {
+ ...launchOptions,
+ channel: "chrome",
+ });
+ return { context, userDataDir };
+ } catch (chromeError) {
+ console.warn(
+ `[smoke:claw-chat-ready-streaming] Chrome channel 启动失败,尝试 Playwright 自带 Chromium: ${
+ chromeError instanceof Error ? chromeError.message : String(chromeError)
+ }`,
+ );
+ const context = await chromium.launchPersistentContext(
+ userDataDir,
+ launchOptions,
+ );
+ return { context, userDataDir };
+ }
+}
+
+async function main() {
+ if (typeof fetch !== "function") {
+ throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
+ }
+
+ const options = parseArgs(process.argv.slice(2));
+ const prefix = options.prefix;
+ const evidenceDir = options.evidenceDir;
+ fs.mkdirSync(evidenceDir, { recursive: true });
+
+ logStage("wait-health");
+ const health = await waitForHealth(options);
+ await sleep(POST_HEALTH_SETTLE_MS);
+
+ logStage("prepare-runtime");
+ const defaultProject =
+ (await invoke(options, "get_or_create_default_project", undefined, 30_000).catch(
+ () => null,
+ )) || null;
+ const workspaceId = defaultProject?.id || "default";
+ const agentStatus = await invoke(options, "aster_agent_init", undefined, 45_000);
+ const providers = await invoke(
+ options,
+ "get_api_key_providers",
+ undefined,
+ 30_000,
+ ).catch(() => []);
+ const providerUiState = await invoke(
+ options,
+ "get_provider_ui_state",
+ undefined,
+ 30_000,
+ ).catch(() => null);
+
+ const enabledProviders = Array.isArray(providers)
+ ? providers.filter((provider) => providerEnabled(provider))
+ : [];
+ assert(
+ Boolean(agentStatus?.provider_configured) || enabledProviders.length > 0,
+ `当前 Agent provider 未配置,无法执行 Claw 流式 smoke: ${JSON.stringify(agentStatus)}`,
+ );
+ const providerResolution = await resolveProviderPreference(
+ options,
+ agentStatus,
+ enabledProviders,
+ );
+ const preferredProvider = providerResolution.providerPreference;
+ const preferredModel = providerResolution.modelPreference;
+
+ console.log(
+ `[smoke:claw-chat-ready-streaming] live runtime provider: provider=${preferredProvider} model=${preferredModel} source=${providerResolution.source}`,
+ );
+
+ logStage("launch-browser");
+ const { context, userDataDir } = await launchPlaywrightContext();
+ let page = null;
+ const consoleMessages = [];
+ const invokes = [];
+ const failedRequests = [];
+ const storageMarker = `${prefix}:original-local-storage`;
+
+ const summary = {
+ scenarioId: "claw-chat-ready-streaming",
+ prefix,
+ verdict: "fail",
+ appUrl: options.appUrl,
+ bridge: health,
+ workspaceId,
+ providerPreference: preferredProvider,
+ modelPreference: preferredModel,
+ agentStatusBefore: {
+ initialized: Boolean(agentStatus?.initialized),
+ provider_configured: Boolean(agentStatus?.provider_configured),
+ provider_name: agentStatus?.provider_name || null,
+ provider_selector: agentStatus?.provider_selector || null,
+ model_name: agentStatus?.model_name || null,
+ credential_uuid: providerUiState?.credential_uuid ? "[redacted]" : null,
+ },
+ steps: [],
+ };
+ const originalProviderConfig = {
+ providerName: agentStatus?.provider_name || "",
+ providerSelector: agentStatus?.provider_selector || "",
+ modelName: agentStatus?.model_name || "",
+ };
+ let submittedSessionId = "";
+
+ try {
+ const scopedProviderKey = `agent_pref_provider_${workspaceId}`;
+ const scopedModelKey = `agent_pref_model_${workspaceId}`;
+ const scopedMigratedKey = `agent_pref_migrated_${workspaceId}`;
+ const storageOverrides = {
+ lime_onboarding_complete: "true",
+ lime_onboarding_version: ONBOARDING_VERSION,
+ lime_user_profile: "developer",
+ [FAST_RESPONSE_MODE_STORAGE_KEY]: "off",
+ agent_pref_provider: JSON.stringify(preferredProvider),
+ agent_pref_model: JSON.stringify(preferredModel),
+ agent_pref_provider_global: JSON.stringify(preferredProvider),
+ agent_pref_model_global: JSON.stringify(preferredModel),
+ [scopedProviderKey]: JSON.stringify(preferredProvider),
+ [scopedModelKey]: JSON.stringify(preferredModel),
+ [scopedMigratedKey]: JSON.stringify(true),
+ };
+
+ await context.addInitScript(buildStorageBootstrapScript(storageMarker, storageOverrides));
+ page = context.pages()[0] ?? (await context.newPage());
+
+ page.on("console", (message) => {
+ consoleMessages.push({
+ type: message.type(),
+ text: message.text(),
+ location: message.location(),
+ });
+ });
+ page.on("pageerror", (error) => {
+ consoleMessages.push({
+ type: "pageerror",
+ text: error.message,
+ location: {},
+ });
+ });
+ page.on("request", (request) => {
+ const requestUrl = request.url();
+ if (
+ request.method() !== "POST" ||
+ (requestUrl !== options.invokeUrl && !requestUrl.endsWith("/invoke"))
+ ) {
+ return;
+ }
+ const postData = request.postData();
+ if (!postData) {
+ return;
+ }
+ try {
+ const parsed = JSON.parse(postData);
+ invokes.push({
+ at: new Date().toISOString(),
+ cmd: parsed.cmd,
+ args: parsed.args,
+ });
+ } catch {
+ invokes.push({
+ at: new Date().toISOString(),
+ cmd: "",
+ raw: postData,
+ });
+ }
+ });
+ page.on("requestfailed", (request) => {
+ failedRequests.push({
+ url: request.url(),
+ method: request.method(),
+ failure: request.failure()?.errorText || "unknown",
+ });
+ });
+
+ logStage("open-app");
+ await page.goto(options.appUrl, { waitUntil: "domcontentloaded" });
+ await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => undefined);
+ await page
+ .getByRole("button", { name: "新建任务" })
+ .click({ timeout: 20_000 })
+ .catch(() => undefined);
+
+ logStage("wait-composer-ready");
+ await page.locator('textarea[name="agent-chat-message"]').last().waitFor({
+ state: "visible",
+ timeout: 60_000,
+ });
+ await page.waitForFunction(
+ () => {
+ const textareas = Array.from(
+ document.querySelectorAll('textarea[name="agent-chat-message"]'),
+ );
+ const textarea = textareas.at(-1);
+ return Boolean(textarea && !textarea.disabled);
+ },
+ null,
+ { timeout: 60_000 },
+ );
+ const readySnapshot = await readPageSnapshot(page);
+ assert(readySnapshot, "读取 ready 页面快照失败");
+ summary.readySnapshot = readySnapshot;
+ summary.steps.push("ready");
+ await page.screenshot({
+ path: path.join(evidenceDir, `${prefix}-01-ready.png`),
+ fullPage: true,
+ });
+
+ logStage("submit-long-turn");
+ const longSubmitStart = invokes.length;
+ const textarea = page.locator('textarea[name="agent-chat-message"]').last();
+ await textarea.fill(LONG_PROMPT);
+ await page.getByRole("button", { name: "发送" }).last().click({
+ timeout: 30_000,
+ });
+ const longSubmit = await waitForCondition(
+ "等待长 turn submit",
+ () =>
+ invokes
+ .slice(longSubmitStart)
+ .find(
+ (item) =>
+ item.cmd === "agent_runtime_submit_turn" &&
+ String(item.args?.request?.message || "").includes("E2E 中断测试"),
+ ) || null,
+ 30_000,
+ 250,
+ );
+ const longRequest = longSubmit.args?.request || {};
+ const sessionId = String(longRequest.session_id || "");
+ const longTurnId = String(longRequest.turn_id || "");
+ assert(sessionId, "长 turn 缺少 session_id");
+ assert(longTurnId, "长 turn 缺少 turn_id");
+ submittedSessionId = sessionId;
+ summary.sessionId = sessionId;
+ summary.longTurnId = longTurnId;
+ summary.longSubmitTurnConfig = longRequest.turn_config || null;
+
+ const firstDelta = await waitForCondition(
+ "等待首个流式增量与停止按钮",
+ async () => {
+ const snapshot = await readPageSnapshot(page);
+ if (!snapshot) {
+ return null;
+ }
+ const session = await invoke(
+ options,
+ "agent_runtime_get_session",
+ { sessionId },
+ 20_000,
+ ).catch(() => null);
+ const turn = findTurn(session, longTurnId);
+ if (turn && ["failed", "aborted", "completed"].includes(turn.status)) {
+ summary.preStreamTurn = turn;
+ const errorMessage = String(turn.error_message || turn.errorMessage || "").trim();
+ throw new Error(
+ `[smoke:claw-chat-ready-streaming] 长 turn 在首个流式增量前结束: status=${turn.status}${
+ errorMessage ? ` error=${errorMessage}` : ""
+ }`,
+ );
+ }
+ return snapshot?.stopVisible && snapshot.streamTextLength > 0
+ ? snapshot
+ : null;
+ },
+ 90_000,
+ 500,
+ );
+ summary.firstDelta = firstDelta;
+ summary.steps.push("running-stop-visible");
+ await page.screenshot({
+ path: path.join(evidenceDir, `${prefix}-02-running-stop-visible.png`),
+ fullPage: true,
+ });
+
+ const runningSession = await waitForCondition(
+ "等待 turn 进入 running",
+ async () => {
+ const session = await invoke(
+ options,
+ "agent_runtime_get_session",
+ { sessionId },
+ 20_000,
+ ).catch(() => null);
+ const turn = findTurn(session, longTurnId);
+ return turn?.status === "running" ? { turn, session } : null;
+ },
+ 60_000,
+ 1_000,
+ );
+ summary.runningTurnObserved = runningSession.turn;
+
+ logStage("interrupt-long-turn");
+ const interruptStart = invokes.length;
+ await page.getByRole("button", { name: "停止" }).last().click({
+ timeout: 30_000,
+ });
+ const interruptInvoke = await waitForCondition(
+ "等待 interrupt invoke",
+ () =>
+ invokes
+ .slice(interruptStart)
+ .find(
+ (item) =>
+ item.cmd === "agent_runtime_interrupt_turn" &&
+ String(item.args?.request?.session_id || "") === sessionId,
+ ) || null,
+ 30_000,
+ 250,
+ );
+ const interruptRequest = interruptInvoke.args?.request || {};
+ summary.interruptRequest = interruptRequest;
+ summary.interruptHasTurnScope =
+ interruptRequest.session_id === sessionId &&
+ interruptRequest.turn_id === longTurnId;
+ await page.screenshot({
+ path: path.join(evidenceDir, `${prefix}-03-after-stop.png`),
+ fullPage: true,
+ });
+ summary.steps.push("after-stop");
+
+ const interrupted = await waitForCondition(
+ "等待 turn 中断完成",
+ async () => {
+ const session = await invoke(
+ options,
+ "agent_runtime_get_session",
+ { sessionId },
+ 20_000,
+ ).catch(() => null);
+ const turn = findTurn(session, longTurnId);
+ return turn && ["aborted", "failed", "completed"].includes(turn.status)
+ ? { turn, session }
+ : null;
+ },
+ 120_000,
+ 1_000,
+ );
+ let latestSession = interrupted.session;
+ summary.interruptedTurn = interrupted.turn;
+ summary.interruptedTurnStatus = interrupted.turn?.status || null;
+ summary.queueCountAfterInterrupt = queuedTurnCount(latestSession);
+
+ await page.waitForFunction(
+ () => {
+ const textareas = Array.from(
+ document.querySelectorAll('textarea[name="agent-chat-message"]'),
+ );
+ const textarea = textareas.at(-1);
+ return Boolean(textarea && !textarea.disabled);
+ },
+ null,
+ { timeout: 60_000 },
+ ).catch(() => undefined);
+
+ logStage("submit-recovery-turn");
+ const followSubmitStart = invokes.length;
+ await textarea.fill(RECOVERY_PROMPT);
+ await page.getByRole("button", { name: "发送" }).last().click({
+ timeout: 30_000,
+ });
+ const followSubmit = await waitForCondition(
+ "等待恢复 turn submit",
+ () =>
+ invokes
+ .slice(followSubmitStart)
+ .find(
+ (item) =>
+ item.cmd === "agent_runtime_submit_turn" &&
+ String(item.args?.request?.message || "").includes("停止后恢复测试"),
+ ) || null,
+ 30_000,
+ 250,
+ );
+ const followRequest = followSubmit.args?.request || {};
+ const followTurnId = String(followRequest.turn_id || "");
+ assert(followTurnId, "恢复 turn 缺少 turn_id");
+ summary.followTurnId = followTurnId;
+ summary.followSubmitTurnConfig = followRequest.turn_config || null;
+
+ const followCompleted = await waitForCondition(
+ "等待恢复 turn 完成",
+ async () => {
+ const session = await invoke(
+ options,
+ "agent_runtime_get_session",
+ { sessionId },
+ 20_000,
+ ).catch(() => null);
+ const turn = findTurn(session, followTurnId);
+ return turn && ["completed", "failed", "aborted"].includes(turn.status)
+ ? { turn, session }
+ : null;
+ },
+ 120_000,
+ 1_000,
+ );
+ latestSession = followCompleted.session || latestSession;
+ summary.followTurn = followCompleted.turn;
+ summary.followTurnStatus = followCompleted.turn?.status || null;
+ let recoverySnapshot = null;
+ try {
+ recoverySnapshot = await waitForCondition(
+ "等待 GUI 出现恢复结果",
+ async () => {
+ const snapshot = await readPageSnapshot(page);
+ return snapshot?.recoveryVisible ? snapshot : null;
+ },
+ 60_000,
+ 500,
+ );
+ summary.recoveryVisibleSource = "live-stream";
+ } catch (recoveryWaitError) {
+ const recoveryWaitMessage =
+ recoveryWaitError instanceof Error
+ ? recoveryWaitError.message
+ : String(recoveryWaitError);
+ summary.recoveryVisibleInitialWait = {
+ passed: false,
+ error: recoveryWaitMessage,
+ };
+ latestSession =
+ (await invoke(options, "agent_runtime_get_session", { sessionId }, 20_000).catch(
+ () => null,
+ )) || latestSession;
+ const persistedAssistantText = [
+ allAssistantText(latestSession),
+ allAgentItemText(latestSession),
+ ]
+ .filter(Boolean)
+ .join("\n");
+ summary.recoveryPersistedBeforeRefresh = persistedAssistantText.includes(
+ RECOVERY_EXPECTED_TEXT,
+ );
+ if (!summary.recoveryPersistedBeforeRefresh) {
+ throw recoveryWaitError;
+ }
+
+ const detachedSnapshot = await readPageSnapshot(page);
+ summary.recoveryDetachedSnapshot = detachedSnapshot;
+ if (isLikelyDetachedBlankTaskSnapshot(detachedSnapshot)) {
+ logStage("restore-session-after-recovery-persisted");
+ summary.recoveryVisibleRestoreDispatch = await dispatchTaskCenterOpenTask(
+ page,
+ sessionId,
+ workspaceId,
+ );
+ recoverySnapshot = await waitForCondition(
+ "等待重新打开目标会话后 GUI 出现恢复结果",
+ async () => {
+ const snapshot = await readPageSnapshot(page);
+ return snapshot?.recoveryVisible ? snapshot : null;
+ },
+ 45_000,
+ 500,
+ );
+ summary.recoveryVisibleSource =
+ "post-session-restore-runtime-persistence";
+ } else {
+ logStage("refresh-after-recovery-persisted");
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => undefined);
+ recoverySnapshot = await waitForCondition(
+ "等待刷新后 GUI 出现恢复结果",
+ async () => {
+ const snapshot = await readPageSnapshot(page);
+ return snapshot?.recoveryVisible ? snapshot : null;
+ },
+ 60_000,
+ 500,
+ );
+ summary.recoveryVisibleSource = "post-refresh-runtime-persistence";
+ }
+ }
+ summary.recoverySnapshot = recoverySnapshot;
+ summary.steps.push("recovery-final");
+ await page.screenshot({
+ path: path.join(evidenceDir, `${prefix}-04-recovery-final.png`),
+ fullPage: true,
+ });
+
+ logStage("collect-runtime-evidence");
+ const threadRead = await invoke(
+ options,
+ "agent_runtime_get_thread_read",
+ { sessionId },
+ 20_000,
+ ).catch((error) => ({
+ __error: error instanceof Error ? error.message : String(error),
+ }));
+ const assistantText = [
+ allAssistantText(latestSession),
+ allAgentItemText(latestSession),
+ ]
+ .filter(Boolean)
+ .join("\n");
+ const {
+ consoleErrors,
+ consoleWarnings,
+ mockFallbackLines,
+ networkErrorTop,
+ } = consoleNetworkSummary(consoleMessages, failedRequests);
+ const activeTurnId = threadRead?.active_turn_id || threadRead?.activeTurnId || null;
+ const runtimeMockLines = mockFallbackLines.filter((line) =>
+ /agent_runtime_(submit_turn|interrupt_turn|get_session|get_thread_read)/.test(line),
+ );
+ const peripheralMockLines = mockFallbackLines.filter(
+ (line) => !runtimeMockLines.includes(line),
+ );
+
+ summary.threadRead = threadRead?.__error ? { error: threadRead.__error } : threadRead;
+ summary.threadReadStatus =
+ summary.threadRead?.diagnostics?.latest_turn_status ||
+ summary.threadRead?.runtime_summary?.latestTurnStatus ||
+ null;
+ const latestRuntimeRouting =
+ latestSession?.execution_runtime?.routing_decision || {};
+ const followProviderPreferenceHonored =
+ followRequest.turn_config?.provider_preference === preferredProvider ||
+ latestRuntimeRouting?.selectedProvider === preferredProvider ||
+ latestRuntimeRouting?.requestedProvider === preferredProvider;
+ const followModelPreferenceHonored =
+ followRequest.turn_config?.model_preference === preferredModel ||
+ latestRuntimeRouting?.selectedModel === preferredModel ||
+ latestRuntimeRouting?.requestedModel === preferredModel;
+ summary.followRoutingEvidence = {
+ selectedProvider: latestRuntimeRouting?.selectedProvider || null,
+ selectedModel: latestRuntimeRouting?.selectedModel || null,
+ requestedProvider: latestRuntimeRouting?.requestedProvider || null,
+ requestedModel: latestRuntimeRouting?.requestedModel || null,
+ decisionSource: latestRuntimeRouting?.decisionSource || null,
+ note:
+ followRequest.turn_config?.provider_preference ||
+ followRequest.turn_config?.model_preference
+ ? "恢复 turn 显式提交 provider/model。"
+ : "恢复 turn 复用 session_default;以 runtime routing_decision 校验 selected/requested provider/model 未漂移。",
+ };
+ summary.queueCountFinal = queuedTurnCount(latestSession);
+ summary.activeTurnIdFinal = activeTurnId;
+ summary.assistantContainsRecovery = assistantText.includes(
+ RECOVERY_EXPECTED_TEXT,
+ );
+ summary.runtimeStreamLineCount = (
+ assistantText.match(/中断测试第\s*\d+\s*行/g) || []
+ ).length;
+ summary.interruptedAssistantContainsFinalLine =
+ assistantText.includes("中断测试第 160 行");
+ summary.consoleErrorCount = consoleErrors.length;
+ summary.consoleWarningCount = consoleWarnings.length;
+ summary.networkErrorCount = failedRequests.length;
+ summary.networkErrorTop = networkErrorTop;
+ summary.devBridgeCommands = [...new Set(invokes.map((item) => item.cmd))];
+ summary.mockFallbackLines = mockFallbackLines;
+ summary.assertions = {
+ workspaceReady: Boolean(readySnapshot?.ready),
+ devBridgeHealthy: health?.status === "ok" || Boolean(health),
+ streamFirstDeltaSeen: Boolean(firstDelta?.streamTextLength > 0),
+ streamGrowthObserved:
+ Number(recoverySnapshot?.streamTextLength || 0) >=
+ Number(firstDelta?.streamTextLength || 0) ||
+ summary.runtimeStreamLineCount >= Number(firstDelta?.streamLineCount || 1),
+ stopButtonVisible: Boolean(firstDelta?.stopVisible),
+ interruptCommandSeen: summary.devBridgeCommands.includes(
+ "agent_runtime_interrupt_turn",
+ ),
+ interruptScopedToLongTurn: Boolean(summary.interruptHasTurnScope),
+ interruptedTurnAborted: summary.interruptedTurnStatus === "aborted",
+ recoveryTurnCompleted: summary.followTurnStatus === "completed",
+ recoveryPersistedInRuntime: summary.assistantContainsRecovery,
+ recoveryVisibleInGui: Boolean(recoverySnapshot?.recoveryVisible),
+ longProviderPreferenceHonored:
+ longRequest.turn_config?.provider_preference === preferredProvider,
+ longModelPreferenceHonored:
+ longRequest.turn_config?.model_preference === preferredModel,
+ followProviderPreferenceHonored,
+ followModelPreferenceHonored,
+ fastResponseRoutingDisabled:
+ !longRequest.turn_config?.metadata?.harness?.fast_response_routing &&
+ !followRequest.turn_config?.metadata?.harness?.fast_response_routing,
+ noRuntimeMockFallbackSeen: runtimeMockLines.length === 0,
+ };
+ summary.mockFallbackClassification = {
+ runtimeMockLines,
+ peripheralMockLines,
+ note:
+ runtimeMockLines.length === 0
+ ? "未观察到聊天 runtime submit/interrupt/get_session/get_thread_read 的 mock fallback;如有 [Mock] 日志,仅属周边 web-mode 能力。"
+ : "观察到聊天 runtime 关键命令 mock fallback,需视为真实路径失败。",
+ };
+ summary.consoleNetwork = {
+ consoleErrorCount: consoleErrors.length,
+ consoleWarningCount: consoleWarnings.length,
+ networkErrorCount: failedRequests.length,
+ networkErrorTop: summary.networkErrorTop,
+ note:
+ failedRequests.length === 0
+ ? "未观察到 requestfailed。"
+ : "requestfailed 需结合 URL 判断是否为页面 reload/close 导致的 abort,不能直接等同产品失败。",
+ };
+ summary.evidenceFiles = {
+ screenshots: [
+ `${prefix}-01-ready.png`,
+ `${prefix}-02-running-stop-visible.png`,
+ `${prefix}-03-after-stop.png`,
+ `${prefix}-04-recovery-final.png`,
+ ],
+ console: `${prefix}-console.txt`,
+ network: `${prefix}-network-invoke.json`,
+ runtimeSession: `${prefix}-runtime-session.json`,
+ threadRead: `${prefix}-thread-read.json`,
+ summary: `${prefix}-summary.json`,
+ };
+
+ summary.verdict =
+ summary.assertions.workspaceReady &&
+ summary.assertions.devBridgeHealthy &&
+ summary.assertions.streamFirstDeltaSeen &&
+ summary.assertions.streamGrowthObserved &&
+ summary.assertions.stopButtonVisible &&
+ summary.assertions.interruptCommandSeen &&
+ summary.assertions.interruptScopedToLongTurn &&
+ summary.assertions.interruptedTurnAborted &&
+ summary.assertions.recoveryTurnCompleted &&
+ summary.assertions.recoveryPersistedInRuntime &&
+ summary.assertions.recoveryVisibleInGui &&
+ summary.assertions.longProviderPreferenceHonored &&
+ summary.assertions.longModelPreferenceHonored &&
+ summary.assertions.followProviderPreferenceHonored &&
+ summary.assertions.followModelPreferenceHonored &&
+ summary.assertions.fastResponseRoutingDisabled &&
+ summary.assertions.noRuntimeMockFallbackSeen &&
+ summary.queueCountFinal === 0 &&
+ summary.activeTurnIdFinal === null &&
+ summary.consoleErrorCount === 0
+ ? "pass"
+ : "fail";
+
+ writeJsonFile(path.join(evidenceDir, `${prefix}-runtime-session.json`), latestSession);
+ writeJsonFile(path.join(evidenceDir, `${prefix}-thread-read.json`), threadRead);
+ writeConsoleNetworkEvidence(
+ evidenceDir,
+ prefix,
+ consoleMessages,
+ invokes,
+ failedRequests,
+ );
+ writeJsonFile(path.join(evidenceDir, `${prefix}-summary.json`), summary);
+ } catch (error) {
+ summary.error = error instanceof Error ? error.message : String(error);
+ if (page) {
+ summary.failureSnapshot = await readPageSnapshot(page);
+ await page
+ .screenshot({
+ path: path.join(evidenceDir, `${prefix}-99-failure.png`),
+ fullPage: true,
+ })
+ .catch(() => undefined);
+ }
+ const failureEvidence = {
+ screenshots: [`${prefix}-99-failure.png`],
+ console: `${prefix}-console.txt`,
+ network: `${prefix}-network-invoke.json`,
+ runtimeSession: `${prefix}-runtime-session.json`,
+ threadRead: `${prefix}-thread-read.json`,
+ summary: `${prefix}-summary.json`,
+ };
+ summary.evidenceFiles = summary.evidenceFiles || failureEvidence;
+ const failureConsoleNetwork = writeConsoleNetworkEvidence(
+ evidenceDir,
+ prefix,
+ consoleMessages,
+ invokes,
+ failedRequests,
+ );
+ summary.consoleErrorCount = failureConsoleNetwork.consoleErrors.length;
+ summary.consoleWarningCount = failureConsoleNetwork.consoleWarnings.length;
+ summary.networkErrorCount = failedRequests.length;
+ summary.networkErrorTop = failureConsoleNetwork.networkErrorTop;
+ summary.devBridgeCommands = [...new Set(invokes.map((item) => item.cmd))];
+
+ if (submittedSessionId) {
+ const failureSession = await invoke(
+ options,
+ "agent_runtime_get_session",
+ { sessionId: submittedSessionId },
+ 20_000,
+ ).catch((sessionError) => ({
+ __error:
+ sessionError instanceof Error ? sessionError.message : String(sessionError),
+ }));
+ const failureThreadRead = await invoke(
+ options,
+ "agent_runtime_get_thread_read",
+ { sessionId: submittedSessionId },
+ 20_000,
+ ).catch((threadError) => ({
+ __error: threadError instanceof Error ? threadError.message : String(threadError),
+ }));
+ writeJsonFile(
+ path.join(evidenceDir, `${prefix}-runtime-session.json`),
+ failureSession,
+ );
+ writeJsonFile(
+ path.join(evidenceDir, `${prefix}-thread-read.json`),
+ failureThreadRead,
+ );
+ summary.failureRuntime = {
+ sessionId: submittedSessionId,
+ sessionError: failureSession?.__error || null,
+ threadReadError: failureThreadRead?.__error || null,
+ turns: Array.isArray(failureSession?.turns)
+ ? failureSession.turns.map((turn) => ({
+ id: turn?.id || null,
+ status: turn?.status || null,
+ error: turn?.error || turn?.error_message || null,
+ }))
+ : [],
+ latestTurnStatus:
+ failureThreadRead?.diagnostics?.latest_turn_status ||
+ failureThreadRead?.runtime_summary?.latestTurnStatus ||
+ failureThreadRead?.status ||
+ null,
+ };
+ }
+ writeJsonFile(path.join(evidenceDir, `${prefix}-summary.json`), summary);
+ throw error;
+ } finally {
+ try {
+ if (page) {
+ await page.evaluate(buildRestoreLocalStorageScript(storageMarker)).catch(
+ () => undefined,
+ );
+ }
+ await restoreOriginalProviderConfig(
+ options,
+ originalProviderConfig,
+ submittedSessionId,
+ ).catch((error) => {
+ console.warn(
+ `[smoke:claw-chat-ready-streaming] 恢复原 provider 配置失败: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ });
+ } finally {
+ await context.close().catch(() => undefined);
+ fs.rmSync(userDataDir, { recursive: true, force: true });
+ }
+ }
+
+ console.log(JSON.stringify(summary, null, 2));
+ if (summary.verdict !== "pass") {
+ process.exitCode = 1;
+ }
+}
+
+main().catch((error) => {
+ const detail = error instanceof Error ? error.message : String(error);
+ console.error(`[smoke:claw-chat-ready-streaming] ${detail}`);
+ process.exit(1);
+});
diff --git a/scripts/detect-missing-translations.test.ts b/scripts/detect-missing-translations.test.ts
new file mode 100644
index 000000000..74adf5163
--- /dev/null
+++ b/scripts/detect-missing-translations.test.ts
@@ -0,0 +1,109 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ analyzeTranslations,
+ applyTranslationFixes,
+ formatTranslationReport,
+ hasTranslationIssues,
+} from "./detect-missing-translations";
+
+const tempDirs: string[] = [];
+
+function createTempResourcesDir() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lime-i18n-check-"));
+ tempDirs.push(dir);
+ return dir;
+}
+
+function writeResource(
+ resourcesDir: string,
+ locale: string,
+ namespace: string,
+ resource: Record,
+) {
+ const filePath = path.join(resourcesDir, locale, `${namespace}.json`);
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, `${JSON.stringify(resource, null, 2)}\n`);
+}
+
+afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ fs.rmSync(dir, { force: true, recursive: true });
+ }
+});
+
+describe("detect-missing-translations", () => {
+ it("应在所有 locale 与 source key 一致时通过", () => {
+ const resourcesDir = createTempResourcesDir();
+ writeResource(resourcesDir, "zh-CN", "common", { "common.save": "保存" });
+ writeResource(resourcesDir, "en-US", "common", { "common.save": "Save" });
+
+ const result = analyzeTranslations({ resourcesDir });
+
+ expect(hasTranslationIssues(result)).toBe(false);
+ expect(formatTranslationReport(result)).toContain("通过");
+ });
+
+ it("应发现缺失 namespace、缺失 key 与多余 key", () => {
+ const resourcesDir = createTempResourcesDir();
+ writeResource(resourcesDir, "zh-CN", "common", {
+ "common.cancel": "取消",
+ "common.save": "保存",
+ });
+ writeResource(resourcesDir, "zh-CN", "settings", {
+ "settings.title": "设置",
+ });
+ writeResource(resourcesDir, "en-US", "common", {
+ "common.extra": "Extra",
+ "common.save": "Save",
+ });
+
+ const result = analyzeTranslations({ resourcesDir });
+
+ expect(hasTranslationIssues(result)).toBe(true);
+ expect(result.issues).toEqual([
+ {
+ locale: "en-US",
+ missingNamespaces: ["settings"],
+ extraNamespaces: [],
+ namespaces: [
+ {
+ namespace: "common",
+ missingKeys: ["common.cancel"],
+ extraKeys: ["common.extra"],
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("--fix 语义应补齐缺失 namespace 与缺失 key,但保留人工已有翻译", () => {
+ const resourcesDir = createTempResourcesDir();
+ writeResource(resourcesDir, "zh-CN", "common", {
+ "common.cancel": "取消",
+ "common.save": "保存",
+ });
+ writeResource(resourcesDir, "zh-CN", "settings", {
+ "settings.title": "设置",
+ });
+ writeResource(resourcesDir, "en-US", "common", {
+ "common.save": "Save",
+ });
+
+ applyTranslationFixes({ resourcesDir });
+ const result = analyzeTranslations({ resourcesDir });
+ const common = JSON.parse(
+ fs.readFileSync(path.join(resourcesDir, "en-US", "common.json"), "utf8"),
+ ) as Record;
+
+ expect(hasTranslationIssues(result)).toBe(false);
+ expect(common["common.save"]).toBe("Save");
+ expect(common["common.cancel"]).toBe("取消");
+ expect(
+ fs.existsSync(path.join(resourcesDir, "en-US", "settings.json")),
+ ).toBe(true);
+ });
+});
diff --git a/scripts/detect-missing-translations.ts b/scripts/detect-missing-translations.ts
new file mode 100644
index 000000000..4b8a2fdd0
--- /dev/null
+++ b/scripts/detect-missing-translations.ts
@@ -0,0 +1,356 @@
+#!/usr/bin/env tsx
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+export interface TranslationCheckOptions {
+ resourcesDir: string;
+ sourceLocale?: string;
+}
+
+export interface NamespaceIssue {
+ namespace: string;
+ missingKeys: string[];
+ extraKeys: string[];
+}
+
+export interface LocaleIssue {
+ locale: string;
+ missingNamespaces: string[];
+ extraNamespaces: string[];
+ namespaces: NamespaceIssue[];
+}
+
+export interface TranslationCheckResult {
+ resourcesDir: string;
+ sourceLocale: string;
+ locales: string[];
+ namespaces: string[];
+ sourceKeyCount: number;
+ issues: LocaleIssue[];
+}
+
+interface CliOptions extends TranslationCheckOptions {
+ fix: boolean;
+ verbose: boolean;
+}
+
+const DEFAULT_SOURCE_LOCALE = "zh-CN";
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const REPO_ROOT = path.resolve(__dirname, "..");
+const DEFAULT_RESOURCES_DIR = path.join(REPO_ROOT, "src", "i18n", "resources");
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function readJsonObject(filePath: string): Record {
+ const raw = fs.readFileSync(filePath, "utf8");
+ const parsed = JSON.parse(raw) as unknown;
+ if (!isRecord(parsed)) {
+ throw new Error(`Translation resource must be a JSON object: ${filePath}`);
+ }
+ return parsed;
+}
+
+function listJsonNamespaces(localeDir: string): string[] {
+ if (!fs.existsSync(localeDir)) {
+ return [];
+ }
+
+ return fs
+ .readdirSync(localeDir, { withFileTypes: true })
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
+ .map((entry) => path.basename(entry.name, ".json"))
+ .sort((left, right) => left.localeCompare(right));
+}
+
+function listLocaleDirs(resourcesDir: string): string[] {
+ if (!fs.existsSync(resourcesDir)) {
+ throw new Error(`Translation resources directory does not exist: ${resourcesDir}`);
+ }
+
+ return fs
+ .readdirSync(resourcesDir, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort((left, right) => left.localeCompare(right));
+}
+
+export function flattenResource(
+ resource: Record,
+ prefix = "",
+): Record {
+ const flattened: Record = {};
+
+ for (const [key, value] of Object.entries(resource)) {
+ const nextKey = prefix ? `${prefix}.${key}` : key;
+ if (isRecord(value)) {
+ Object.assign(flattened, flattenResource(value, nextKey));
+ } else {
+ flattened[nextKey] = value;
+ }
+ }
+
+ return flattened;
+}
+
+function sortedDifference(left: Iterable, right: Set): string[] {
+ return [...left]
+ .filter((item) => !right.has(item))
+ .sort((a, b) => a.localeCompare(b));
+}
+
+function readNamespaceKeys(localeDir: string, namespace: string): Set {
+ const filePath = path.join(localeDir, `${namespace}.json`);
+ if (!fs.existsSync(filePath)) {
+ return new Set();
+ }
+
+ return new Set(Object.keys(flattenResource(readJsonObject(filePath))));
+}
+
+function countSourceKeys(sourceLocaleDir: string, namespaces: string[]): number {
+ return namespaces.reduce(
+ (total, namespace) => total + readNamespaceKeys(sourceLocaleDir, namespace).size,
+ 0,
+ );
+}
+
+export function hasTranslationIssues(result: TranslationCheckResult): boolean {
+ return result.issues.some(
+ (issue) =>
+ issue.missingNamespaces.length > 0 ||
+ issue.extraNamespaces.length > 0 ||
+ issue.namespaces.some(
+ (namespaceIssue) =>
+ namespaceIssue.missingKeys.length > 0 || namespaceIssue.extraKeys.length > 0,
+ ),
+ );
+}
+
+export function analyzeTranslations(
+ options: TranslationCheckOptions,
+): TranslationCheckResult {
+ const resourcesDir = path.resolve(options.resourcesDir);
+ const sourceLocale = options.sourceLocale || DEFAULT_SOURCE_LOCALE;
+ const locales = listLocaleDirs(resourcesDir);
+ const sourceLocaleDir = path.join(resourcesDir, sourceLocale);
+
+ if (!locales.includes(sourceLocale)) {
+ throw new Error(`Source locale is missing from resources: ${sourceLocale}`);
+ }
+
+ const namespaces = listJsonNamespaces(sourceLocaleDir);
+ const sourceNamespaceSet = new Set(namespaces);
+ const issues: LocaleIssue[] = [];
+
+ for (const locale of locales) {
+ if (locale === sourceLocale) {
+ continue;
+ }
+
+ const localeDir = path.join(resourcesDir, locale);
+ const localeNamespaces = listJsonNamespaces(localeDir);
+ const localeNamespaceSet = new Set(localeNamespaces);
+ const missingNamespaces = sortedDifference(namespaces, localeNamespaceSet);
+ const extraNamespaces = sortedDifference(localeNamespaces, sourceNamespaceSet);
+ const namespaceIssues: NamespaceIssue[] = [];
+
+ for (const namespace of namespaces) {
+ if (!localeNamespaceSet.has(namespace)) {
+ continue;
+ }
+
+ const sourceKeys = readNamespaceKeys(sourceLocaleDir, namespace);
+ const targetKeys = readNamespaceKeys(localeDir, namespace);
+ const missingKeys = sortedDifference(sourceKeys, targetKeys);
+ const extraKeys = sortedDifference(targetKeys, sourceKeys);
+
+ if (missingKeys.length > 0 || extraKeys.length > 0) {
+ namespaceIssues.push({ namespace, missingKeys, extraKeys });
+ }
+ }
+
+ if (
+ missingNamespaces.length > 0 ||
+ extraNamespaces.length > 0 ||
+ namespaceIssues.length > 0
+ ) {
+ issues.push({
+ locale,
+ missingNamespaces,
+ extraNamespaces,
+ namespaces: namespaceIssues,
+ });
+ }
+ }
+
+ return {
+ resourcesDir,
+ sourceLocale,
+ locales,
+ namespaces,
+ sourceKeyCount: countSourceKeys(sourceLocaleDir, namespaces),
+ issues,
+ };
+}
+
+function sortObjectByKey(resource: Record): Record {
+ return Object.fromEntries(
+ Object.entries(resource).sort(([left], [right]) => left.localeCompare(right)),
+ );
+}
+
+function writeJsonObject(filePath: string, resource: Record): void {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, `${JSON.stringify(sortObjectByKey(resource), null, 2)}${os.EOL}`);
+}
+
+export function applyTranslationFixes(options: TranslationCheckOptions): void {
+ const resourcesDir = path.resolve(options.resourcesDir);
+ const sourceLocale = options.sourceLocale || DEFAULT_SOURCE_LOCALE;
+ const sourceLocaleDir = path.join(resourcesDir, sourceLocale);
+ const sourceNamespaces = listJsonNamespaces(sourceLocaleDir);
+ const locales = listLocaleDirs(resourcesDir).filter((locale) => locale !== sourceLocale);
+
+ for (const locale of locales) {
+ const localeDir = path.join(resourcesDir, locale);
+
+ for (const namespace of sourceNamespaces) {
+ const sourcePath = path.join(sourceLocaleDir, `${namespace}.json`);
+ const targetPath = path.join(localeDir, `${namespace}.json`);
+ const sourceResource = flattenResource(readJsonObject(sourcePath));
+ const targetResource = fs.existsSync(targetPath)
+ ? flattenResource(readJsonObject(targetPath))
+ : {};
+
+ let changed = false;
+ for (const [key, value] of Object.entries(sourceResource)) {
+ if (!(key in targetResource)) {
+ targetResource[key] = value;
+ changed = true;
+ }
+ }
+
+ if (changed || !fs.existsSync(targetPath)) {
+ writeJsonObject(targetPath, targetResource);
+ }
+ }
+ }
+}
+
+function formatList(items: string[]): string {
+ return items.length === 0 ? "-" : items.join(", ");
+}
+
+export function formatTranslationReport(
+ result: TranslationCheckResult,
+ options: { verbose?: boolean } = {},
+): string {
+ const lines: string[] = [];
+ const issueCount = result.issues.length;
+ lines.push(
+ `[i18n:check] resources=${path.relative(REPO_ROOT, result.resourcesDir) || result.resourcesDir} source=${result.sourceLocale} locales=${result.locales.length} namespaces=${result.namespaces.length} sourceKeys=${result.sourceKeyCount}`,
+ );
+
+ if (!hasTranslationIssues(result)) {
+ lines.push("[i18n:check] 通过:所有 locale 与 source namespace/key 保持一致。");
+ if (options.verbose) {
+ lines.push(`[i18n:check] locale 列表: ${result.locales.join(", ")}`);
+ lines.push(`[i18n:check] namespace 列表: ${result.namespaces.join(", ")}`);
+ }
+ return lines.join(os.EOL);
+ }
+
+ lines.push(`[i18n:check] 发现 ${issueCount} 个 locale 存在翻译结构差异。`);
+ for (const localeIssue of result.issues) {
+ lines.push(`[i18n:check] locale=${localeIssue.locale}`);
+ if (localeIssue.missingNamespaces.length > 0) {
+ lines.push(
+ ` missing namespaces: ${formatList(localeIssue.missingNamespaces)}`,
+ );
+ }
+ if (localeIssue.extraNamespaces.length > 0) {
+ lines.push(` extra namespaces: ${formatList(localeIssue.extraNamespaces)}`);
+ }
+ for (const namespaceIssue of localeIssue.namespaces) {
+ lines.push(` namespace=${namespaceIssue.namespace}`);
+ if (namespaceIssue.missingKeys.length > 0) {
+ lines.push(` missing keys: ${formatList(namespaceIssue.missingKeys)}`);
+ }
+ if (namespaceIssue.extraKeys.length > 0) {
+ lines.push(` extra keys: ${formatList(namespaceIssue.extraKeys)}`);
+ }
+ }
+ }
+
+ return lines.join(os.EOL);
+}
+
+function parseCliArgs(argv: string[]): CliOptions {
+ const options: CliOptions = {
+ fix: false,
+ resourcesDir: DEFAULT_RESOURCES_DIR,
+ sourceLocale: DEFAULT_SOURCE_LOCALE,
+ verbose: false,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ const next = argv[index + 1];
+
+ if (arg === "--fix") {
+ options.fix = true;
+ continue;
+ }
+ if (arg === "--verbose") {
+ options.verbose = true;
+ continue;
+ }
+ if (arg === "--resources-dir" && next) {
+ options.resourcesDir = next;
+ index += 1;
+ continue;
+ }
+ if (arg === "--source-locale" && next) {
+ options.sourceLocale = next;
+ index += 1;
+ continue;
+ }
+ if (arg === "-h" || arg === "--help") {
+ console.log(`Usage: npm run detect-translations -- [--fix] [--verbose] [--resources-dir ] [--source-locale ]`);
+ process.exit(0);
+ }
+
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+
+ return options;
+}
+
+export function runCli(argv = process.argv.slice(2)): number {
+ const options = parseCliArgs(argv);
+
+ if (options.fix) {
+ applyTranslationFixes(options);
+ }
+
+ const result = analyzeTranslations(options);
+ console.log(formatTranslationReport(result, { verbose: options.verbose }));
+ return hasTranslationIssues(result) ? 1 : 0;
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
+ try {
+ process.exitCode = runCli();
+ } catch (error) {
+ console.error(
+ `[i18n:check] 失败:${error instanceof Error ? error.message : String(error)}`,
+ );
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/i18n-patch-metrics-report.mjs b/scripts/i18n-patch-metrics-report.mjs
new file mode 100644
index 000000000..2c53972ea
--- /dev/null
+++ b/scripts/i18n-patch-metrics-report.mjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import {
+ createI18nPatchMetricsReport,
+ renderI18nPatchMetricsTextReport,
+} from "./lib/i18n-patch-metrics-report-core.mjs";
+
+const DEFAULT_INPUT_PATH = ".lime/i18n/patch-metrics.json";
+
+function parseNumberArg(value) {
+ if (value === undefined) {
+ return undefined;
+ }
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
+function parseArgs(argv) {
+ const result = {
+ check: false,
+ format: "text",
+ help: false,
+ inputPath: DEFAULT_INPUT_PATH,
+ maxMatchedSegments: undefined,
+ maxReplacedNodes: undefined,
+ maxRuns: undefined,
+ outputPath: "",
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+
+ if (arg === "--check") {
+ result.check = true;
+ continue;
+ }
+
+ if (arg === "--format" && argv[index + 1]) {
+ result.format = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--input" && argv[index + 1]) {
+ result.inputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--max-matched-segments" && argv[index + 1]) {
+ result.maxMatchedSegments = parseNumberArg(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--max-replaced-nodes" && argv[index + 1]) {
+ result.maxReplacedNodes = parseNumberArg(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--max-runs" && argv[index + 1]) {
+ result.maxRuns = parseNumberArg(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--output" && argv[index + 1]) {
+ result.outputPath = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+
+ return result;
+}
+
+function printHelp() {
+ console.log(`
+Lime i18n Patch Metrics Report
+
+用法:
+ npm run i18n:patch-report
+ npm run i18n:patch-report:json
+ node scripts/i18n-patch-metrics-report.mjs --input .lime/i18n/patch-metrics.json --format json
+ node scripts/i18n-patch-metrics-report.mjs --check --max-matched-segments 0 --max-replaced-nodes 0
+
+输入:
+ 默认读取 ${DEFAULT_INPUT_PATH}。该文件应来自 GUI / Playwright 导出的 window.__I18N_METRICS__ 或 getI18nPatchMetricsReport() JSON。
+
+选项:
+ --input PATH Patch metrics JSON 路径
+ --output PATH 写入报告文件;默认输出到 stdout
+ --format FMT 输出格式:text | json
+ --check 如果门限问题存在,以非 0 退出
+ --max-matched-segments NUM 允许的最大命中文本段数
+ --max-replaced-nodes NUM 允许的最大替换节点数
+ --max-runs NUM 允许的最大 Patch 运行次数
+ -h, --help 显示帮助
+`);
+}
+
+function resolvePath(targetPath) {
+ return path.resolve(process.cwd(), targetPath);
+}
+
+function readJsonFile(filePath) {
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
+}
+
+function writeOutput(outputPath, content) {
+ if (!outputPath) {
+ process.stdout.write(content);
+ return;
+ }
+
+ const resolvedOutputPath = resolvePath(outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(resolvedOutputPath, content, "utf8");
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const resolvedInputPath = resolvePath(options.inputPath);
+ const metrics = readJsonFile(resolvedInputPath);
+ const report = createI18nPatchMetricsReport({
+ metrics,
+ sourcePath: path.relative(process.cwd(), resolvedInputPath),
+ thresholds: {
+ maxMatchedSegments: options.maxMatchedSegments,
+ maxReplacedNodes: options.maxReplacedNodes,
+ maxRuns: options.maxRuns,
+ },
+ });
+ const content =
+ options.format === "json"
+ ? `${JSON.stringify(report, null, 2)}\n`
+ : renderI18nPatchMetricsTextReport(report);
+
+ writeOutput(options.outputPath, content);
+
+ if (options.check && report.thresholdIssues.length > 0) {
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/knowledge-gui-smoke.mjs b/scripts/knowledge-gui-smoke.mjs
index 6cb6b3a7b..9100f8e03 100644
--- a/scripts/knowledge-gui-smoke.mjs
+++ b/scripts/knowledge-gui-smoke.mjs
@@ -12,6 +12,7 @@ const DEFAULTS = {
invokeUrl: "http://127.0.0.1:3030/invoke",
timeoutMs: 180_000,
intervalMs: 1_000,
+ i18nPatchMetricsOutput: "",
};
const INVOKE_TIMEOUT_CEILING_MS = 180_000;
@@ -135,6 +136,8 @@ Lime Knowledge GUI Smoke
--invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
--timeout-ms 总超时,默认 180000
--interval-ms 轮询间隔,默认 1000
+ --i18n-patch-metrics-output
+ 导出 window.__I18N_METRICS__ JSON,供 i18n:patch-report 消费
-h, --help 显示帮助
`);
}
@@ -175,6 +178,12 @@ function parseArgs(argv) {
continue;
}
+ if (arg === "--i18n-patch-metrics-output" && argv[index + 1]) {
+ options.i18nPatchMetricsOutput = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
@@ -202,6 +211,39 @@ function logStage(label) {
console.log(`[smoke:knowledge-gui] stage=${label}`);
}
+async function exportI18nPatchMetrics(page, outputPath) {
+ if (!outputPath) {
+ return;
+ }
+
+ try {
+ const metrics = await page.evaluate(() => {
+ const globalMetrics = window.__I18N_METRICS__;
+ if (!globalMetrics) {
+ return null;
+ }
+
+ return JSON.parse(JSON.stringify(globalMetrics));
+ });
+ const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
+ fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
+ fs.writeFileSync(
+ resolvedOutputPath,
+ JSON.stringify(metrics ?? {}, null, 2),
+ "utf8",
+ );
+ console.log(
+ `[smoke:knowledge-gui] i18n Patch metrics: ${resolvedOutputPath}`,
+ );
+ } catch (error) {
+ console.warn(
+ `[smoke:knowledge-gui] 导出 i18n Patch metrics 失败: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ }
+}
+
function isTransientInvokeError(error) {
return (
error?.name === "TimeoutError" ||
@@ -1096,6 +1138,7 @@ async function runPlaywrightGuiFlow(options) {
);
}
} finally {
+ await exportI18nPatchMetrics(page, options.i18nPatchMetricsOutput);
await context.close().catch(() => undefined);
fs.rmSync(userDataDir, { recursive: true, force: true });
}
diff --git a/scripts/knowledge-product-e2e.mjs b/scripts/knowledge-product-e2e.mjs
index 7f572267d..30d0b17aa 100755
--- a/scripts/knowledge-product-e2e.mjs
+++ b/scripts/knowledge-product-e2e.mjs
@@ -302,8 +302,23 @@ async function openKnowledgeOverview(page, options) {
text.includes("存到哪里?") ||
text.includes("整理新资料")
) {
+ if (text.includes("项目资料状态说明")) {
+ const backButton = page.getByRole("button", {
+ name: "回到项目资料",
+ exact: true,
+ });
+ if (await backButton.isVisible().catch(() => false)) {
+ await backButton.click({ timeout: options.timeoutMs });
+ await waitText(page, options, "状态说明返回项目资料首页", [
+ "让 Lime 记住这个项目",
+ "项目资料清单",
+ ]);
+ return;
+ }
+ }
+
await clickSideNav(page, options, "灵感");
- await waitText(page, options, "灵感页", ["灵感"]);
+ await waitText(page, options, "灵感页", ["收藏过的想法"]);
}
await clickSideNav(page, options, "项目资料");
diff --git a/scripts/lib/agent-qc-completion-audit-core.mjs b/scripts/lib/agent-qc-completion-audit-core.mjs
new file mode 100644
index 000000000..ee935a8f7
--- /dev/null
+++ b/scripts/lib/agent-qc-completion-audit-core.mjs
@@ -0,0 +1,292 @@
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function normalizeIdList(value) {
+ return asArray(value)
+ .map((item) => String(item || "").trim())
+ .filter(Boolean);
+}
+
+function createItem(id, title, passed, evidence, gap = "") {
+ return { id, title, passed: Boolean(passed), evidence: evidence || "", gap: passed ? "" : gap };
+}
+
+function summarizeQcloopStatusSidecar(entry) {
+ const counts = entry.counts || {};
+ return [
+ `${entry.path} verdict=${entry.verdictStatus || "unknown"}`,
+ `success=${counts.success ?? 0}`,
+ `running=${counts.running ?? 0}`,
+ `pending=${counts.pending ?? 0}`,
+ `stale=${counts.stale ?? 0}`,
+ ].join(" ");
+}
+
+function buildAgentQcCompletionAudit(facts) {
+ const requiredQcloopScenarioIds = normalizeIdList(facts.scenarioReport?.p0ScenarioIds);
+ const requiredQcloopScenarioCount =
+ requiredQcloopScenarioIds.length || facts.scenarioReport?.p0ScenarioCount || 1;
+ const realEvidenceScenarioIds = new Set(normalizeIdList(facts.realEvidencePack?.scenarioIds));
+ const missingQcloopScenarioIds = requiredQcloopScenarioIds.filter(
+ (scenarioId) => !realEvidenceScenarioIds.has(scenarioId),
+ );
+ const hasRequiredQcloopCoverage =
+ requiredQcloopScenarioIds.length > 0
+ ? missingQcloopScenarioIds.length === 0
+ : facts.realEvidencePack?.scenarioCount >= requiredQcloopScenarioCount;
+ const sidecarEvidenceSummary = asArray(facts.realEvidenceSidecars)
+ .map((entry) => `${entry.path} status=${entry.status || "unknown"} scenarios=${entry.scenarioCount || 0}`)
+ .join("; ");
+ const qcloopStatusSidecarSummary = asArray(facts.qcloopStatusSidecars)
+ .map((entry) => {
+ const counts = entry.counts
+ ? ` success=${entry.counts.success ?? 0} running=${entry.counts.running ?? 0} pending=${entry.counts.pending ?? 0} stale=${entry.counts.stale ?? 0}`
+ : "";
+ return `${entry.path} verdict=${entry.verdictStatus || "unknown"}${counts}`;
+ })
+ .join("; ");
+ const staleQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter(
+ (entry) => entry.verdictStatus === "stale" || Number(entry.counts?.stale || 0) > 0,
+ );
+ const activeQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter(
+ (entry) =>
+ entry.verdictStatus === "running" ||
+ entry.verdictStatus === "stale" ||
+ Number(entry.counts?.running || 0) > 0 ||
+ Number(entry.counts?.pending || 0) > 0,
+ );
+ const qcloopEvidenceText = facts.realEvidencePack?.exists
+ ? `.lime/qc/agent-qc-evidence.json status=${facts.realEvidencePack.status || "unknown"} scenarios=${facts.realEvidencePack.scenarioCount || 0}/${requiredQcloopScenarioCount}${missingQcloopScenarioIds.length > 0 ? ` missing=${missingQcloopScenarioIds.join(",")}` : ""}`
+ : sidecarEvidenceSummary
+ ? `未发现 .lime/qc/agent-qc-evidence.json;sidecars: ${sidecarEvidenceSummary}`
+ : "未发现 .lime/qc/agent-qc-evidence.json";
+ const qcloopEvidenceWithStatusText = qcloopStatusSidecarSummary
+ ? `${qcloopEvidenceText}; qcloopStatus: ${qcloopStatusSidecarSummary}`
+ : qcloopEvidenceText;
+ const qcloopGapFragments = [];
+ if (facts.realEvidencePack?.exists && facts.realEvidencePack.status !== "pass") {
+ qcloopGapFragments.push(
+ `官方 Evidence Pack 当前 status=${facts.realEvidencePack.status || "unknown"},不能发布`,
+ );
+ }
+ if (!facts.realEvidencePack?.exists && sidecarEvidenceSummary) {
+ qcloopGapFragments.push("已有 sidecar Evidence Pack,但尚未生成 pass 的官方 .lime/qc/agent-qc-evidence.json");
+ }
+ if (missingQcloopScenarioIds.length > 0) {
+ qcloopGapFragments.push(`尚未覆盖 P0 场景:${missingQcloopScenarioIds.join(",")}`);
+ }
+ if (activeQcloopStatusSidecars.length > 0) {
+ qcloopGapFragments.push(
+ `仍有 qcloop status sidecar 未终态:${activeQcloopStatusSidecars
+ .map(summarizeQcloopStatusSidecar)
+ .join("; ")}`,
+ );
+ }
+ if (staleQcloopStatusSidecars.length > 0) {
+ qcloopGapFragments.push(
+ `其中 stale sidecar:${staleQcloopStatusSidecars.map((entry) => entry.path).join(", ")}`,
+ );
+ }
+ const qcloopGapText =
+ qcloopGapFragments.length > 0
+ ? `${qcloopGapFragments.join(";")}。`
+ : "尚未运行真实 qcloop 批次并导出 pass Evidence Pack。";
+ const localVerifyEvidenceText = facts.localVerify?.status
+ ? `status=${facts.localVerify.status}${facts.localVerify.failedStage ? ` failedStage=${facts.localVerify.failedStage}` : ""}`
+ : "未发现 .lime/qc/verify-local-current.json";
+ const guiSmokeEvidenceText = facts.guiSmoke?.status
+ ? `; latestGuiSmoke status=${facts.guiSmoke.status}${facts.guiSmoke.failedStage ? ` failedStage=${facts.guiSmoke.failedStage}` : ""}`
+ : "";
+ const localVerifyGapFragments = [];
+ if (facts.localVerify?.status) {
+ localVerifyGapFragments.push(
+ `verify:local 当前 status=${facts.localVerify.status}${facts.localVerify.error ? `;${facts.localVerify.error}` : ""}`,
+ );
+ } else {
+ localVerifyGapFragments.push("缺少 verify:local 当前结果 sidecar,无法证明仓库统一本地校验通过");
+ }
+ if (facts.guiSmoke?.status && facts.guiSmoke.status !== "pass") {
+ localVerifyGapFragments.push(
+ `最近一次 verify:gui-smoke status=${facts.guiSmoke.status}${facts.guiSmoke.error ? `;${facts.guiSmoke.error}` : ""}`,
+ );
+ }
+ const items = [
+ createItem(
+ "docs-tests-standard",
+ "docs/tests 下存在 Agent QC 人读测试文档",
+ facts.files?.agentOpsQc &&
+ facts.files?.p0Scenarios &&
+ facts.files?.limeRolloutPlan &&
+ facts.files?.testsReadme,
+ "docs/tests/agent-ops-qc.md, docs/tests/agent-qc-p0-scenarios.md, docs/tests/lime-agent-qc-rollout-plan.md, docs/tests/README.md",
+ "缺少 docs/tests 测试体系文档。",
+ ),
+ createItem(
+ "scenario-manifest",
+ "Agent QC scenario manifest 有效",
+ facts.scenarioReport?.valid === true && facts.scenarioReport?.scenarioCount > 0,
+ `scenarioCount=${facts.scenarioReport?.scenarioCount ?? 0}`,
+ "scenario manifest 未通过校验或没有场景。",
+ ),
+ createItem(
+ "gui-flow-manifest",
+ "GUI / Playwright MCP flow manifest 有效",
+ facts.guiFlowReport?.valid === true && facts.guiFlowReport?.flowCount > 0,
+ `flowCount=${facts.guiFlowReport?.flowCount ?? 0}`,
+ "GUI flow manifest 未通过校验或没有 flow。",
+ ),
+ createItem(
+ "evidence-schema",
+ "Agent QC Evidence Pack schema 存在",
+ facts.files?.evidenceSchema,
+ "docs/test/agent-qc-evidence.schema.json",
+ "缺少 Evidence Pack schema。",
+ ),
+ createItem(
+ "qcloop-payload-generator",
+ "可从 manifest 生成 qcloop job payload",
+ facts.files?.qcloopJobScript && facts.qcloopPayload?.valid === true && facts.qcloopPayload?.itemCount > 0,
+ `itemCount=${facts.qcloopPayload?.itemCount ?? 0}`,
+ "qcloop payload 生成器不可用或没有 item。",
+ ),
+ createItem(
+ "qcloop-verifier-evidence-placeholders",
+ "qcloop verifier prompt 带 worker evidence 占位符",
+ facts.qcloopPayload?.verifierHasWorkerOutput === true &&
+ facts.qcloopPayload?.verifierHasAttemptStatus === true &&
+ facts.qcloopPayload?.verifierHasExitCode === true,
+ `stdout=${Boolean(facts.qcloopPayload?.verifierHasWorkerOutput)} attempt_status=${Boolean(facts.qcloopPayload?.verifierHasAttemptStatus)} exit_code=${Boolean(facts.qcloopPayload?.verifierHasExitCode)}`,
+ "qcloop verifier prompt 缺少 {{stdout}} / {{attempt_status}} / {{exit_code}},verifier 无法审查 worker 证据。",
+ ),
+ createItem(
+ "structured-evidence-contract",
+ "qcloop worker / verifier / exporter 强制结构化 evidence summary",
+ facts.files?.evidenceContractDoc &&
+ facts.qcloopPayload?.workerPromptHasStructuredEvidence === true &&
+ facts.qcloopPayload?.verifierRequiresStructuredEvidence === true &&
+ facts.qcloopPayload?.verifierRequiresStrictJson === true &&
+ facts.structuredEvidence?.exporterParsesSummary === true &&
+ facts.structuredEvidence?.releaseSummaryRejectsWeakRefs === true,
+ `doc=${Boolean(facts.files?.evidenceContractDoc)} worker=${Boolean(facts.qcloopPayload?.workerPromptHasStructuredEvidence)} verifier=${Boolean(facts.qcloopPayload?.verifierRequiresStructuredEvidence)} strictJson=${Boolean(facts.qcloopPayload?.verifierRequiresStrictJson)} exporter=${Boolean(facts.structuredEvidence?.exporterParsesSummary)} release=${Boolean(facts.structuredEvidence?.releaseSummaryRejectsWeakRefs)}`,
+ "结构化 evidence summary 契约未被文档、payload worker prompt、verifier prompt、exporter 与 release summary gate 同时强制,可能再次接受浅层 qcloop success。",
+ ),
+ createItem(
+ "qcloop-evidence-exporter",
+ "可把 qcloop job/items 导出为 Evidence Pack",
+ facts.files?.exportEvidenceScript,
+ "scripts/agent-qc-export-evidence.mjs",
+ "缺少 qcloop Evidence Pack 导出入口。",
+ ),
+ createItem(
+ "qcloop-status-monitor",
+ "可只读监控 qcloop 运行批次和 stale item",
+ facts.files?.qcloopStatusScript && facts.files?.qcloopOperationsDoc,
+ "scripts/agent-qc-qcloop-status.mjs, docs/tests/lime-agent-qc-qcloop-operations.md",
+ "缺少 qcloop 只读状态监控脚本或运维手册。",
+ ),
+ createItem(
+ "gui-owner-check",
+ "可在启动 GUI P0 前阻断并发 qcloop GUI owner",
+ facts.files?.guiOwnerCheckScript && facts.files?.qcloopOperationsDoc,
+ "scripts/agent-qc-gui-owner-check.mjs, docs/tests/lime-agent-qc-qcloop-operations.md",
+ "缺少 GUI owner 并发检查脚本或运维手册。",
+ ),
+ createItem(
+ "stale-owner-intervention-protocol",
+ "stale GUI qcloop owner 有只读取证和 owner 确认协议",
+ facts.files?.staleOwnerInterventionDoc &&
+ facts.files?.qcloopOperationsDoc &&
+ facts.staleOwnerIntervention?.guiOwnerReportHasDecisionPacket === true &&
+ facts.staleOwnerIntervention?.docMentionsDecisionPacket === true &&
+ facts.staleOwnerIntervention?.guiOwnerReportHasWatchHistory === true &&
+ facts.staleOwnerIntervention?.docMentionsWatchHistory === true,
+ `doc=${Boolean(facts.files?.staleOwnerInterventionDoc)} operations=${Boolean(facts.files?.qcloopOperationsDoc)} ownerIntervention=${Boolean(facts.staleOwnerIntervention?.guiOwnerReportHasDecisionPacket)} docDecisionPacket=${Boolean(facts.staleOwnerIntervention?.docMentionsDecisionPacket)} watchHistory=${Boolean(facts.staleOwnerIntervention?.guiOwnerReportHasWatchHistory)} docWatchHistory=${Boolean(facts.staleOwnerIntervention?.docMentionsWatchHistory)}`,
+ "缺少 stale GUI owner 处置协议、机器可读 ownerIntervention 或 watch history 输出;容易在未确认时误杀 worker、启动并发 GUI P0,或丢失长期观察证据。",
+ ),
+ createItem(
+ "qcloop-worker-preflight",
+ "qcloop worker 执行前有 cwd / tmp / DevBridge preflight",
+ facts.files?.qcloopPreflightScript &&
+ facts.qcloopPayload?.workerPromptHasPreflight === true,
+ `script=${Boolean(facts.files?.qcloopPreflightScript)} prompt=${Boolean(facts.qcloopPayload?.workerPromptHasPreflight)}`,
+ "缺少 qcloop worker preflight 脚本,或 payload prompt 没有要求执行前置环境检查。",
+ ),
+ createItem(
+ "release-summary",
+ "可把 Evidence Pack 汇总为 release note 质量证据",
+ facts.files?.releaseSummaryScript,
+ "scripts/agent-qc-release-summary.mjs",
+ "缺少 release summary 入口。",
+ ),
+ createItem(
+ "nightly-artifacts",
+ "nightly 上传 Agent QC 标准资产报告",
+ facts.nightly?.hasAgentQcReport && facts.nightly?.hasGuiFlowReport && facts.nightly?.hasReleasePreview,
+ ".github/workflows/harness-nightly.yml artifacts/agent-qc/*",
+ "nightly 未完整上传 Agent QC report / GUI flow report / release preview。",
+ ),
+ createItem(
+ "real-qcloop-evidence",
+ "存在真实 qcloop Agent QC Evidence Pack",
+ facts.realEvidencePack?.status === "pass" &&
+ hasRequiredQcloopCoverage,
+ qcloopEvidenceWithStatusText,
+ qcloopGapText,
+ ),
+ createItem(
+ "real-gui-evidence",
+ "存在真实 GUI / Playwright MCP evidence",
+ facts.files?.realGuiEvidence,
+ facts.files?.realGuiEvidence ? ".lime/qc/gui-evidence" : "未发现 .lime/qc/gui-evidence",
+ "尚未执行真实 GUI / Playwright MCP flow 并保存证据。",
+ ),
+ createItem(
+ "release-hard-gate",
+ "release workflow 强制 Evidence Pack pass 且覆盖 P0 场景",
+ facts.release?.hasHardGate === true && facts.release?.requiresP0ScenarioCoverage === true,
+ facts.release?.hasHardGate && facts.release?.requiresP0ScenarioCoverage
+ ? ".github/workflows/release.yml contains agent-qc release gate and P0 scenario coverage"
+ : "release.yml 未完整强制 agent-qc release gate / P0 scenario coverage",
+ "release workflow 尚未强制真实 Evidence Pack pass 或 P0 场景覆盖。",
+ ),
+ createItem(
+ "local-verify-gate",
+ "仓库统一本地校验 verify:local 通过",
+ facts.localVerify?.status === "pass",
+ `${localVerifyEvidenceText}${guiSmokeEvidenceText}`,
+ `${localVerifyGapFragments.join(";")}。`,
+ ),
+ ];
+
+ const passedCount = items.filter((item) => item.passed).length;
+ const failed = items.filter((item) => !item.passed);
+ return {
+ status: failed.length === 0 ? "complete" : "incomplete",
+ passedCount,
+ failedCount: failed.length,
+ totalCount: items.length,
+ completionRatio: items.length === 0 ? 0 : passedCount / items.length,
+ items,
+ gaps: failed.map((item) => ({ id: item.id, title: item.title, gap: item.gap })),
+ };
+}
+
+function renderAgentQcCompletionAuditMarkdown(audit) {
+ const lines = audit.items.map((item) => {
+ const marker = item.passed ? "PASS" : "MISS";
+ return `- ${marker} ${item.id}: ${item.title};证据:${item.evidence}${item.gap ? `;缺口:${item.gap}` : ""}`;
+ });
+ return `# Agent QC Completion Audit
+
+- Status: ${audit.status}
+- Passed: ${audit.passedCount}/${audit.totalCount}
+- Completion: ${Math.round(audit.completionRatio * 100)}%
+
+## Checklist
+
+${lines.join("\n")}
+`;
+}
+
+export { buildAgentQcCompletionAudit, renderAgentQcCompletionAuditMarkdown };
diff --git a/scripts/lib/agent-qc-completion-audit-core.test.ts b/scripts/lib/agent-qc-completion-audit-core.test.ts
new file mode 100644
index 000000000..9b9d403b2
--- /dev/null
+++ b/scripts/lib/agent-qc-completion-audit-core.test.ts
@@ -0,0 +1,299 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildAgentQcCompletionAudit,
+ renderAgentQcCompletionAuditMarkdown,
+} from "./agent-qc-completion-audit-core.mjs";
+
+function completeFacts() {
+ return {
+ files: {
+ agentOpsQc: true,
+ p0Scenarios: true,
+ limeRolloutPlan: true,
+ testsReadme: true,
+ evidenceSchema: true,
+ qcloopJobScript: true,
+ guiOwnerCheckScript: true,
+ qcloopStatusScript: true,
+ qcloopPreflightScript: true,
+ qcloopOperationsDoc: true,
+ evidenceContractDoc: true,
+ staleOwnerInterventionDoc: true,
+ exportEvidenceScript: true,
+ releaseSummaryScript: true,
+ realGuiEvidence: true,
+ },
+ realEvidencePack: {
+ exists: true,
+ status: "pass",
+ scenarioCount: 1,
+ scenarioIds: ["command-bridge-contract"],
+ },
+ localVerify: {
+ status: "pass",
+ failedStage: "",
+ error: "",
+ },
+ realEvidenceSidecars: [],
+ qcloopStatusSidecars: [],
+ scenarioReport: {
+ valid: true,
+ scenarioCount: 12,
+ p0ScenarioCount: 1,
+ p0ScenarioIds: ["command-bridge-contract"],
+ },
+ guiFlowReport: { valid: true, flowCount: 4 },
+ qcloopPayload: {
+ valid: true,
+ itemCount: 8,
+ verifierHasWorkerOutput: true,
+ verifierHasAttemptStatus: true,
+ verifierHasExitCode: true,
+ workerPromptHasPreflight: true,
+ workerPromptHasStructuredEvidence: true,
+ verifierRequiresStructuredEvidence: true,
+ verifierRequiresStrictJson: true,
+ },
+ structuredEvidence: {
+ exporterParsesSummary: true,
+ releaseSummaryRejectsWeakRefs: true,
+ },
+ staleOwnerIntervention: {
+ guiOwnerReportHasDecisionPacket: true,
+ docMentionsDecisionPacket: true,
+ guiOwnerReportHasWatchHistory: true,
+ docMentionsWatchHistory: true,
+ },
+ nightly: { hasAgentQcReport: true, hasGuiFlowReport: true, hasReleasePreview: true },
+ release: { hasHardGate: true, requiresP0ScenarioCoverage: true },
+ };
+}
+
+describe("agent-qc-completion-audit-core", () => {
+ it("所有事实齐全时应标记 complete", () => {
+ const audit = buildAgentQcCompletionAudit(completeFacts());
+
+ expect(audit.status).toBe("complete");
+ expect(audit.failedCount).toBe(0);
+ });
+
+ it("缺真实 evidence 时应保持 incomplete", () => {
+ const facts = completeFacts();
+ facts.realEvidencePack = { exists: false, status: "", scenarioCount: 0, scenarioIds: [] };
+ facts.files.realGuiEvidence = false;
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("real-qcloop-evidence");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("real-gui-evidence");
+ });
+
+ it("缺正式 evidence 但存在 sidecar evidence 时应在审计证据中显示 sidecar 状态", () => {
+ const facts = completeFacts();
+ facts.realEvidencePack = { exists: false, status: "", scenarioCount: 0, scenarioIds: [] };
+ facts.realEvidenceSidecars = [
+ {
+ path: ".lime/qc/agent-qc-evidence.p0-v2.json",
+ status: "fail",
+ scenarioCount: 8,
+ scenarioIds: ["command-bridge-contract"],
+ },
+ ];
+
+ const audit = buildAgentQcCompletionAudit(facts);
+ const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence");
+
+ expect(audit.status).toBe("incomplete");
+ expect(item?.evidence).toContain("sidecars");
+ expect(item?.evidence).toContain("status=fail");
+ });
+
+ it("真实 qcloop 批次 stale 时应在缺口中显示 stale sidecar", () => {
+ const facts = completeFacts();
+ facts.realEvidencePack = {
+ exists: true,
+ status: "fail",
+ scenarioCount: 8,
+ scenarioIds: ["command-bridge-contract"],
+ };
+ facts.qcloopStatusSidecars = [
+ {
+ path: ".lime/qc/qcloop-status.isolated-p0-full-v1-stale-check.json",
+ verdictStatus: "stale",
+ counts: { success: 4, running: 1, pending: 3, stale: 1 },
+ },
+ ];
+
+ const audit = buildAgentQcCompletionAudit(facts);
+ const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence");
+
+ expect(audit.status).toBe("incomplete");
+ expect(item?.evidence).toContain("qcloopStatus");
+ expect(item?.gap).toContain("stale");
+ expect(item?.gap).toContain("qcloop-status.isolated-p0-full-v1-stale-check.json");
+ });
+
+ it("真实 qcloop 批次仍在运行时应在缺口中显示未终态 sidecar 摘要", () => {
+ const facts = completeFacts();
+ facts.realEvidencePack = {
+ exists: true,
+ status: "fail",
+ scenarioCount: 8,
+ scenarioIds: ["command-bridge-contract"],
+ };
+ facts.qcloopStatusSidecars = [
+ {
+ path: ".lime/qc/qcloop-status.fastmini-readonly-p0-v1-current.json",
+ verdictStatus: "running",
+ counts: { success: 0, running: 1, pending: 2, stale: 0 },
+ },
+ ];
+
+ const audit = buildAgentQcCompletionAudit(facts);
+ const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence");
+
+ expect(audit.status).toBe("incomplete");
+ expect(item?.gap).toContain("未终态");
+ expect(item?.gap).toContain("running=1");
+ expect(item?.gap).toContain("qcloop-status.fastmini-readonly-p0-v1-current.json");
+ });
+
+ it("qcloop verifier prompt 缺少 worker evidence 占位符时应保持 incomplete", () => {
+ const facts = completeFacts();
+ facts.qcloopPayload.verifierHasWorkerOutput = false;
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain(
+ "qcloop-verifier-evidence-placeholders",
+ );
+ });
+
+ it("结构化 evidence summary 契约缺失时应保持 incomplete", () => {
+ const facts = completeFacts();
+ facts.qcloopPayload.workerPromptHasStructuredEvidence = false;
+ facts.structuredEvidence.exporterParsesSummary = false;
+ facts.structuredEvidence.releaseSummaryRejectsWeakRefs = false;
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("structured-evidence-contract");
+ });
+
+ it("缺少 qcloop 状态监控入口时应保持 incomplete", () => {
+ const facts = completeFacts();
+ facts.files.qcloopStatusScript = false;
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-status-monitor");
+ });
+
+ it("缺少 qcloop worker preflight 时应保持 incomplete", () => {
+ const facts = completeFacts();
+ facts.qcloopPayload.workerPromptHasPreflight = false;
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-worker-preflight");
+ });
+
+ it("缺少 stale owner 机器可读决策输出时应保持 incomplete", () => {
+ const facts = completeFacts();
+ facts.staleOwnerIntervention.guiOwnerReportHasDecisionPacket = false;
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("stale-owner-intervention-protocol");
+ expect(audit.items.find((item) => item.id === "stale-owner-intervention-protocol")?.evidence).toContain(
+ "ownerIntervention=false",
+ );
+ });
+
+ it("缺少 stale owner watch history 输出时应保持 incomplete", () => {
+ const facts = completeFacts();
+ facts.staleOwnerIntervention.guiOwnerReportHasWatchHistory = false;
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("stale-owner-intervention-protocol");
+ expect(audit.items.find((item) => item.id === "stale-owner-intervention-protocol")?.evidence).toContain(
+ "watchHistory=false",
+ );
+ });
+
+ it("release hard gate 缺失时应给出缺口", () => {
+ const facts = completeFacts();
+ facts.release.hasHardGate = false;
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("release-hard-gate");
+ });
+
+ it("release 未强制 P0 scenario 覆盖时应给出缺口", () => {
+ const facts = completeFacts();
+ facts.release.requiresP0ScenarioCoverage = false;
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("release-hard-gate");
+ });
+
+ it("verify:local 失败时应给出缺口", () => {
+ const facts = completeFacts();
+ facts.localVerify = {
+ status: "fail",
+ failedStage: "typecheck",
+ error: "example typecheck failure",
+ };
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.gaps.map((gap) => gap.id)).toContain("local-verify-gate");
+ expect(audit.items.find((item) => item.id === "local-verify-gate")?.evidence).toContain(
+ "failedStage=typecheck",
+ );
+ });
+
+ it("真实 evidence 数量足够但缺 P0 scenario id 时仍应保持 incomplete", () => {
+ const facts = completeFacts();
+ facts.scenarioReport.p0ScenarioCount = 2;
+ facts.scenarioReport.p0ScenarioIds = [
+ "command-bridge-contract",
+ "tool-approval-sandbox-boundary",
+ ];
+ facts.realEvidencePack = {
+ exists: true,
+ status: "pass",
+ scenarioCount: 2,
+ scenarioIds: ["command-bridge-contract", "non-p0-placeholder"],
+ };
+
+ const audit = buildAgentQcCompletionAudit(facts);
+
+ expect(audit.status).toBe("incomplete");
+ expect(audit.items.find((item) => item.id === "real-qcloop-evidence")?.evidence).toContain(
+ "missing=tool-approval-sandbox-boundary",
+ );
+ });
+
+ it("应渲染 Markdown 审计报告", () => {
+ const audit = buildAgentQcCompletionAudit(completeFacts());
+ const markdown = renderAgentQcCompletionAuditMarkdown(audit);
+
+ expect(markdown).toContain("Agent QC Completion Audit");
+ expect(markdown).toContain("Status: complete");
+ });
+});
diff --git a/scripts/lib/agent-qc-evidence-core.mjs b/scripts/lib/agent-qc-evidence-core.mjs
new file mode 100644
index 000000000..bde59621a
--- /dev/null
+++ b/scripts/lib/agent-qc-evidence-core.mjs
@@ -0,0 +1,497 @@
+const QCLoopTerminalStatus = new Set(["success", "failed", "exhausted"]);
+const QCLoopEvidenceSummaryMarker = "QCLOOP_EVIDENCE_SUMMARY_JSON=";
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function isNonEmptyString(value) {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function normalizeStatus(value) {
+ return isNonEmptyString(value) ? value.trim().toLowerCase() : "unknown";
+}
+
+function collectAttemptStdout(item) {
+ return asArray(item?.attempts)
+ .map((attempt) => (typeof attempt?.stdout === "string" ? attempt.stdout : ""))
+ .join("\n");
+}
+
+function collectAttemptStderr(item) {
+ return asArray(item?.attempts)
+ .map((attempt) => (typeof attempt?.stderr === "string" ? attempt.stderr : ""))
+ .join("\n");
+}
+
+function collectAttemptOutput(item) {
+ return [collectAttemptStdout(item), collectAttemptStderr(item)].join("\n");
+}
+
+function isQCLoopWorkerBlocked(item) {
+ return /QCLOOP_WORKER_RESULT\s*[:=]\s*BLOCKED/i.test(collectAttemptStdout(item));
+}
+
+function isQCLoopWorkerEnvironmentBlocked(item) {
+ return [
+ /QCLOOP_CODEX_BIN\s+不可用/i,
+ /QCLOOP_CODEX_EXTRA_ARGS\s+解析失败/i,
+ /QCLOOP_CODEX_SANDBOX=.*无效/i,
+ /fork\/exec .* no such file or directory/i,
+ /incorrect api key provided/i,
+ /failed to connect to websocket: HTTP error: 401 Unauthorized/i,
+ ].some((pattern) => pattern.test(collectAttemptOutput(item)));
+}
+
+function isQCLoopWorkerSelfReportedPass(item) {
+ return /QCLOOP_WORKER_RESULT\s*[:=]\s*PASS/i.test(collectAttemptStdout(item));
+}
+
+function parseQCLoopEvidenceSummaryLine(line) {
+ if (!isNonEmptyString(line)) {
+ return { summary: null, error: "" };
+ }
+ const markerIndex = line.indexOf(QCLoopEvidenceSummaryMarker);
+ if (markerIndex < 0) {
+ return { summary: null, error: "" };
+ }
+
+ const rawJson = line.slice(markerIndex + QCLoopEvidenceSummaryMarker.length).trim();
+ if (!rawJson) {
+ return { summary: null, error: "empty" };
+ }
+ try {
+ const parsed = JSON.parse(rawJson);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return { summary: null, error: "not_object" };
+ }
+ return { summary: parsed, error: "" };
+ } catch {
+ return { summary: null, error: "invalid_json" };
+ }
+}
+
+function collectQCLoopEvidenceSummaries(item) {
+ const summaries = [];
+ const parseErrors = [];
+ const stdout = collectAttemptStdout(item);
+ for (const line of stdout.split(/\r?\n/)) {
+ if (!line.includes(QCLoopEvidenceSummaryMarker)) {
+ continue;
+ }
+ const parsed = parseQCLoopEvidenceSummaryLine(line);
+ if (parsed.summary) {
+ summaries.push(parsed.summary);
+ } else {
+ parseErrors.push(parsed.error || "unknown");
+ }
+ }
+ return { summaries, parseErrors };
+}
+
+function latestQCLoopEvidenceSummary(item) {
+ const { summaries } = collectQCLoopEvidenceSummaries(item);
+ return summaries.at(-1) || null;
+}
+
+function normalizeEvidenceResult(value) {
+ const normalized = normalizeStatus(value);
+ if (["pass", "passed", "success"].includes(normalized)) {
+ return "pass";
+ }
+ if (["blocked", "block"].includes(normalized)) {
+ return "blocked";
+ }
+ if (["fail", "failed", "failure", "error"].includes(normalized)) {
+ return "fail";
+ }
+ return "unknown";
+}
+
+function hasFailedQCLoopExecution(item) {
+ const status = normalizeStatus(item?.status);
+ if (status === "failed" || status === "exhausted") {
+ return true;
+ }
+ if (asArray(item?.attempts).some((attempt) => normalizeStatus(attempt?.status) === "failed")) {
+ return true;
+ }
+ return asArray(item?.qc_rounds).some((round) => normalizeStatus(round?.status) === "fail");
+}
+
+function mapQCLoopItemStatus(status, item = null) {
+ const normalized = normalizeStatus(status);
+ const evidenceSummary = item ? latestQCLoopEvidenceSummary(item) : null;
+ const evidenceResult = evidenceSummary ? normalizeEvidenceResult(evidenceSummary.result) : "unknown";
+ if (evidenceResult === "blocked") {
+ return "blocked";
+ }
+ if (evidenceResult === "fail") {
+ return "fail";
+ }
+ if (normalized === "success") {
+ return item ? (evidenceSummary ? "pass" : "fail") : "pass";
+ }
+ if (isQCLoopWorkerBlocked(item) || isQCLoopWorkerEnvironmentBlocked(item)) {
+ return "blocked";
+ }
+ if (normalized === "failed" || normalized === "exhausted") {
+ return "fail";
+ }
+ if (normalized === "running" || normalized === "pending") {
+ return "blocked";
+ }
+ return "needs-human-review";
+}
+
+function mapQCLoopJobStatus(status, itemStatuses) {
+ const normalized = normalizeStatus(status);
+ if (itemStatuses.some((itemStatus) => itemStatus === "fail")) {
+ return "fail";
+ }
+ if (itemStatuses.some((itemStatus) => itemStatus === "blocked")) {
+ return "blocked";
+ }
+ if (itemStatuses.some((itemStatus) => itemStatus === "needs-human-review")) {
+ return "needs-human-review";
+ }
+ if (normalized === "completed") {
+ return "pass";
+ }
+ if (normalized === "failed") {
+ return "fail";
+ }
+ if (normalized === "paused" || normalized === "running" || normalized === "pending") {
+ return "blocked";
+ }
+ return "needs-human-review";
+}
+
+function parseScenarioId(itemValue) {
+ if (!isNonEmptyString(itemValue)) {
+ return "unknown-scenario";
+ }
+
+ const trimmed = itemValue.trim();
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
+ try {
+ const parsed = JSON.parse(trimmed);
+ for (const key of ["scenarioId", "scenario_id", "id", "name", "entry"]) {
+ if (isNonEmptyString(parsed?.[key])) {
+ return parsed[key].trim();
+ }
+ }
+ } catch {
+ // 非 JSON item 按普通字符串处理。
+ }
+ }
+
+ return trimmed.split(/[\s,|]+/)[0] || "unknown-scenario";
+}
+
+function collectItemFailureModes(item) {
+ const modes = [];
+ const status = normalizeStatus(item?.status);
+ const { summaries, parseErrors } = collectQCLoopEvidenceSummaries(item);
+ if (parseErrors.length > 0) {
+ modes.push("qcloop:evidence_summary_invalid_json");
+ }
+ if (status === "success" && summaries.length === 0 && parseErrors.length === 0) {
+ modes.push("qcloop:evidence_summary_missing");
+ }
+ if (status === "failed") {
+ modes.push("qcloop:item_failed");
+ }
+ if (status === "exhausted") {
+ modes.push("qcloop:max_qc_rounds_exhausted");
+ }
+ if (status === "running" || status === "pending") {
+ modes.push("qcloop:item_not_terminal");
+ }
+ if (isNonEmptyString(item?.last_error)) {
+ modes.push("qcloop:last_error_present");
+ }
+ if (isQCLoopWorkerBlocked(item)) {
+ modes.push("qcloop:worker_blocked");
+ }
+ if (isQCLoopWorkerEnvironmentBlocked(item)) {
+ modes.push("qcloop:worker_environment_blocked");
+ }
+ if (isQCLoopWorkerSelfReportedPass(item) && hasFailedQCLoopExecution(item)) {
+ modes.push("qcloop:worker_self_report_pass_not_verified");
+ }
+
+ for (const attempt of asArray(item?.attempts)) {
+ if (normalizeStatus(attempt.status) === "failed") {
+ modes.push(`qcloop:attempt_${attempt.attempt_no ?? "unknown"}_failed`);
+ }
+ }
+
+ for (const round of asArray(item?.qc_rounds)) {
+ if (normalizeStatus(round.status) === "fail") {
+ modes.push(`qcloop:qc_${round.qc_no ?? "unknown"}_failed`);
+ }
+ }
+
+ return Array.from(new Set(modes));
+}
+
+function collectEvidenceSummaryArtifactRefs(summary) {
+ const refs = [];
+ for (const command of asArray(summary?.commands)) {
+ for (const key of ["stdout_artifact", "stderr_artifact", "logRef", "log_ref"]) {
+ if (isNonEmptyString(command?.[key])) {
+ refs.push(command[key].trim());
+ }
+ }
+ }
+ for (const evidence of asArray(summary?.evidence_required)) {
+ if (isNonEmptyString(evidence?.artifact_path)) {
+ refs.push(evidence.artifact_path.trim());
+ }
+ }
+ for (const artifact of asArray(summary?.artifacts)) {
+ if (typeof artifact === "string" && artifact.trim()) {
+ refs.push(artifact.trim());
+ continue;
+ }
+ if (isNonEmptyString(artifact?.path)) {
+ refs.push(artifact.path.trim());
+ }
+ }
+ if (isNonEmptyString(summary?.gui_session_owner)) {
+ refs.push(`gui-session-owner:${summary.gui_session_owner.trim()}`);
+ }
+ if (isNonEmptyString(summary?.release_scope)) {
+ refs.push(`release-scope:${summary.release_scope.trim()}`);
+ }
+ return refs;
+}
+
+function collectEvidenceRefs(item) {
+ const refs = [];
+ if (isNonEmptyString(item?.id)) {
+ refs.push(`qcloop:item:${item.id}`);
+ }
+ for (const attempt of asArray(item?.attempts)) {
+ if (isNonEmptyString(attempt?.id)) {
+ refs.push(`qcloop:attempt:${attempt.id}`);
+ }
+ }
+ for (const round of asArray(item?.qc_rounds)) {
+ if (isNonEmptyString(round?.id)) {
+ refs.push(`qcloop:qc:${round.id}`);
+ }
+ }
+ for (const summary of collectQCLoopEvidenceSummaries(item).summaries) {
+ refs.push(...collectEvidenceSummaryArtifactRefs(summary));
+ }
+ return Array.from(new Set(refs));
+}
+
+function findArtifactRefByKind(summary, kind) {
+ for (const artifact of asArray(summary?.artifacts)) {
+ if (typeof artifact === "string") {
+ if (artifact.includes(kind)) {
+ return artifact;
+ }
+ continue;
+ }
+ if (artifact?.kind === kind && isNonEmptyString(artifact?.path)) {
+ return artifact.path.trim();
+ }
+ }
+ return "";
+}
+
+function summarizeQCLoopItem(item) {
+ const attempts = asArray(item?.attempts);
+ const qcRounds = asArray(item?.qc_rounds);
+ const failedQcRound = qcRounds.find((round) => normalizeStatus(round.status) === "fail");
+ const latestQcRound = qcRounds.at(-1);
+ const status = mapQCLoopItemStatus(item?.status, item);
+ const evidenceSummary = latestQCLoopEvidenceSummary(item);
+
+ return {
+ scenarioId: parseScenarioId(item?.item_value),
+ status,
+ executor: "qcloop",
+ attempts: attempts.length,
+ evidenceRefs: collectEvidenceRefs(item),
+ failureModes: collectItemFailureModes(item),
+ humanReview: {
+ required: status === "needs-human-review",
+ reason:
+ status === "needs-human-review"
+ ? "qcloop item 状态无法自动归类,需要人工审核。"
+ : "无需人工审核。",
+ decision: status === "needs-human-review" ? "not-reviewed" : "approved",
+ },
+ runtimeTranscriptRef:
+ findArtifactRefByKind(evidenceSummary, "runtime-transcript") ||
+ (attempts.length > 0 ? `qcloop:item:${item.id}:attempts` : ""),
+ guiTraceRef: findArtifactRefByKind(evidenceSummary, "gui-trace"),
+ consoleErrorCount: 0,
+ networkErrorCount: 0,
+ _summary: {
+ itemId: item?.id ?? "",
+ itemValue: item?.item_value ?? "",
+ qcloopStatus: item?.status ?? "unknown",
+ currentAttemptNo: item?.current_attempt_no ?? 0,
+ currentQcNo: item?.current_qc_no ?? 0,
+ latestFeedback: failedQcRound?.feedback || latestQcRound?.feedback || item?.last_error || "",
+ evidenceSummaryPresent: Boolean(evidenceSummary),
+ },
+ };
+}
+
+function stripPrivateSummary(scenarioResult) {
+ const { _summary, ...publicResult } = scenarioResult;
+ return publicResult;
+}
+
+function collectChangedFiles(options) {
+ return asArray(options.changedFiles).filter(isNonEmptyString);
+}
+
+function buildAgentQcEvidencePack({ job, items, options = {} }) {
+ const generatedAt = options.generatedAt || new Date().toISOString();
+ const scenarioSummaries = asArray(items).map(summarizeQCLoopItem);
+ const scenarioResults = scenarioSummaries.map(stripPrivateSummary);
+ const itemStatuses = scenarioResults.map((result) => result.status);
+ const verdictStatus = mapQCLoopJobStatus(job?.status, itemStatuses);
+ const failedItems = scenarioSummaries.filter((result) => result.status === "fail");
+ const blockedItems = scenarioSummaries.filter((result) => result.status === "blocked");
+ const reviewItems = scenarioSummaries.filter((result) => result.status === "needs-human-review");
+ const commandStatus = verdictStatus === "pass" ? "pass" : verdictStatus;
+
+ return {
+ schemaVersion: "v1",
+ runId: options.runId || `qcloop-${job?.id || "unknown"}-${Date.parse(generatedAt) || Date.now()}`,
+ generatedAt,
+ subject: {
+ repo: options.repo || "lime",
+ ref: options.ref || "unknown",
+ diffBase: options.diffBase || "",
+ changedFiles: collectChangedFiles(options),
+ riskTags: ["agent-qc", "qcloop", ...(options.riskTags || [])],
+ },
+ laneResults: [
+ {
+ laneId: "L4-behavior-eval",
+ status: commandStatus,
+ commands: [
+ {
+ command: `qcloop job ${job?.id || "unknown"}`,
+ status: commandStatus,
+ artifactRefs: [
+ `qcloop:job:${job?.id || "unknown"}`,
+ `qcloop:items:${job?.id || "unknown"}`,
+ ],
+ },
+ ],
+ notes: [
+ `qcloop job status: ${job?.status || "unknown"}`,
+ `items: ${scenarioResults.length}`,
+ `failed: ${failedItems.length}`,
+ `blocked: ${blockedItems.length}`,
+ `needs review: ${reviewItems.length}`,
+ ],
+ },
+ ],
+ scenarioResults,
+ verdict: {
+ status: verdictStatus,
+ summary: renderVerdictSummary({ job, scenarioResults, failedItems, blockedItems, reviewItems }),
+ blockers: renderBlockers({ failedItems, blockedItems, reviewItems }),
+ waivers: [],
+ nextAction: renderNextAction(verdictStatus),
+ },
+ };
+}
+
+function renderVerdictSummary({ job, scenarioResults, failedItems, blockedItems, reviewItems }) {
+ return `qcloop job ${job?.id || "unknown"} (${job?.status || "unknown"}) 导出 ${scenarioResults.length} 个场景结果:失败 ${failedItems.length},阻断 ${blockedItems.length},需人审 ${reviewItems.length}。`;
+}
+
+function renderBlockers({ failedItems, blockedItems, reviewItems }) {
+ const blockers = [];
+ for (const result of failedItems) {
+ let reason = result._summary.latestFeedback || "qcloop item failed";
+ if (result.failureModes.includes("qcloop:evidence_summary_missing")) {
+ reason = "qcloop item 已 success,但 stdout 缺少 QCLOOP_EVIDENCE_SUMMARY_JSON;不能作为 release pass。";
+ } else if (result.failureModes.includes("qcloop:evidence_summary_invalid_json")) {
+ reason = "qcloop stdout 包含 QCLOOP_EVIDENCE_SUMMARY_JSON,但 JSON 无法解析。";
+ } else if (result.failureModes.includes("qcloop:worker_self_report_pass_not_verified")) {
+ reason = `worker stdout 自报 QCLOOP_WORKER_RESULT=PASS,但 qcloop / verifier 未通过;latest verifier feedback: ${result._summary.latestFeedback || "无"}`;
+ }
+ blockers.push(`${result.scenarioId}: ${reason}`);
+ }
+ for (const result of blockedItems) {
+ const reason = result.failureModes.includes("qcloop:worker_environment_blocked")
+ ? "qcloop worker 环境阻断;请检查内层 CLI 二进制、认证、sandbox 或 extra args。"
+ : result.failureModes.includes("qcloop:worker_blocked")
+ ? `qcloop worker 明确输出 QCLOOP_WORKER_RESULT=BLOCKED;latest verifier feedback: ${result._summary.latestFeedback || "无"}`
+ : `qcloop item 未进入终态 (${result._summary.qcloopStatus})`;
+ blockers.push(`${result.scenarioId}: ${reason}`);
+ }
+ for (const result of reviewItems) {
+ blockers.push(`${result.scenarioId}: 状态无法自动归类,需要人工审核`);
+ }
+ return blockers;
+}
+
+function renderNextAction(status) {
+ if (status === "pass") {
+ return "可把 Evidence Pack 挂到 PR / release 证据摘要。";
+ }
+ if (status === "fail") {
+ return "优先修复 failed/exhausted item,并把高价值失败沉淀为 replay 或稳定回归。";
+ }
+ if (status === "blocked") {
+ return "先恢复 qcloop job 到终态,或记录环境/权限阻断原因。";
+ }
+ return "安排人工审核 verifier feedback,再决定修复、waive 或补测试。";
+}
+
+function validateEvidencePackShape(pack) {
+ const issues = [];
+ for (const field of ["schemaVersion", "runId", "generatedAt", "subject", "laneResults", "scenarioResults", "verdict"]) {
+ if (!(field in pack)) {
+ issues.push(`缺少字段 ${field}`);
+ }
+ }
+ if (pack.schemaVersion !== "v1") {
+ issues.push("schemaVersion 必须是 v1");
+ }
+ if (!Array.isArray(pack.laneResults)) {
+ issues.push("laneResults 必须是数组");
+ }
+ if (!Array.isArray(pack.scenarioResults)) {
+ issues.push("scenarioResults 必须是数组");
+ }
+ if (!pack.verdict || !["pass", "fail", "blocked", "needs-human-review", "waived"].includes(pack.verdict.status)) {
+ issues.push("verdict.status 不合法");
+ }
+ return {
+ valid: issues.length === 0,
+ issues,
+ };
+}
+
+export {
+ buildAgentQcEvidencePack,
+ collectEvidenceRefs,
+ collectItemFailureModes,
+ collectQCLoopEvidenceSummaries,
+ isQCLoopWorkerEnvironmentBlocked,
+ isQCLoopWorkerBlocked,
+ isQCLoopWorkerSelfReportedPass,
+ mapQCLoopItemStatus,
+ mapQCLoopJobStatus,
+ parseScenarioId,
+ parseQCLoopEvidenceSummaryLine,
+ summarizeQCLoopItem,
+ validateEvidencePackShape,
+};
diff --git a/scripts/lib/agent-qc-evidence-core.test.ts b/scripts/lib/agent-qc-evidence-core.test.ts
new file mode 100644
index 000000000..1255b75c4
--- /dev/null
+++ b/scripts/lib/agent-qc-evidence-core.test.ts
@@ -0,0 +1,339 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildAgentQcEvidencePack,
+ collectQCLoopEvidenceSummaries,
+ isQCLoopWorkerEnvironmentBlocked,
+ isQCLoopWorkerBlocked,
+ isQCLoopWorkerSelfReportedPass,
+ mapQCLoopItemStatus,
+ parseQCLoopEvidenceSummaryLine,
+ parseScenarioId,
+ validateEvidencePackShape,
+} from "./agent-qc-evidence-core.mjs";
+
+function evidenceSummary(overrides = {}) {
+ return `QCLOOP_EVIDENCE_SUMMARY_JSON=${JSON.stringify({
+ scenario_id: "command-bridge-contract",
+ result: "pass",
+ commands: [
+ {
+ command: "npm run test:contracts",
+ exit_code: 0,
+ stdout_artifact: ".lime/qc/test-contracts.stdout.txt",
+ },
+ ],
+ evidence_required: [
+ {
+ name: "contract command log",
+ status: "pass",
+ evidence: "test:contracts passed",
+ artifact_path: ".lime/qc/test-contracts.stdout.txt",
+ },
+ ],
+ evidence_layers_covered: ["deterministic-smoke"],
+ failure_modes: [{ name: "mock fallback drift", status: "excluded", evidence: "contract check passed" }],
+ artifacts: [{ path: ".lime/qc/contract-summary.json", kind: "deterministic-smoke", redacted: true }],
+ blockers: [],
+ gui_session_owner: "",
+ release_scope: "source-tree-startup-smoke",
+ ...overrides,
+ })}`;
+}
+
+describe("agent-qc-evidence-core", () => {
+ it("应把 qcloop item 状态映射为 Evidence Pack 状态", () => {
+ expect(mapQCLoopItemStatus("success")).toBe("pass");
+ expect(mapQCLoopItemStatus("failed")).toBe("fail");
+ expect(mapQCLoopItemStatus("exhausted")).toBe("fail");
+ expect(mapQCLoopItemStatus("running")).toBe("blocked");
+ });
+
+ it("应解析 qcloop 结构化 evidence summary marker", () => {
+ const parsed = parseQCLoopEvidenceSummaryLine(evidenceSummary());
+
+ expect(parsed.error).toBe("");
+ expect(parsed.summary?.scenario_id).toBe("command-bridge-contract");
+
+ const item = { attempts: [{ stdout: `log\n${evidenceSummary()}\n` }] };
+ const summaries = collectQCLoopEvidenceSummaries(item);
+ expect(summaries.summaries).toHaveLength(1);
+ expect(summaries.parseErrors).toEqual([]);
+ });
+
+ it("应把 worker 明确报告 BLOCKED 的 exhausted item 映射为 blocked", () => {
+ const item = {
+ status: "exhausted",
+ attempts: [
+ {
+ stdout: [
+ "QCLOOP_WORKER_RESULT=BLOCKED",
+ "失败 check: devbridge-health",
+ "错误: TypeError: fetch failed",
+ ].join("\n"),
+ },
+ ],
+ };
+
+ expect(isQCLoopWorkerBlocked(item)).toBe(true);
+ expect(mapQCLoopItemStatus("exhausted", item)).toBe("blocked");
+ });
+
+ it("应把内层 CLI 二进制或认证配置错误映射为环境 blocked", () => {
+ const item = {
+ status: "failed",
+ attempts: [
+ {
+ status: "failed",
+ stderr:
+ "QCLOOP_CODEX_BIN 不可用: /opt/homebrew/bin/codex: fork/exec /opt/homebrew/bin/codex: no such file or directory",
+ },
+ ],
+ qc_rounds: [{ status: "fail", feedback: "verifier 输出格式错误" }],
+ };
+
+ expect(isQCLoopWorkerEnvironmentBlocked(item)).toBe(true);
+ expect(mapQCLoopItemStatus("failed", item)).toBe("blocked");
+ });
+
+ it("应标记 worker 自报 PASS 但 qcloop/verifier 未通过的冲突", () => {
+ const conflictItem = {
+ id: "item-pass-conflict",
+ item_value: "tool-approval-sandbox-boundary",
+ status: "failed",
+ current_attempt_no: 2,
+ current_qc_no: 1,
+ attempts: [
+ {
+ id: "attempt-pass-conflict",
+ status: "failed",
+ attempt_no: 2,
+ stdout: "QCLOOP_WORKER_RESULT=PASS\nsmoke pass, but no live transcript",
+ },
+ ],
+ qc_rounds: [
+ {
+ id: "qc-pass-conflict",
+ status: "fail",
+ qc_no: 1,
+ feedback: "缺少 live runtime transcript。",
+ },
+ ],
+ };
+ const pack = buildAgentQcEvidencePack({
+ job: { id: "job-pass-conflict", status: "running" },
+ items: [conflictItem],
+ options: { generatedAt: "2026-05-10T00:00:00.000Z" },
+ });
+
+ expect(isQCLoopWorkerSelfReportedPass(conflictItem)).toBe(true);
+ expect(pack.verdict.status).toBe("fail");
+ expect(pack.scenarioResults[0].failureModes).toContain(
+ "qcloop:worker_self_report_pass_not_verified",
+ );
+ expect(pack.verdict.blockers.join("\n")).toContain(
+ "worker stdout 自报 QCLOOP_WORKER_RESULT=PASS",
+ );
+ });
+
+ it("应从 item_value 中提取 scenario id", () => {
+ expect(parseScenarioId("claw-chat-ready-streaming 基础聊天")).toBe(
+ "claw-chat-ready-streaming",
+ );
+ expect(parseScenarioId('{"scenario_id":"command-bridge-contract"}')).toBe(
+ "command-bridge-contract",
+ );
+ expect(parseScenarioId('{"name":"agent-ui-performance-summary-ttft-fields"}')).toBe(
+ "agent-ui-performance-summary-ttft-fields",
+ );
+ expect(parseScenarioId('{"entry":"lime-agent-runtime-client-check"}')).toBe(
+ "lime-agent-runtime-client-check",
+ );
+ });
+
+ it("应把成功 qcloop job 导出为 pass evidence pack", () => {
+ const pack = buildAgentQcEvidencePack({
+ job: { id: "job-1", status: "completed" },
+ items: [
+ {
+ id: "item-1",
+ item_value: "command-bridge-contract",
+ status: "success",
+ current_attempt_no: 1,
+ current_qc_no: 1,
+ attempts: [
+ {
+ id: "attempt-1",
+ status: "success",
+ attempt_no: 1,
+ stdout: evidenceSummary(),
+ },
+ ],
+ qc_rounds: [{ id: "qc-1", status: "pass", qc_no: 1 }],
+ },
+ ],
+ options: {
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ ref: "test-ref",
+ changedFiles: ["docs/tests/agent-ops-qc.md"],
+ },
+ });
+
+ expect(pack.verdict.status).toBe("pass");
+ expect(pack.scenarioResults[0].scenarioId).toBe("command-bridge-contract");
+ expect(pack.scenarioResults[0].evidenceRefs).toContain("qcloop:attempt:attempt-1");
+ expect(pack.scenarioResults[0].evidenceRefs).toContain(".lime/qc/contract-summary.json");
+ expect(pack.scenarioResults[0].evidenceRefs).toContain("release-scope:source-tree-startup-smoke");
+ expect(validateEvidencePackShape(pack).valid).toBe(true);
+ });
+
+ it("应拒绝缺少 QCLOOP_EVIDENCE_SUMMARY_JSON 的 success item,避免旧浅层 pass 进入 release gate", () => {
+ const pack = buildAgentQcEvidencePack({
+ job: { id: "job-missing-summary", status: "completed" },
+ items: [
+ {
+ id: "item-missing-summary",
+ item_value: "command-bridge-contract",
+ status: "success",
+ current_attempt_no: 1,
+ current_qc_no: 1,
+ attempts: [{ id: "attempt-missing-summary", status: "success", attempt_no: 1 }],
+ qc_rounds: [{ id: "qc-missing-summary", status: "pass", qc_no: 1 }],
+ },
+ ],
+ options: { generatedAt: "2026-05-10T00:00:00.000Z" },
+ });
+
+ expect(pack.verdict.status).toBe("fail");
+ expect(pack.scenarioResults[0].failureModes).toContain("qcloop:evidence_summary_missing");
+ expect(pack.verdict.blockers.join("\n")).toContain("缺少 QCLOOP_EVIDENCE_SUMMARY_JSON");
+ });
+
+ it("应拒绝无法解析的 QCLOOP_EVIDENCE_SUMMARY_JSON", () => {
+ const pack = buildAgentQcEvidencePack({
+ job: { id: "job-invalid-summary", status: "completed" },
+ items: [
+ {
+ id: "item-invalid-summary",
+ item_value: "command-bridge-contract",
+ status: "success",
+ current_attempt_no: 1,
+ current_qc_no: 1,
+ attempts: [
+ {
+ id: "attempt-invalid-summary",
+ status: "success",
+ attempt_no: 1,
+ stdout: "QCLOOP_EVIDENCE_SUMMARY_JSON={not-json}",
+ },
+ ],
+ qc_rounds: [{ id: "qc-invalid-summary", status: "pass", qc_no: 1 }],
+ },
+ ],
+ options: { generatedAt: "2026-05-10T00:00:00.000Z" },
+ });
+
+ expect(pack.verdict.status).toBe("fail");
+ expect(pack.scenarioResults[0].failureModes).toContain("qcloop:evidence_summary_invalid_json");
+ expect(pack.verdict.blockers.join("\n")).toContain("JSON 无法解析");
+ });
+
+ it("应把耗尽 qcloop item 导出为 fail evidence pack", () => {
+ const pack = buildAgentQcEvidencePack({
+ job: { id: "job-2", status: "failed" },
+ items: [
+ {
+ id: "item-2",
+ item_value: "tool-approval-sandbox-boundary",
+ status: "exhausted",
+ current_attempt_no: 3,
+ current_qc_no: 3,
+ attempts: [{ id: "attempt-2", status: "failed", attempt_no: 3 }],
+ qc_rounds: [
+ {
+ id: "qc-2",
+ status: "fail",
+ qc_no: 3,
+ feedback: "缺少 approval 证据。",
+ },
+ ],
+ },
+ ],
+ options: { generatedAt: "2026-05-10T00:00:00.000Z" },
+ });
+
+ expect(pack.verdict.status).toBe("fail");
+ expect(pack.verdict.blockers.join("\n")).toContain("缺少 approval 证据");
+ expect(pack.scenarioResults[0].failureModes).toContain(
+ "qcloop:max_qc_rounds_exhausted",
+ );
+ });
+
+ it("应把 preflight blocked 的耗尽 item 导出为 blocked evidence pack", () => {
+ const pack = buildAgentQcEvidencePack({
+ job: { id: "job-3", status: "completed" },
+ items: [
+ {
+ id: "item-3",
+ item_value: "claw-chat-ready-streaming",
+ status: "exhausted",
+ current_attempt_no: 3,
+ current_qc_no: 3,
+ attempts: [
+ {
+ id: "attempt-3",
+ status: "success",
+ attempt_no: 3,
+ stdout: "QCLOOP_WORKER_RESULT=BLOCKED\nDevBridge preflight: BLOCKED",
+ },
+ ],
+ qc_rounds: [
+ {
+ id: "qc-3",
+ status: "fail",
+ qc_no: 3,
+ feedback: "DevBridge preflight blocked。",
+ },
+ ],
+ },
+ ],
+ options: { generatedAt: "2026-05-10T00:00:00.000Z" },
+ });
+
+ expect(pack.verdict.status).toBe("blocked");
+ expect(pack.verdict.blockers.join("\n")).toContain("DevBridge preflight blocked");
+ expect(pack.scenarioResults[0].failureModes).toContain("qcloop:worker_blocked");
+ });
+
+ it("应把 qcloop worker 环境阻断导出为 blocked evidence pack,且不泄露原始 stderr", () => {
+ const pack = buildAgentQcEvidencePack({
+ job: { id: "job-env", status: "failed" },
+ items: [
+ {
+ id: "item-env",
+ item_value: "command-bridge-contract",
+ status: "failed",
+ current_attempt_no: 1,
+ current_qc_no: 1,
+ attempts: [
+ {
+ id: "attempt-env",
+ status: "failed",
+ attempt_no: 1,
+ stdout: "",
+ stderr:
+ "QCLOOP_CODEX_BIN 不可用: /opt/homebrew/bin/codex: fork/exec /opt/homebrew/bin/codex: no such file or directory",
+ },
+ ],
+ qc_rounds: [{ id: "qc-env", status: "fail", qc_no: 1, feedback: "verifier 输出格式错误" }],
+ },
+ ],
+ options: { generatedAt: "2026-05-10T00:00:00.000Z" },
+ });
+
+ expect(pack.verdict.status).toBe("blocked");
+ expect(pack.scenarioResults[0].failureModes).toContain("qcloop:worker_environment_blocked");
+ expect(pack.verdict.blockers.join("\n")).toContain("qcloop worker 环境阻断");
+ expect(pack.verdict.blockers.join("\n")).not.toContain("/opt/homebrew/bin/codex");
+ });
+});
diff --git a/scripts/lib/agent-qc-gui-flow-core.mjs b/scripts/lib/agent-qc-gui-flow-core.mjs
new file mode 100644
index 000000000..2c238bd54
--- /dev/null
+++ b/scripts/lib/agent-qc-gui-flow-core.mjs
@@ -0,0 +1,163 @@
+const REQUIRED_FLOW_FIELDS = [
+ "id",
+ "scenarioId",
+ "risk",
+ "surface",
+ "preflight",
+ "steps",
+ "assertions",
+ "evidenceRequired",
+];
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function isNonEmptyString(value) {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function addIssue(issues, severity, path, message) {
+ issues.push({ severity, path, message });
+}
+
+function validateStringArray(value, path, issues) {
+ if (!Array.isArray(value) || value.length === 0) {
+ addIssue(issues, "error", path, "必须是非空数组。");
+ return;
+ }
+ value.forEach((entry, index) => {
+ if (!isNonEmptyString(entry)) {
+ addIssue(issues, "error", `${path}[${index}]`, "必须是非空字符串。");
+ }
+ });
+}
+
+function validateAgentQcGuiFlowManifest(flowManifest, scenarioManifest) {
+ const issues = [];
+ if (!flowManifest || typeof flowManifest !== "object") {
+ return {
+ valid: false,
+ issues: [{ severity: "error", path: "manifest", message: "GUI flow manifest 必须是 JSON object。" }],
+ };
+ }
+
+ if (flowManifest.manifestVersion !== "v1") {
+ addIssue(issues, "error", "manifestVersion", "当前只接受 manifestVersion=v1。");
+ }
+
+ const scenarios = new Set(asArray(scenarioManifest?.scenarios).map((scenario) => scenario.id).filter(Boolean));
+ const flows = asArray(flowManifest.flows);
+ const flowIds = new Set();
+
+ if (flows.length === 0) {
+ addIssue(issues, "error", "flows", "至少需要一个 GUI flow。");
+ }
+
+ flows.forEach((flow, index) => {
+ const prefix = `flows[${index}]`;
+ for (const field of REQUIRED_FLOW_FIELDS) {
+ if (!(field in flow)) {
+ addIssue(issues, "error", `${prefix}.${field}`, `缺少 ${field}。`);
+ }
+ }
+
+ if (!isNonEmptyString(flow.id)) {
+ addIssue(issues, "error", `${prefix}.id`, "flow id 必须是非空字符串。");
+ } else if (flowIds.has(flow.id)) {
+ addIssue(issues, "error", `${prefix}.id`, `重复 flow id: ${flow.id}`);
+ } else {
+ flowIds.add(flow.id);
+ }
+
+ if (!isNonEmptyString(flow.scenarioId)) {
+ addIssue(issues, "error", `${prefix}.scenarioId`, "scenarioId 必须是非空字符串。");
+ } else if (!scenarios.has(flow.scenarioId)) {
+ addIssue(issues, "error", `${prefix}.scenarioId`, `引用了不存在的 Agent QC scenario: ${flow.scenarioId}`);
+ }
+
+ for (const field of ["preflight", "steps", "assertions", "evidenceRequired"]) {
+ validateStringArray(flow[field], `${prefix}.${field}`, issues);
+ }
+ });
+
+ const errors = issues.filter((issue) => issue.severity === "error");
+ return {
+ valid: errors.length === 0,
+ issues,
+ };
+}
+
+function summarizeAgentQcGuiFlowManifest(flowManifest, validation) {
+ const flows = asArray(flowManifest?.flows);
+ const riskCounts = new Map();
+ for (const flow of flows) {
+ const risk = flow.risk || "unknown";
+ riskCounts.set(risk, (riskCounts.get(risk) ?? 0) + 1);
+ }
+
+ return {
+ title: flowManifest?.title || "Lime Agent QC GUI Flow Manifest",
+ manifestVersion: flowManifest?.manifestVersion || "unknown",
+ valid: validation.valid,
+ issueCount: validation.issues.length,
+ errorCount: validation.issues.filter((issue) => issue.severity === "error").length,
+ warnCount: validation.issues.filter((issue) => issue.severity === "warn").length,
+ flowCount: flows.length,
+ risks: Object.fromEntries(Array.from(riskCounts.entries()).sort(([left], [right]) => left.localeCompare(right))),
+ flows: flows.map((flow) => ({
+ id: flow.id,
+ scenarioId: flow.scenarioId,
+ risk: flow.risk,
+ surface: flow.surface,
+ stepCount: asArray(flow.steps).length,
+ assertionCount: asArray(flow.assertions).length,
+ evidenceCount: asArray(flow.evidenceRequired).length,
+ })),
+ issues: validation.issues,
+ };
+}
+
+function renderAgentQcGuiFlowMarkdown(summary) {
+ const flowLines = summary.flows.length
+ ? summary.flows
+ .map(
+ (flow) =>
+ `- ${flow.id}: ${flow.scenarioId};${flow.risk};${flow.surface};steps ${flow.stepCount} / assertions ${flow.assertionCount} / evidence ${flow.evidenceCount}`,
+ )
+ .join("\n")
+ : "- 无";
+ const issueLines = summary.issues.length
+ ? summary.issues.map((issue) => `- ${issue.severity.toUpperCase()} ${issue.path}: ${issue.message}`).join("\n")
+ : "- 无";
+
+ return `# ${summary.title}
+
+## 结论
+
+- Manifest: ${summary.manifestVersion}
+- 状态: ${summary.valid ? "PASS" : "FAIL"}
+- Flow 数: ${summary.flowCount}
+- Issue: ${summary.issueCount}(error ${summary.errorCount} / warn ${summary.warnCount})
+
+## Flow 清单
+
+${flowLines}
+
+## 校验问题
+
+${issueLines}
+`;
+}
+
+function createAgentQcGuiFlowReport({ flowManifest, scenarioManifest }) {
+ const validation = validateAgentQcGuiFlowManifest(flowManifest, scenarioManifest);
+ return summarizeAgentQcGuiFlowManifest(flowManifest, validation);
+}
+
+export {
+ createAgentQcGuiFlowReport,
+ renderAgentQcGuiFlowMarkdown,
+ summarizeAgentQcGuiFlowManifest,
+ validateAgentQcGuiFlowManifest,
+};
diff --git a/scripts/lib/agent-qc-gui-flow-core.test.ts b/scripts/lib/agent-qc-gui-flow-core.test.ts
new file mode 100644
index 000000000..ac9416c5d
--- /dev/null
+++ b/scripts/lib/agent-qc-gui-flow-core.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createAgentQcGuiFlowReport,
+ renderAgentQcGuiFlowMarkdown,
+ validateAgentQcGuiFlowManifest,
+} from "./agent-qc-gui-flow-core.mjs";
+
+const scenarioManifest = {
+ scenarios: [
+ { id: "claw-chat-ready-streaming" },
+ { id: "workspace-ready-session-restore" },
+ ],
+};
+
+const flowManifest = {
+ manifestVersion: "v1",
+ title: "GUI Flow Manifest",
+ flows: [
+ {
+ id: "gui-claw-chat-ready-streaming",
+ scenarioId: "claw-chat-ready-streaming",
+ risk: "P0",
+ surface: "desktop_gui",
+ preflight: ["check bridge"],
+ steps: ["open", "send"],
+ assertions: ["ready"],
+ evidenceRequired: ["trace"],
+ },
+ ],
+};
+
+describe("agent-qc-gui-flow-core", () => {
+ it("应接受引用现有 scenario 的 GUI flow manifest", () => {
+ const result = validateAgentQcGuiFlowManifest(flowManifest, scenarioManifest);
+
+ expect(result.valid).toBe(true);
+ expect(result.issues).toEqual([]);
+ });
+
+ it("应拒绝引用不存在 scenario 的 flow", () => {
+ const invalid = structuredClone(flowManifest);
+ invalid.flows[0].scenarioId = "missing-scenario";
+
+ const result = validateAgentQcGuiFlowManifest(invalid, scenarioManifest);
+
+ expect(result.valid).toBe(false);
+ expect(result.issues.map((issue) => issue.message).join("\n")).toContain(
+ "missing-scenario",
+ );
+ });
+
+ it("应汇总 flow 数、步骤数和证据数", () => {
+ const report = createAgentQcGuiFlowReport({ flowManifest, scenarioManifest });
+
+ expect(report.valid).toBe(true);
+ expect(report.flowCount).toBe(1);
+ expect(report.flows[0].stepCount).toBe(2);
+ expect(report.flows[0].evidenceCount).toBe(1);
+ });
+
+ it("应渲染 Markdown 报告", () => {
+ const report = createAgentQcGuiFlowReport({ flowManifest, scenarioManifest });
+ const markdown = renderAgentQcGuiFlowMarkdown(report);
+
+ expect(markdown).toContain("GUI Flow Manifest");
+ expect(markdown).toContain("gui-claw-chat-ready-streaming");
+ });
+});
diff --git a/scripts/lib/agent-qc-gui-owner-core.mjs b/scripts/lib/agent-qc-gui-owner-core.mjs
new file mode 100644
index 000000000..4928dbc5d
--- /dev/null
+++ b/scripts/lib/agent-qc-gui-owner-core.mjs
@@ -0,0 +1,239 @@
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function isNonEmptyString(value) {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function normalizeScenarioId(value) {
+ return String(value || "").trim();
+}
+
+function collectGuiScenarioIds(manifest) {
+ return asArray(manifest?.scenarios)
+ .filter((scenario) =>
+ asArray(scenario?.evidenceRequired).includes("GUI session owner / isolation statement"),
+ )
+ .map((scenario) => normalizeScenarioId(scenario?.id))
+ .filter(Boolean);
+}
+
+function summarizeActiveGuiItems(status, guiScenarioIds) {
+ const guiScenarioIdSet = new Set(guiScenarioIds);
+ return asArray(status?.items)
+ .filter((item) => guiScenarioIdSet.has(normalizeScenarioId(item?.scenarioId)))
+ .filter((item) => item?.terminal !== true)
+ .map((item) => ({
+ scenarioId: normalizeScenarioId(item?.scenarioId),
+ qcloopStatus: item?.qcloopStatus || "unknown",
+ evidenceStatus: item?.evidenceStatus || "unknown",
+ stale: Boolean(item?.stale),
+ staleSeconds: item?.staleSeconds ?? null,
+ workerStatus: item?.worker?.status || "unknown",
+ workerDurationSeconds: item?.worker?.durationSeconds ?? null,
+ }));
+}
+
+function createAgentQcGuiOwnerReport({
+ manifest,
+ statusSidecars = [],
+ generatedAt = new Date().toISOString(),
+ maxActiveOwners = 0,
+} = {}) {
+ const guiScenarioIds = collectGuiScenarioIds(manifest);
+ const sidecarsByJobId = new Map();
+ for (const sidecar of asArray(statusSidecars)) {
+ const status = sidecar?.status || sidecar;
+ const jobId = status?.job?.id || sidecar?.path || "unknown";
+ const entries = sidecarsByJobId.get(jobId) || [];
+ entries.push(sidecar);
+ sidecarsByJobId.set(jobId, entries);
+ }
+
+ const activeOwnerByJobId = new Map();
+
+ for (const sidecars of sidecarsByJobId.values()) {
+ const sidecar = selectRepresentativeSidecar(sidecars);
+ const status = sidecar?.status || sidecar;
+ const activeItems = summarizeActiveGuiItems(status, guiScenarioIds);
+ if (activeItems.length === 0) {
+ continue;
+ }
+
+ const jobId = status?.job?.id || sidecar?.path || "unknown";
+ const existing = activeOwnerByJobId.get(jobId);
+ const owner = {
+ path: sidecar?.path || "",
+ jobId,
+ jobName: status?.job?.name || "",
+ jobStatus: status?.job?.status || "unknown",
+ verdictStatus: status?.verdict?.status || "unknown",
+ counts: status?.counts || null,
+ activeItems,
+ };
+
+ if (!existing) {
+ activeOwnerByJobId.set(jobId, owner);
+ continue;
+ }
+
+ const existingStaleCount = existing.activeItems.filter((item) => item.stale).length;
+ const ownerStaleCount = owner.activeItems.filter((item) => item.stale).length;
+ if (ownerStaleCount > existingStaleCount) {
+ activeOwnerByJobId.set(jobId, owner);
+ }
+ }
+
+ const activeOwners = Array.from(activeOwnerByJobId.values()).sort((left, right) =>
+ left.jobId.localeCompare(right.jobId),
+ );
+ const ownerCount = activeOwners.length;
+ const staleOwners = activeOwners.filter((owner) =>
+ owner.activeItems.some((item) => item.stale),
+ );
+ const staleOwnerCount = staleOwners.length;
+ const oldestStaleSeconds = Math.max(
+ 0,
+ ...activeOwners.flatMap((owner) =>
+ owner.activeItems
+ .filter((item) => item.stale)
+ .map((item) => Number(item.staleSeconds || 0)),
+ ),
+ );
+ const pass = ownerCount <= maxActiveOwners;
+ const ownerIntervention =
+ staleOwnerCount > 0 ? createOwnerIntervention(activeOwners) : null;
+
+ return {
+ schemaVersion: "v1",
+ generatedAt,
+ maxActiveOwners,
+ guiScenarioIds,
+ ownerCount,
+ staleOwnerCount,
+ oldestStaleSeconds,
+ activeOwners,
+ ownerIntervention,
+ verdict: {
+ status: pass ? "pass" : "blocked",
+ summary: pass
+ ? `active GUI qcloop owner=${ownerCount},未超过上限 ${maxActiveOwners}。`
+ : `active GUI qcloop owner=${ownerCount},超过上限 ${maxActiveOwners}${staleOwnerCount > 0 ? `;其中 stale owner=${staleOwnerCount},最长 ${oldestStaleSeconds}s` : ""}。`,
+ nextAction: pass
+ ? "可以在其他 release gate 满足后启动新的 GUI P0 批次。"
+ : staleOwnerCount > 0
+ ? "不要启动新的 GUI P0 批次;当前存在 stale GUI owner,只能继续只读观察,或由该 owner 明确确认后处理。"
+ : "不要启动新的 GUI P0 批次;等待现有 running 批次自然结束或由 owner 明确处理。",
+ },
+ };
+}
+
+function createOwnerIntervention(activeOwners) {
+ const staleOwners = activeOwners.filter((owner) =>
+ owner.activeItems.some((item) => item.stale),
+ );
+ const primaryOwner = staleOwners[0];
+ const primaryJobId = primaryOwner?.jobId || "";
+ return {
+ status: "requires_owner_confirmation",
+ jobIds: staleOwners.map((owner) => owner.jobId),
+ requiredConfirmationText: `确认处理 stale GUI owner ${primaryJobId},可以终止 PID 并记录 sidecar。`,
+ prohibitedUntilConfirmed: [
+ "kill / pause / interrupt stale worker",
+ "modify qcloop SQLite DB",
+ "start another full GUI P0 batch",
+ "overwrite .lime/qc/agent-qc-evidence.json",
+ "git commit / push / tag / release",
+ ],
+ evidenceRefs: staleOwners.map((owner) => owner.path).filter(Boolean),
+ nextAction:
+ "先刷新 qcloop status、GUI owner、DB lease 和进程证据;只有 owner 明确确认后才能处理 stale worker。",
+ };
+}
+
+function isTerminalJobStatus(status) {
+ const jobStatus = String(status?.job?.status || "").toLowerCase();
+ return (
+ status?.job?.terminal === true ||
+ ["completed", "failed", "cancelled", "canceled"].includes(jobStatus)
+ );
+}
+
+function selectRepresentativeSidecar(sidecars) {
+ const terminal = asArray(sidecars).find((sidecar) => isTerminalJobStatus(sidecar?.status || sidecar));
+ if (terminal) {
+ return terminal;
+ }
+
+ return asArray(sidecars).reduce((selected, candidate) => {
+ if (!selected) {
+ return candidate;
+ }
+ const selectedStatus = selected?.status || selected;
+ const candidateStatus = candidate?.status || candidate;
+ const selectedStale = Number(selectedStatus?.counts?.stale || 0);
+ const candidateStale = Number(candidateStatus?.counts?.stale || 0);
+ if (candidateStale > selectedStale) {
+ return candidate;
+ }
+ const selectedRunning = Number(selectedStatus?.counts?.running || 0);
+ const candidateRunning = Number(candidateStatus?.counts?.running || 0);
+ return candidateRunning > selectedRunning ? candidate : selected;
+ }, null);
+}
+
+function renderAgentQcGuiOwnerSummary(report) {
+ const lines = [
+ `status=${report.verdict.status}`,
+ `activeOwners=${report.ownerCount}`,
+ `staleOwners=${report.staleOwnerCount ?? 0}`,
+ `oldestStaleSeconds=${report.oldestStaleSeconds ?? 0}`,
+ `maxActiveOwners=${report.maxActiveOwners}`,
+ `guiScenarios=${report.guiScenarioIds.join(",")}`,
+ `summary=${report.verdict.summary}`,
+ ];
+ for (const owner of report.activeOwners) {
+ const items = owner.activeItems
+ .map((item) =>
+ `${item.scenarioId}:${item.qcloopStatus}${item.stale ? `:stale=${item.staleSeconds ?? 0}s` : ""}`,
+ )
+ .join(",");
+ lines.push(`owner=${owner.jobId} jobStatus=${owner.jobStatus} verdict=${owner.verdictStatus} path=${owner.path} items=${items}`);
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+function createAgentQcGuiOwnerWatchEntry(report) {
+ return {
+ schemaVersion: "v1",
+ observedAt: report?.generatedAt || new Date().toISOString(),
+ verdictStatus: report?.verdict?.status || "unknown",
+ ownerCount: report?.ownerCount ?? 0,
+ staleOwnerCount: report?.staleOwnerCount ?? 0,
+ oldestStaleSeconds: report?.oldestStaleSeconds ?? 0,
+ activeOwners: asArray(report?.activeOwners).map((owner) => ({
+ jobId: owner.jobId,
+ jobStatus: owner.jobStatus,
+ verdictStatus: owner.verdictStatus,
+ path: owner.path,
+ activeItems: asArray(owner.activeItems).map((item) => ({
+ scenarioId: item.scenarioId,
+ qcloopStatus: item.qcloopStatus,
+ evidenceStatus: item.evidenceStatus,
+ stale: Boolean(item.stale),
+ staleSeconds: item.staleSeconds ?? null,
+ workerStatus: item.workerStatus,
+ workerDurationSeconds: item.workerDurationSeconds ?? null,
+ })),
+ })),
+ };
+}
+
+export {
+ collectGuiScenarioIds,
+ createAgentQcGuiOwnerWatchEntry,
+ createAgentQcGuiOwnerReport,
+ createOwnerIntervention,
+ renderAgentQcGuiOwnerSummary,
+};
diff --git a/scripts/lib/agent-qc-gui-owner-core.test.ts b/scripts/lib/agent-qc-gui-owner-core.test.ts
new file mode 100644
index 000000000..61de46caf
--- /dev/null
+++ b/scripts/lib/agent-qc-gui-owner-core.test.ts
@@ -0,0 +1,198 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ collectGuiScenarioIds,
+ createAgentQcGuiOwnerWatchEntry,
+ createAgentQcGuiOwnerReport,
+ renderAgentQcGuiOwnerSummary,
+} from "./agent-qc-gui-owner-core.mjs";
+
+const manifest = {
+ scenarios: [
+ {
+ id: "claw-chat-ready-streaming",
+ evidenceRequired: ["GUI session owner / isolation statement"],
+ },
+ {
+ id: "command-bridge-contract",
+ evidenceRequired: ["contract command log"],
+ },
+ ],
+};
+
+describe("agent-qc-gui-owner-core", () => {
+ it("应从 manifest 中识别 GUI owner 场景", () => {
+ expect(collectGuiScenarioIds(manifest)).toEqual(["claw-chat-ready-streaming"]);
+ });
+
+ it("没有 active GUI sidecar 时应 pass", () => {
+ const report = createAgentQcGuiOwnerReport({
+ manifest,
+ statusSidecars: [
+ {
+ path: ".lime/qc/qcloop-status.contract.json",
+ status: {
+ job: { id: "job-1", status: "completed" },
+ verdict: { status: "complete" },
+ items: [
+ {
+ scenarioId: "command-bridge-contract",
+ qcloopStatus: "success",
+ terminal: true,
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ expect(report.verdict.status).toBe("pass");
+ expect(report.ownerCount).toBe(0);
+ });
+
+ it("active GUI sidecar 超过上限时应 blocked", () => {
+ const report = createAgentQcGuiOwnerReport({
+ manifest,
+ statusSidecars: [
+ {
+ path: ".lime/qc/qcloop-status.gui.json",
+ status: {
+ job: { id: "job-gui", status: "running" },
+ verdict: { status: "stale" },
+ counts: { running: 1, stale: 1 },
+ items: [
+ {
+ scenarioId: "claw-chat-ready-streaming",
+ qcloopStatus: "running",
+ evidenceStatus: "blocked",
+ terminal: false,
+ stale: true,
+ staleSeconds: 600,
+ worker: { status: "running", durationSeconds: 600 },
+ },
+ ],
+ },
+ },
+ ],
+ maxActiveOwners: 0,
+ });
+
+ expect(report.verdict.status).toBe("blocked");
+ expect(report.ownerCount).toBe(1);
+ expect(report.staleOwnerCount).toBe(1);
+ expect(report.oldestStaleSeconds).toBe(600);
+ expect(report.verdict.nextAction).toContain("只读观察");
+ expect(report.ownerIntervention?.status).toBe("requires_owner_confirmation");
+ expect(report.ownerIntervention?.requiredConfirmationText).toContain("job-gui");
+ expect(report.ownerIntervention?.prohibitedUntilConfirmed).toContain(
+ "start another full GUI P0 batch",
+ );
+ expect(report.activeOwners[0].activeItems[0].scenarioId).toBe(
+ "claw-chat-ready-streaming",
+ );
+ });
+
+ it("同一 job 的多个 sidecar 应去重", () => {
+ const sidecar = {
+ job: { id: "job-gui", status: "running" },
+ verdict: { status: "running" },
+ items: [
+ {
+ scenarioId: "claw-chat-ready-streaming",
+ qcloopStatus: "running",
+ terminal: false,
+ },
+ ],
+ };
+
+ const report = createAgentQcGuiOwnerReport({
+ manifest,
+ statusSidecars: [
+ { path: ".lime/qc/qcloop-status.gui-current.json", status: sidecar },
+ { path: ".lime/qc/qcloop-status.gui-stale.json", status: sidecar },
+ ],
+ });
+
+ expect(report.ownerCount).toBe(1);
+ });
+
+ it("同一 job 存在终态 sidecar 时应忽略旧 running sidecar", () => {
+ const runningSidecar = {
+ job: { id: "job-gui", status: "running", terminal: false },
+ verdict: { status: "running" },
+ items: [
+ {
+ scenarioId: "claw-chat-ready-streaming",
+ qcloopStatus: "running",
+ terminal: false,
+ },
+ ],
+ };
+ const terminalSidecar = {
+ job: { id: "job-gui", status: "failed", terminal: true },
+ verdict: { status: "fail" },
+ items: [
+ {
+ scenarioId: "claw-chat-ready-streaming",
+ qcloopStatus: "exhausted",
+ terminal: true,
+ },
+ ],
+ };
+
+ const report = createAgentQcGuiOwnerReport({
+ manifest,
+ statusSidecars: [
+ { path: ".lime/qc/qcloop-status.gui-current.json", status: runningSidecar },
+ { path: ".lime/qc/qcloop-status.gui-completed.json", status: terminalSidecar },
+ ],
+ });
+
+ expect(report.ownerCount).toBe(0);
+ expect(report.ownerIntervention).toBeNull();
+ expect(report.verdict.status).toBe("pass");
+ });
+
+ it("应渲染 summary", () => {
+ const report = createAgentQcGuiOwnerReport({ manifest, statusSidecars: [] });
+ const summary = renderAgentQcGuiOwnerSummary(report);
+
+ expect(summary).toContain("status=pass");
+ expect(summary).toContain("staleOwners=0");
+ expect(summary).toContain("guiScenarios=claw-chat-ready-streaming");
+ });
+
+ it("应生成 watch history JSONL entry", () => {
+ const report = createAgentQcGuiOwnerReport({
+ manifest,
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ statusSidecars: [
+ {
+ path: ".lime/qc/qcloop-status.gui.json",
+ status: {
+ job: { id: "job-gui", status: "running" },
+ verdict: { status: "stale" },
+ items: [
+ {
+ scenarioId: "claw-chat-ready-streaming",
+ qcloopStatus: "running",
+ evidenceStatus: "blocked",
+ terminal: false,
+ stale: true,
+ staleSeconds: 900,
+ worker: { status: "running", durationSeconds: 900 },
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ const entry = createAgentQcGuiOwnerWatchEntry(report);
+
+ expect(entry.observedAt).toBe("2026-05-10T00:00:00.000Z");
+ expect(entry.verdictStatus).toBe("blocked");
+ expect(entry.activeOwners[0].jobId).toBe("job-gui");
+ expect(entry.activeOwners[0].activeItems[0].staleSeconds).toBe(900);
+ });
+});
diff --git a/scripts/lib/agent-qc-qcloop-job-core.mjs b/scripts/lib/agent-qc-qcloop-job-core.mjs
new file mode 100644
index 000000000..70f37955e
--- /dev/null
+++ b/scripts/lib/agent-qc-qcloop-job-core.mjs
@@ -0,0 +1,273 @@
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function isNonEmptyString(value) {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function normalizeRiskList(risks) {
+ return asArray(risks)
+ .map((risk) => String(risk).trim().toUpperCase())
+ .filter(Boolean);
+}
+
+function selectScenarios(manifest, { risks = ["P0"], scenarioIds = [], includeAll = false } = {}) {
+ const normalizedRisks = new Set(normalizeRiskList(risks));
+ const normalizedScenarioIds = new Set(asArray(scenarioIds).filter(isNonEmptyString));
+ const scenarios = asArray(manifest?.scenarios);
+
+ return scenarios.filter((scenario) => {
+ if (includeAll) {
+ return true;
+ }
+ if (normalizedScenarioIds.size > 0) {
+ return normalizedScenarioIds.has(scenario.id);
+ }
+ return normalizedRisks.has(String(scenario.risk || "").toUpperCase());
+ });
+}
+
+const WORKER_RESULT_MARKER = "QCLOOP_WORKER_RESULT=";
+const WORKER_EVIDENCE_SUMMARY_MARKER = "QCLOOP_EVIDENCE_SUMMARY_JSON=";
+
+const WORKER_EVIDENCE_CONTRACT_BLOCK = `
+
+---
+Agent QC 结构化证据输出契约:
+- 最终 stdout 必须包含单行 \`QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED\`。
+- 最终 stdout 必须包含单行 \`QCLOOP_EVIDENCE_SUMMARY_JSON=\`;\`\` 必须是单个 JSON object,不要 Markdown / code fence。
+- \`QCLOOP_EVIDENCE_SUMMARY_JSON\` 至少包含:scenario_id、result、commands[]、evidence_required[]、evidence_layers_covered[]、failure_modes[]、artifacts[]、blockers[]、gui_session_owner、release_scope。
+- evidence_required[] 每项包含 name、status(pass|fail|blocked|missing)、evidence、artifact_path;failure_modes[] 每项包含 name、status(covered|excluded|hit|unknown)、evidence。
+- 不得输出密钥、token、用户私密内容或未经脱敏的请求 / 响应正文。`;
+
+const VERIFIER_JSON_CONTRACT_BLOCK = `
+
+---
+qcloop verifier 输出格式硬约束:
+- 只输出一个合法 JSON object,不要 Markdown、不要代码块、不要前后缀文本。
+- JSON 必须至少包含 {"pass": true|false, "feedback": "..."}。
+- 可选字段 missingEvidence、nextAction、evidenceStatus、scenarioId 必须仍在同一个 JSON object 内。
+- 如果 worker stdout 缺少 QCLOOP_EVIDENCE_SUMMARY_JSON=,或该 JSON 无法逐项证明 evidenceRequired / failureModes,必须输出 {"pass": false, "feedback": "..."}。`;
+
+function ensureWorkerEvidenceContract(template) {
+ const normalized = isNonEmptyString(template)
+ ? template
+ : "在 Lime 仓库中执行 Agent QC 场景 {{item}},收集证据并给出结论。";
+ if (
+ normalized.includes(WORKER_RESULT_MARKER) &&
+ normalized.includes(WORKER_EVIDENCE_SUMMARY_MARKER)
+ ) {
+ return normalized;
+ }
+ return `${normalized}${WORKER_EVIDENCE_CONTRACT_BLOCK}`;
+}
+
+const VERIFIER_EVIDENCE_BLOCK = `
+
+---
+qcloop worker 执行证据(自动占位符替换):
+- attempt_status: {{attempt_status}}
+- attempt_type: {{attempt_type}}
+- exit_code: {{exit_code}}
+
+worker stdout:
+{{stdout}}
+
+qcloop issue ledger:
+{{issue_ledger}}
+
+判定要求:如果 stdout 缺少场景 verifier / evidence_required / failure_modes 所需证据,必须 pass=false;不能因为命令名看似正确就通过。`;
+
+function ensureVerifierMetadataPlaceholders(template) {
+ const missingLines = [];
+ if (!template.includes("{{attempt_status}}")) {
+ missingLines.push("- attempt_status: {{attempt_status}}");
+ }
+ if (!template.includes("{{attempt_type}}")) {
+ missingLines.push("- attempt_type: {{attempt_type}}");
+ }
+ if (!template.includes("{{exit_code}}")) {
+ missingLines.push("- exit_code: {{exit_code}}");
+ }
+ if (!template.includes("{{qc_history}}") && !template.includes("{{issue_ledger}}")) {
+ missingLines.push("- issue_ledger: {{issue_ledger}}");
+ }
+ if (missingLines.length === 0) {
+ return template;
+ }
+ return `${template}
+
+---
+qcloop worker 执行元数据(自动占位符替换):
+${missingLines.join("\n")}`;
+}
+
+function ensureVerifierEvidencePlaceholders(template) {
+ let normalized = isNonEmptyString(template)
+ ? template
+ : "审查 Agent QC 场景 {{item}} 的输出是否满足证据要求,只输出 JSON verdict。";
+
+ if (!normalized.includes("{{stdout}}") && !normalized.includes("{{output}}")) {
+ normalized = `${normalized}${VERIFIER_EVIDENCE_BLOCK}`;
+ }
+
+ normalized = ensureVerifierMetadataPlaceholders(normalized);
+
+ if (!normalized.includes("QCLOOP_EVIDENCE_SUMMARY_JSON")) {
+ normalized = `${normalized}${VERIFIER_JSON_CONTRACT_BLOCK}`;
+ } else if (!normalized.includes('{"pass": true|false')) {
+ normalized = `${normalized}${VERIFIER_JSON_CONTRACT_BLOCK}`;
+ }
+
+ return normalized;
+}
+
+function buildQCLoopItemValue(scenario, { cwd = "" } = {}) {
+ return JSON.stringify({
+ scenario_id: scenario.id,
+ title: scenario.title,
+ risk: scenario.risk,
+ cwd,
+ executor: scenario.executor,
+ lanes: scenario.lanes,
+ commands: scenario.commands || [],
+ goal: scenario.goal,
+ verifier: scenario.verifier,
+ evidence_required: scenario.evidenceRequired || [],
+ evidence_layers: scenario.evidenceLayers || [],
+ failure_modes: scenario.failureModes || [],
+ });
+}
+
+function buildQCLoopJobPayload(manifest, options = {}) {
+ const selectedScenarios = selectScenarios(manifest, options);
+ const qcloop = manifest?.qcloop || {};
+ const riskLabel = options.includeAll
+ ? "all"
+ : asArray(options.scenarioIds).length > 0
+ ? "selected"
+ : normalizeRiskList(options.risks || ["P0"]).join("+") || "P0";
+ const name =
+ options.name ||
+ `lime-agent-qc-${riskLabel.toLowerCase()}-${new Date(options.generatedAt || Date.now()).toISOString().slice(0, 10)}`;
+
+ const cwdLine = options.cwd
+ ? `目标仓库 cwd: ${options.cwd}。执行任何命令前必须先切换到该目录,并在输出中记录 pwd。`
+ : "";
+ const workerPromptTemplate = ensureWorkerEvidenceContract(
+ options.promptTemplate ||
+ qcloop.workerPromptTemplate ||
+ "在 Lime 仓库中执行 Agent QC 场景 {{item}},收集证据并给出结论。",
+ );
+
+ return {
+ name,
+ prompt_template: [cwdLine, workerPromptTemplate].filter(Boolean).join("\n\n"),
+ verifier_prompt_template: ensureVerifierEvidencePlaceholders(
+ options.verifierPromptTemplate || qcloop.verifierPromptTemplate,
+ ),
+ max_qc_rounds: Number(options.maxQcRounds || qcloop.recommendedMaxQcRounds || 3),
+ max_executor_retries: Number(
+ options.maxExecutorRetries ?? qcloop.recommendedMaxExecutorRetries ?? 1,
+ ),
+ token_budget_per_item: Number(options.tokenBudgetPerItem || 0),
+ execution_mode: options.executionMode || "standard",
+ executor_provider: options.executorProvider || "codex",
+ items: selectedScenarios.map((scenario) =>
+ buildQCLoopItemValue(scenario, { cwd: options.cwd || "" }),
+ ),
+ };
+}
+
+
+function validateVerifierPromptTemplate(template, issues) {
+ if (!isNonEmptyString(template)) {
+ issues.push("缺少 verifier_prompt_template。");
+ return;
+ }
+ if (!template.includes("{{stdout}}") && !template.includes("{{output}}")) {
+ issues.push("verifier_prompt_template 必须包含 {{stdout}} 或 {{output}},否则 verifier 看不到 worker 输出。");
+ }
+ for (const placeholder of ["{{attempt_status}}", "{{exit_code}}"]) {
+ if (!template.includes(placeholder)) {
+ issues.push(`verifier_prompt_template 必须包含 ${placeholder}。`);
+ }
+ }
+ if (!template.includes("{{qc_history}}") && !template.includes("{{issue_ledger}}")) {
+ issues.push("verifier_prompt_template 必须包含 {{qc_history}} 或 {{issue_ledger}}。");
+ }
+ if (!template.includes(WORKER_EVIDENCE_SUMMARY_MARKER)) {
+ issues.push(`verifier_prompt_template 必须要求审查 ${WORKER_EVIDENCE_SUMMARY_MARKER}。`);
+ }
+ if (!template.includes('{"pass": true|false')) {
+ issues.push('verifier_prompt_template 必须声明只输出 {"pass": true|false, "feedback": "..."} JSON。');
+ }
+}
+
+function validateQCLoopJobPayload(payload) {
+ const issues = [];
+ if (!isNonEmptyString(payload?.name)) {
+ issues.push("缺少 name。 ");
+ }
+ if (!isNonEmptyString(payload?.prompt_template)) {
+ issues.push("缺少 prompt_template。 ");
+ } else {
+ if (!payload.prompt_template.includes("{{item}}")) {
+ issues.push("prompt_template 必须包含 {{item}}。 ");
+ }
+ if (!payload.prompt_template.includes(WORKER_RESULT_MARKER)) {
+ issues.push(`prompt_template 必须要求输出 ${WORKER_RESULT_MARKER}。 `);
+ }
+ if (!payload.prompt_template.includes(WORKER_EVIDENCE_SUMMARY_MARKER)) {
+ issues.push(`prompt_template 必须要求输出 ${WORKER_EVIDENCE_SUMMARY_MARKER}。 `);
+ }
+ }
+ validateVerifierPromptTemplate(payload?.verifier_prompt_template, issues);
+ if (!Number.isInteger(payload?.max_qc_rounds) || payload.max_qc_rounds <= 0) {
+ issues.push("max_qc_rounds 必须是正整数。 ");
+ }
+ if (
+ !Number.isInteger(payload?.max_executor_retries) ||
+ payload.max_executor_retries < 0 ||
+ payload.max_executor_retries > 5
+ ) {
+ issues.push("max_executor_retries 必须是 0 到 5 的整数。 ");
+ }
+ if (!Array.isArray(payload?.items) || payload.items.length === 0) {
+ issues.push("items 不能为空。 ");
+ }
+ for (const [index, item] of asArray(payload?.items).entries()) {
+ if (!isNonEmptyString(item)) {
+ issues.push(`items[${index}] 不能为空。`);
+ continue;
+ }
+ try {
+ const parsed = JSON.parse(item);
+ if (!isNonEmptyString(parsed.scenario_id)) {
+ issues.push(`items[${index}] 缺少 scenario_id。`);
+ }
+ } catch {
+ issues.push(`items[${index}] 必须是 JSON 字符串。`);
+ }
+ }
+ return {
+ valid: issues.length === 0,
+ issues,
+ };
+}
+
+function renderQCLoopCurl(payload, { baseUrl = "http://127.0.0.1:8080" } = {}) {
+ const body = JSON.stringify(payload, null, 2);
+ return `curl -sS -X POST "${baseUrl.replace(/\/$/, "")}/api/jobs" \\
+ -H "Content-Type: application/json" \\
+ --data-binary @- <<'JSON'\n${body}\nJSON\n`;
+}
+
+export {
+ buildQCLoopItemValue,
+ buildQCLoopJobPayload,
+ renderQCLoopCurl,
+ selectScenarios,
+ validateQCLoopJobPayload,
+};
diff --git a/scripts/lib/agent-qc-qcloop-job-core.test.ts b/scripts/lib/agent-qc-qcloop-job-core.test.ts
new file mode 100644
index 000000000..bc6e1f2eb
--- /dev/null
+++ b/scripts/lib/agent-qc-qcloop-job-core.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildQCLoopJobPayload,
+ renderQCLoopCurl,
+ selectScenarios,
+ validateQCLoopJobPayload,
+} from "./agent-qc-qcloop-job-core.mjs";
+
+const manifest = {
+ qcloop: {
+ recommendedMaxQcRounds: 3,
+ workerPromptTemplate: "worker {{item}}",
+ verifierPromptTemplate: "verifier {{item}}",
+ },
+ scenarios: [
+ {
+ id: "command-bridge-contract",
+ title: "命令桥接四侧一致",
+ risk: "P0",
+ executor: "npm_command",
+ lanes: ["L1-contract-bridge"],
+ commands: ["npm run test:contracts"],
+ goal: "goal",
+ verifier: "verifier",
+ evidenceRequired: ["evidence"],
+ evidenceLayers: ["deterministic-smoke"],
+ failureModes: ["failure"],
+ },
+ {
+ id: "knowledge-ingest-retrieve-summarize",
+ title: "Knowledge",
+ risk: "P1",
+ executor: "mixed",
+ lanes: ["L3-product-surface"],
+ goal: "goal",
+ verifier: "verifier",
+ evidenceRequired: ["evidence"],
+ failureModes: ["failure"],
+ },
+ ],
+};
+
+describe("agent-qc-qcloop-job-core", () => {
+ it("默认应选择 P0 场景", () => {
+ const scenarios = selectScenarios(manifest);
+
+ expect(scenarios.map((scenario) => scenario.id)).toEqual(["command-bridge-contract"]);
+ });
+
+ it("应按风险等级生成 qcloop job payload", () => {
+ const payload = buildQCLoopJobPayload(manifest, {
+ risks: ["P1"],
+ cwd: "/workspace/lime",
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ maxExecutorRetries: 0,
+ });
+
+ expect(payload.name).toBe("lime-agent-qc-p1-2026-05-10");
+ expect(payload.prompt_template).toContain("目标仓库 cwd: /workspace/lime");
+ expect(payload.prompt_template).toContain("QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED");
+ expect(payload.prompt_template).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON=");
+ expect(payload.items).toHaveLength(1);
+ expect(payload.max_executor_retries).toBe(0);
+ expect(payload.verifier_prompt_template).toContain("{{stdout}}");
+ expect(payload.verifier_prompt_template).toContain("{{exit_code}}");
+ expect(payload.verifier_prompt_template).toContain("{{issue_ledger}}");
+ expect(payload.verifier_prompt_template).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON");
+ expect(payload.verifier_prompt_template).toContain('{"pass": true|false');
+ expect(JSON.parse(payload.items[0]).scenario_id).toBe(
+ "knowledge-ingest-retrieve-summarize",
+ );
+ expect(JSON.parse(payload.items[0]).cwd).toBe("/workspace/lime");
+ expect(validateQCLoopJobPayload(payload).valid).toBe(true);
+ });
+
+ it("应把 evidenceLayers 带入 qcloop item,方便 worker 声明证据深度", () => {
+ const payload = buildQCLoopJobPayload(manifest, {
+ scenarioIds: ["command-bridge-contract"],
+ });
+
+ expect(JSON.parse(payload.items[0]).evidence_layers).toEqual([
+ "deterministic-smoke",
+ ]);
+ });
+
+ it("应支持按 scenario id 精确选择", () => {
+ const payload = buildQCLoopJobPayload(manifest, {
+ scenarioIds: ["command-bridge-contract"],
+ name: "selected-job",
+ });
+
+ expect(payload.name).toBe("selected-job");
+ expect(payload.items).toHaveLength(1);
+ expect(JSON.parse(payload.items[0]).scenario_id).toBe("command-bridge-contract");
+ });
+
+ it("validateQCLoopJobPayload 应阻止缺少 verifier stdout 占位符的 payload", () => {
+ const payload = buildQCLoopJobPayload(manifest);
+ payload.verifier_prompt_template = "verifier {{item}}";
+
+ const validation = validateQCLoopJobPayload(payload);
+
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.join("\n")).toContain("{{stdout}}");
+ });
+
+ it("validateQCLoopJobPayload 应阻止缺少结构化证据契约的 payload", () => {
+ const payload = buildQCLoopJobPayload(manifest);
+ payload.prompt_template = "worker {{item}}";
+ payload.verifier_prompt_template =
+ "verifier {{item}} status={{attempt_status}} code={{exit_code}} stdout={{stdout}} ledger={{issue_ledger}}";
+
+ const validation = validateQCLoopJobPayload(payload);
+
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.join("\n")).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON=");
+ });
+
+ it("validateQCLoopJobPayload 应阻止非法 max_executor_retries", () => {
+ const payload = buildQCLoopJobPayload(manifest);
+ payload.max_executor_retries = 6;
+
+ const validation = validateQCLoopJobPayload(payload);
+
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.join("\n")).toContain("max_executor_retries");
+ });
+
+ it("verifier_prompt_template 不应重复追加已有 stdout 占位符的模板", () => {
+ const payload = buildQCLoopJobPayload(
+ {
+ ...manifest,
+ qcloop: {
+ ...manifest.qcloop,
+ verifierPromptTemplate: "verifier {{item}} stdout={{stdout}}",
+ },
+ },
+ { scenarioIds: ["command-bridge-contract"] },
+ );
+
+ expect(payload.verifier_prompt_template.match(/{{stdout}}/g)).toHaveLength(1);
+ });
+
+ it("应生成可复制的 curl 命令", () => {
+ const payload = buildQCLoopJobPayload(manifest);
+ const curl = renderQCLoopCurl(payload, { baseUrl: "http://localhost:8080" });
+
+ expect(curl).toContain("POST \"http://localhost:8080/api/jobs\"");
+ expect(curl).toContain("command-bridge-contract");
+ });
+
+ it("默认 curl 应使用 IPv4 loopback,避免 localhost 代理或 IPv6 解析干扰", () => {
+ const payload = buildQCLoopJobPayload(manifest);
+ const curl = renderQCLoopCurl(payload);
+
+ expect(curl).toContain("POST \"http://127.0.0.1:8080/api/jobs\"");
+ });
+});
diff --git a/scripts/lib/agent-qc-qcloop-preflight-core.mjs b/scripts/lib/agent-qc-qcloop-preflight-core.mjs
new file mode 100644
index 000000000..03a3c083f
--- /dev/null
+++ b/scripts/lib/agent-qc-qcloop-preflight-core.mjs
@@ -0,0 +1,69 @@
+function isNonEmptyString(value) {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function normalizePathText(value) {
+ return isNonEmptyString(value) ? value.trim().replace(/\\/g, "/") : "";
+}
+
+function createCheck(id, title, passed, detail = "") {
+ return {
+ id,
+ title,
+ passed: Boolean(passed),
+ detail,
+ };
+}
+
+function buildQCLoopPreflightReport({ cwd, expectedCwd = "", tmpWritable = false, devBridge = null } = {}) {
+ const checks = [];
+ const normalizedCwd = normalizePathText(cwd);
+ const normalizedExpectedCwd = normalizePathText(expectedCwd);
+
+ checks.push(createCheck("cwd-present", "当前工作目录可读", Boolean(normalizedCwd), normalizedCwd));
+ if (normalizedExpectedCwd) {
+ checks.push(
+ createCheck(
+ "cwd-expected",
+ "当前工作目录匹配预期仓库",
+ normalizedCwd === normalizedExpectedCwd,
+ `cwd=${normalizedCwd || "unknown"} expected=${normalizedExpectedCwd}`,
+ ),
+ );
+ }
+ checks.push(
+ createCheck(
+ "tmp-writable",
+ "临时目录可写",
+ tmpWritable === true,
+ tmpWritable ? "tmp write ok" : "tmp write failed",
+ ),
+ );
+
+ if (devBridge) {
+ checks.push(
+ createCheck(
+ "devbridge-health",
+ "DevBridge health 可访问",
+ devBridge.ok === true,
+ devBridge.ok
+ ? `status=${devBridge.status || "ok"}`
+ : `url=${devBridge.url || "unknown"} error=${devBridge.error || "unknown"}`,
+ ),
+ );
+ }
+
+ const failed = checks.filter((check) => !check.passed);
+ return {
+ schemaVersion: "v1",
+ status: failed.length === 0 ? "pass" : "blocked",
+ checks,
+ failedChecks: failed.map((check) => check.id),
+ summary:
+ failed.length === 0
+ ? "qcloop worker preflight 通过。"
+ : `qcloop worker preflight 阻断:${failed.map((check) => check.id).join(", ")}。`,
+ };
+}
+
+export { buildQCLoopPreflightReport, createCheck };
diff --git a/scripts/lib/agent-qc-qcloop-preflight-core.test.ts b/scripts/lib/agent-qc-qcloop-preflight-core.test.ts
new file mode 100644
index 000000000..ac69f28ed
--- /dev/null
+++ b/scripts/lib/agent-qc-qcloop-preflight-core.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import { buildQCLoopPreflightReport } from "./agent-qc-qcloop-preflight-core.mjs";
+
+describe("agent-qc-qcloop-preflight-core", () => {
+ it("基础环境通过时应返回 pass", () => {
+ const report = buildQCLoopPreflightReport({
+ cwd: "/repo/lime",
+ expectedCwd: "/repo/lime",
+ tmpWritable: true,
+ });
+
+ expect(report.status).toBe("pass");
+ expect(report.failedChecks).toEqual([]);
+ });
+
+ it("cwd 不匹配时应返回 blocked", () => {
+ const report = buildQCLoopPreflightReport({
+ cwd: "/repo/qcloop",
+ expectedCwd: "/repo/lime",
+ tmpWritable: true,
+ });
+
+ expect(report.status).toBe("blocked");
+ expect(report.failedChecks).toContain("cwd-expected");
+ });
+
+ it("DevBridge 不可访问时应返回 blocked", () => {
+ const report = buildQCLoopPreflightReport({
+ cwd: "/repo/lime",
+ tmpWritable: true,
+ devBridge: {
+ ok: false,
+ url: "http://127.0.0.1:3030/health",
+ error: "TypeError: fetch failed",
+ },
+ });
+
+ expect(report.status).toBe("blocked");
+ expect(report.failedChecks).toContain("devbridge-health");
+ expect(report.checks.find((check) => check.id === "devbridge-health")?.detail).toContain(
+ "fetch failed",
+ );
+ });
+});
diff --git a/scripts/lib/agent-qc-qcloop-status-core.mjs b/scripts/lib/agent-qc-qcloop-status-core.mjs
new file mode 100644
index 000000000..b92783d97
--- /dev/null
+++ b/scripts/lib/agent-qc-qcloop-status-core.mjs
@@ -0,0 +1,289 @@
+import { mapQCLoopItemStatus, parseScenarioId } from "./agent-qc-evidence-core.mjs";
+
+const TERMINAL_ITEM_STATUSES = new Set(["success", "failed", "exhausted"]);
+const TERMINAL_JOB_STATUSES = new Set(["completed", "failed", "canceled", "cancelled"]);
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function isNonEmptyString(value) {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function normalizeStatus(value) {
+ return isNonEmptyString(value) ? value.trim().toLowerCase() : "unknown";
+}
+
+function parseTimestampMs(value) {
+ if (!isNonEmptyString(value)) {
+ return null;
+ }
+ const timestamp = Date.parse(value);
+ return Number.isFinite(timestamp) ? timestamp : null;
+}
+
+function stringLength(value) {
+ return typeof value === "string" ? value.length : 0;
+}
+
+function durationMinutes(startedAt, finishedAt, nowMs) {
+ const startedMs = parseTimestampMs(startedAt);
+ if (startedMs === null) {
+ return null;
+ }
+ const finishedMs = parseTimestampMs(finishedAt) ?? nowMs;
+ return Math.max(0, Math.round((finishedMs - startedMs) / 60000));
+}
+
+function durationSeconds(startedAt, finishedAt, nowMs) {
+ const startedMs = parseTimestampMs(startedAt);
+ if (startedMs === null) {
+ return null;
+ }
+ const finishedMs = parseTimestampMs(finishedAt) ?? nowMs;
+ return Math.max(0, Math.round((finishedMs - startedMs) / 1000));
+}
+
+function latestEntry(entries) {
+ const normalizedEntries = asArray(entries).filter(Boolean);
+ return normalizedEntries.length > 0 ? normalizedEntries[normalizedEntries.length - 1] : null;
+}
+
+function summarizeLatestWorker(item, nowMs) {
+ const latestAttempt = latestEntry(item?.attempts);
+ if (!latestAttempt) {
+ return {
+ status: "unknown",
+ exitCode: null,
+ startedAt: null,
+ finishedAt: null,
+ durationMinutes: null,
+ durationSeconds: null,
+ stdoutLength: 0,
+ stderrLength: 0,
+ outputLength: 0,
+ };
+ }
+
+ const stdoutLength = stringLength(latestAttempt.stdout);
+ const stderrLength = stringLength(latestAttempt.stderr);
+ return {
+ status: normalizeStatus(latestAttempt.status),
+ exitCode: latestAttempt.exit_code ?? null,
+ startedAt: latestAttempt.started_at ?? null,
+ finishedAt: latestAttempt.finished_at ?? null,
+ durationMinutes: durationMinutes(latestAttempt.started_at, latestAttempt.finished_at, nowMs),
+ durationSeconds: durationSeconds(latestAttempt.started_at, latestAttempt.finished_at, nowMs),
+ stdoutLength,
+ stderrLength,
+ outputLength: stdoutLength + stderrLength,
+ };
+}
+
+function summarizeLatestQc(item) {
+ const latestQc = latestEntry(item?.qc_rounds);
+ if (!latestQc) {
+ return {
+ status: "unknown",
+ feedback: "",
+ };
+ }
+ return {
+ status: normalizeStatus(latestQc.status),
+ feedback: latestQc.feedback || "",
+ };
+}
+
+function classifyItemStaleness(item, worker, { staleMinutes = 30 } = {}) {
+ const itemStatus = normalizeStatus(item?.status);
+ const workerStatus = worker.status;
+ const isRunning = itemStatus === "running" || workerStatus === "running";
+ const isTerminal = TERMINAL_ITEM_STATUSES.has(itemStatus);
+ const reasons = [];
+
+ if (!isRunning || isTerminal || staleMinutes <= 0) {
+ return { stale: false, reasons };
+ }
+
+ if (worker.startedAt === null) {
+ reasons.push("running item 缺少 worker started_at,无法判断进度");
+ }
+
+ if (worker.durationMinutes !== null && worker.durationMinutes >= staleMinutes && worker.outputLength === 0) {
+ reasons.push(`worker 运行 ${worker.durationMinutes} 分钟且 stdout/stderr 为空`);
+ }
+
+ return {
+ stale: reasons.length > 0,
+ reasons,
+ };
+}
+
+function summarizeItem(item, options = {}) {
+ const nowMs = options.nowMs ?? Date.now();
+ const worker = summarizeLatestWorker(item, nowMs);
+ const qc = summarizeLatestQc(item);
+ const itemStatus = normalizeStatus(item?.status);
+ const terminal = TERMINAL_ITEM_STATUSES.has(itemStatus);
+ const staleness = classifyItemStaleness(item, worker, options);
+
+ return {
+ scenarioId: parseScenarioId(item?.item_value),
+ itemId: item?.id || "",
+ qcloopStatus: itemStatus,
+ evidenceStatus: mapQCLoopItemStatus(item?.status, item),
+ terminal,
+ stale: staleness.stale,
+ staleReasons: staleness.reasons,
+ staleSeconds: staleness.stale ? worker.durationSeconds : null,
+ currentAttemptNo: item?.current_attempt_no ?? 0,
+ currentQcNo: item?.current_qc_no ?? 0,
+ worker,
+ qc,
+ };
+}
+
+function countByStatus(items) {
+ const counts = {
+ total: items.length,
+ success: 0,
+ failed: 0,
+ exhausted: 0,
+ running: 0,
+ pending: 0,
+ unknown: 0,
+ terminal: 0,
+ nonTerminal: 0,
+ stale: 0,
+ };
+
+ for (const item of items) {
+ const status = item.qcloopStatus;
+ if (status in counts) {
+ counts[status] += 1;
+ } else {
+ counts.unknown += 1;
+ }
+ if (item.terminal) {
+ counts.terminal += 1;
+ } else {
+ counts.nonTerminal += 1;
+ }
+ if (item.stale) {
+ counts.stale += 1;
+ }
+ }
+
+ return counts;
+}
+
+function buildVerdict(job, items, counts) {
+ const jobStatus = normalizeStatus(job?.status);
+ const terminalProblemItems = items.filter((item) => item.qcloopStatus === "failed" || item.qcloopStatus === "exhausted");
+ const terminalBlockedItems = terminalProblemItems.filter((item) => item.evidenceStatus === "blocked");
+ const terminalFailedItems = terminalProblemItems.filter((item) => item.evidenceStatus === "fail");
+ if (counts.stale > 0) {
+ return {
+ status: "stale",
+ summary: `qcloop job ${job?.id || "unknown"} 仍在运行,${counts.stale} 个 item 疑似无进度。`,
+ nextAction: "不要中断进程;先导出 sidecar、记录卡点,等当前 worker 结束后再提交修正后的重跑 payload。",
+ };
+ }
+ if (counts.running > 0 || counts.pending > 0 || !TERMINAL_JOB_STATUSES.has(jobStatus)) {
+ return {
+ status: "running",
+ summary: `qcloop job ${job?.id || "unknown"} 尚未进入终态,running=${counts.running} pending=${counts.pending}。`,
+ nextAction: "继续观察 job/items;如果需要证据,只导出 sidecar,不覆盖官方 Evidence Pack。",
+ };
+ }
+ if (terminalFailedItems.length > 0 || (jobStatus === "failed" && terminalBlockedItems.length === 0)) {
+ return {
+ status: "fail",
+ summary: `qcloop job ${job?.id || "unknown"} 已终止但仍有 failed/exhausted item。`,
+ nextAction: "修复失败项或补足 verifier 可审查证据后,发起新的 qcloop 批次。",
+ };
+ }
+ if (terminalBlockedItems.length > 0) {
+ return {
+ status: "blocked",
+ summary: `qcloop job ${job?.id || "unknown"} 已终止但 ${terminalBlockedItems.length} 个 item 明确报告 worker 环境阻断。`,
+ nextAction: "先修复 qcloop worker 环境或权限,再用新批次重跑被阻断场景;不要覆盖官方 Evidence Pack。",
+ };
+ }
+ if (items.length > 0 && counts.success === items.length && jobStatus === "completed") {
+ return {
+ status: "complete",
+ summary: `qcloop job ${job?.id || "unknown"} 已完成,全部 item success。`,
+ nextAction: "可导出官方 Evidence Pack,并运行 release summary 与 completion audit。",
+ };
+ }
+ return {
+ status: "needs-human-review",
+ summary: `qcloop job ${job?.id || "unknown"} 状态无法自动归类。`,
+ nextAction: "人工审查 qcloop job/items 原始 JSON,再决定是否重跑或补 exporter 规则。",
+ };
+}
+
+function buildQCLoopStatusReport({ job, items, options = {} }) {
+ const generatedAt = options.generatedAt || new Date().toISOString();
+ const nowMs = parseTimestampMs(generatedAt) ?? Date.now();
+ const parsedStaleMinutes = Number(options.staleMinutes ?? 30);
+ const staleMinutes = Number.isFinite(parsedStaleMinutes) ? parsedStaleMinutes : 30;
+ const itemSummaries = asArray(items).map((item) =>
+ summarizeItem(item, {
+ nowMs,
+ staleMinutes,
+ }),
+ );
+ const counts = countByStatus(itemSummaries);
+ const verdict = buildVerdict(job, itemSummaries, counts);
+
+ return {
+ schemaVersion: "v1",
+ generatedAt,
+ staleMinutes,
+ job: {
+ id: job?.id || "unknown",
+ name: job?.name || "",
+ status: normalizeStatus(job?.status),
+ createdAt: job?.created_at || null,
+ finishedAt: job?.finished_at || null,
+ terminal: TERMINAL_JOB_STATUSES.has(normalizeStatus(job?.status)),
+ },
+ counts,
+ items: itemSummaries,
+ verdict,
+ };
+}
+
+function validateQCLoopStatusReport(report) {
+ const issues = [];
+ if (report?.schemaVersion !== "v1") {
+ issues.push("schemaVersion 必须是 v1。");
+ }
+ if (!report?.job || !isNonEmptyString(report.job.id)) {
+ issues.push("缺少 job.id。");
+ }
+ if (!Array.isArray(report?.items)) {
+ issues.push("items 必须是数组。");
+ }
+ if (!report?.counts || typeof report.counts.total !== "number") {
+ issues.push("缺少 counts.total。");
+ }
+ if (!report?.verdict || !isNonEmptyString(report.verdict.status)) {
+ issues.push("缺少 verdict.status。");
+ }
+ return {
+ valid: issues.length === 0,
+ issues,
+ };
+}
+
+export {
+ buildQCLoopStatusReport,
+ classifyItemStaleness,
+ normalizeStatus,
+ summarizeItem,
+ validateQCLoopStatusReport,
+};
diff --git a/scripts/lib/agent-qc-qcloop-status-core.test.ts b/scripts/lib/agent-qc-qcloop-status-core.test.ts
new file mode 100644
index 000000000..2efda88bb
--- /dev/null
+++ b/scripts/lib/agent-qc-qcloop-status-core.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildQCLoopStatusReport,
+ classifyItemStaleness,
+ validateQCLoopStatusReport,
+} from "./agent-qc-qcloop-status-core.mjs";
+
+const generatedAt = "2026-05-10T07:00:00.000Z";
+
+describe("agent-qc-qcloop-status-core", () => {
+ it("应把长时间无输出的 running item 标记为 stale", () => {
+ const report = buildQCLoopStatusReport({
+ job: { id: "job-1", name: "p0", status: "running" },
+ items: [
+ {
+ id: "item-1",
+ item_value: '{"scenario_id":"skill-forge-register-bind-enable"}',
+ status: "running",
+ current_attempt_no: 1,
+ current_qc_no: 0,
+ attempts: [
+ {
+ id: "attempt-1",
+ status: "running",
+ started_at: "2026-05-10T06:00:00.000Z",
+ stdout: "",
+ stderr: "",
+ },
+ ],
+ qc_rounds: [],
+ },
+ ],
+ options: { generatedAt, staleMinutes: 30 },
+ });
+
+ expect(report.verdict.status).toBe("stale");
+ expect(report.counts.stale).toBe(1);
+ expect(report.items[0].staleSeconds).toBe(3600);
+ expect(report.items[0].worker.durationSeconds).toBe(3600);
+ expect(report.items[0].staleReasons.join("\n")).toContain("stdout/stderr 为空");
+ expect(validateQCLoopStatusReport(report).valid).toBe(true);
+ });
+
+ it("有输出的 running item 不应仅因运行时间长被标记为 stale", () => {
+ const result = classifyItemStaleness(
+ { status: "running" },
+ {
+ status: "running",
+ startedAt: "2026-05-10T06:00:00.000Z",
+ durationMinutes: 60,
+ outputLength: 120,
+ },
+ { staleMinutes: 30 },
+ );
+
+ expect(result.stale).toBe(false);
+ });
+
+ it("全部 success 且 job completed 时应给出 complete verdict", () => {
+ const report = buildQCLoopStatusReport({
+ job: { id: "job-2", name: "p0", status: "completed" },
+ items: [
+ {
+ id: "item-2",
+ item_value: "command-bridge-contract",
+ status: "success",
+ current_attempt_no: 1,
+ current_qc_no: 1,
+ attempts: [
+ {
+ id: "attempt-2",
+ status: "success",
+ started_at: "2026-05-10T06:00:00.000Z",
+ finished_at: "2026-05-10T06:01:00.000Z",
+ stdout: "ok",
+ stderr: "",
+ },
+ ],
+ qc_rounds: [{ id: "qc-2", status: "pass", feedback: "ok" }],
+ },
+ ],
+ options: { generatedAt, staleMinutes: 30 },
+ });
+
+ expect(report.verdict.status).toBe("complete");
+ expect(report.counts.success).toBe(1);
+ });
+
+ it("failed/exhausted item 应保持 fail verdict", () => {
+ const report = buildQCLoopStatusReport({
+ job: { id: "job-3", name: "p0", status: "completed" },
+ items: [
+ {
+ id: "item-3",
+ item_value: "tool-approval-sandbox-boundary",
+ status: "exhausted",
+ current_attempt_no: 1,
+ current_qc_no: 1,
+ attempts: [{ id: "attempt-3", status: "failed", stdout: "", stderr: "error" }],
+ qc_rounds: [{ id: "qc-3", status: "fail", feedback: "缺少证据" }],
+ },
+ ],
+ options: { generatedAt, staleMinutes: 30 },
+ });
+
+ expect(report.verdict.status).toBe("fail");
+ expect(report.counts.exhausted).toBe(1);
+ expect(report.items[0].qc.feedback).toBe("缺少证据");
+ });
+
+ it("worker 明确报告 BLOCKED 的 exhausted item 应保持环境阻断语义", () => {
+ const report = buildQCLoopStatusReport({
+ job: { id: "job-4", name: "p0", status: "completed" },
+ items: [
+ {
+ id: "item-4",
+ item_value: "claw-chat-ready-streaming",
+ status: "exhausted",
+ current_attempt_no: 3,
+ current_qc_no: 3,
+ attempts: [
+ {
+ id: "attempt-4",
+ status: "success",
+ stdout: "QCLOOP_WORKER_RESULT=BLOCKED\nDevBridge preflight: BLOCKED",
+ stderr: "",
+ },
+ ],
+ qc_rounds: [{ id: "qc-4", status: "fail", feedback: "DevBridge preflight blocked" }],
+ },
+ ],
+ options: { generatedAt, staleMinutes: 30 },
+ });
+
+ expect(report.verdict.status).toBe("blocked");
+ expect(report.counts.exhausted).toBe(1);
+ expect(report.items[0].evidenceStatus).toBe("blocked");
+ });
+
+ it("内层 Codex CLI 环境错误应保持环境阻断语义", () => {
+ const report = buildQCLoopStatusReport({
+ job: { id: "job-5", name: "p0", status: "failed" },
+ items: [
+ {
+ id: "item-5",
+ item_value: "command-bridge-contract",
+ status: "failed",
+ current_attempt_no: 1,
+ current_qc_no: 1,
+ attempts: [
+ {
+ id: "attempt-5",
+ status: "failed",
+ stderr:
+ "QCLOOP_CODEX_BIN 不可用: /opt/homebrew/bin/codex: fork/exec /opt/homebrew/bin/codex: no such file or directory",
+ },
+ ],
+ qc_rounds: [{ id: "qc-5", status: "fail", feedback: "verifier 输出格式错误" }],
+ },
+ ],
+ options: { generatedAt, staleMinutes: 30 },
+ });
+
+ expect(report.verdict.status).toBe("blocked");
+ expect(report.items[0].evidenceStatus).toBe("blocked");
+ });
+});
diff --git a/scripts/lib/agent-qc-release-summary-core.mjs b/scripts/lib/agent-qc-release-summary-core.mjs
new file mode 100644
index 000000000..d0de1b45e
--- /dev/null
+++ b/scripts/lib/agent-qc-release-summary-core.mjs
@@ -0,0 +1,194 @@
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function isNonEmptyString(value) {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function countByStatus(items, getStatus = (item) => item?.status) {
+ const counts = new Map();
+ for (const item of asArray(items)) {
+ const status = getStatus(item) || "unknown";
+ counts.set(status, (counts.get(status) ?? 0) + 1);
+ }
+ return Object.fromEntries(Array.from(counts.entries()).sort(([left], [right]) => left.localeCompare(right)));
+}
+
+function summarizeEvidencePack(pack, sourcePath = "") {
+ const scenarioResults = asArray(pack?.scenarioResults);
+ const laneResults = asArray(pack?.laneResults);
+ return {
+ sourcePath,
+ runId: pack?.runId || "unknown",
+ generatedAt: pack?.generatedAt || "",
+ status: pack?.verdict?.status || "unknown",
+ summary: pack?.verdict?.summary || "",
+ scenarioCount: scenarioResults.length,
+ scenarioIds: scenarioResults
+ .map((scenario) => String(scenario?.scenarioId || "").trim())
+ .filter(Boolean)
+ .sort(),
+ scenarioStatusCounts: countByStatus(scenarioResults),
+ laneStatusCounts: countByStatus(laneResults),
+ blockers: asArray(pack?.verdict?.blockers).filter(isNonEmptyString),
+ waivers: asArray(pack?.verdict?.waivers),
+ };
+}
+
+function normalizeScenarioIds(scenarioIds) {
+ return Array.from(
+ new Set(asArray(scenarioIds).map((scenarioId) => String(scenarioId || "").trim()).filter(Boolean)),
+ ).sort();
+}
+
+function hasStructuredEvidenceRef(scenario) {
+ return asArray(scenario?.evidenceRefs).some((ref) => {
+ const normalized = String(ref || "").trim();
+ return normalized.length > 0 && !normalized.startsWith("qcloop:");
+ });
+}
+
+function summarizeHarnessReport(summaryReport, trendReport) {
+ return {
+ summaryGeneratedAt: summaryReport?.generatedAt || "",
+ trendGeneratedAt: trendReport?.generatedAt || "",
+ readyCount: summaryReport?.totals?.readyCount ?? null,
+ invalidCount: summaryReport?.totals?.invalidCount ?? null,
+ trendSampleCount: trendReport?.sampleCount ?? null,
+ signals: asArray(trendReport?.signals).filter(isNonEmptyString),
+ };
+}
+
+function buildAgentQcReleaseSummary({
+ evidencePacks = [],
+ harnessSummary = null,
+ harnessTrend = null,
+ requiredScenarioIds = [],
+ tag = "",
+} = {}) {
+ const evidence = asArray(evidencePacks).map((entry) => summarizeEvidencePack(entry.pack, entry.sourcePath));
+ const statusCounts = countByStatus(evidence);
+ const blockers = evidence.flatMap((entry) => entry.blockers.map((blocker) => `${entry.runId}: ${blocker}`));
+ const waiverCount = evidence.reduce((sum, entry) => sum + entry.waivers.length, 0);
+ const hasFailingEvidence = evidence.some((entry) => entry.status !== "pass");
+ const weakEvidenceScenarioIds = normalizeScenarioIds(
+ asArray(evidencePacks).flatMap((entry) =>
+ asArray(entry?.pack?.scenarioResults)
+ .filter((scenario) => scenario?.status === "pass" && !hasStructuredEvidenceRef(scenario))
+ .map((scenario) => scenario?.scenarioId),
+ ),
+ );
+ const structuredEvidenceBlockers = weakEvidenceScenarioIds.map(
+ (scenarioId) =>
+ `structured-evidence:${scenarioId}: pass scenario 缺少非 qcloop evidenceRefs,不能只凭 qcloop item id 作为发布证据。`,
+ );
+ const hasWeakStructuredEvidence = weakEvidenceScenarioIds.length > 0;
+ const status =
+ evidence.length === 0 ? "blocked" : hasFailingEvidence || hasWeakStructuredEvidence ? "fail" : "pass";
+ const coveredScenarioIds = normalizeScenarioIds(evidence.flatMap((entry) => entry.scenarioIds));
+ const normalizedRequiredScenarioIds = normalizeScenarioIds(requiredScenarioIds);
+ const coveredScenarioIdSet = new Set(coveredScenarioIds);
+ const missingRequiredScenarioIds = normalizedRequiredScenarioIds.filter(
+ (scenarioId) => !coveredScenarioIdSet.has(scenarioId),
+ );
+
+ return {
+ tag,
+ status,
+ evidenceCount: evidence.length,
+ statusCounts,
+ waiverCount,
+ blockers: [...blockers, ...structuredEvidenceBlockers],
+ coveredScenarioIds,
+ evidence,
+ harness: summarizeHarnessReport(harnessSummary, harnessTrend),
+ missingRequiredScenarioIds,
+ requiredScenarioIds: normalizedRequiredScenarioIds,
+ weakEvidenceScenarioIds,
+ };
+}
+
+function renderStatusCounts(counts) {
+ const entries = Object.entries(counts || {});
+ if (entries.length === 0) {
+ return "无";
+ }
+ return entries.map(([status, count]) => `${status}: ${count}`).join(" / ");
+}
+
+function renderAgentQcReleaseMarkdown(summary) {
+ const evidenceLines = summary.evidence.length
+ ? summary.evidence
+ .map(
+ (entry) =>
+ `- ${entry.runId}: ${entry.status};场景 ${entry.scenarioCount};${renderStatusCounts(entry.scenarioStatusCounts)}${entry.sourcePath ? `;source ${entry.sourcePath}` : ""}`,
+ )
+ .join("\n")
+ : "- 无 Evidence Pack;发布应视为 blocked,除非有明确 waiver。";
+
+ const blockerLines = summary.blockers.length
+ ? summary.blockers.map((blocker) => `- ${blocker}`).join("\n")
+ : "- 无";
+
+ const harnessLines = [
+ summary.harness.readyCount !== null ? `- Harness ready: ${summary.harness.readyCount}` : "- Harness ready: 未提供",
+ summary.harness.invalidCount !== null ? `- Harness invalid: ${summary.harness.invalidCount}` : "- Harness invalid: 未提供",
+ summary.harness.trendSampleCount !== null ? `- Harness trend samples: ${summary.harness.trendSampleCount}` : "- Harness trend samples: 未提供",
+ ...summary.harness.signals.map((signal) => `- ${signal}`),
+ ].join("\n");
+
+ return `## Agent QC Evidence${summary.tag ? ` (${summary.tag})` : ""}
+
+- Verdict: ${summary.status}
+- Evidence packs: ${summary.evidenceCount}
+- Evidence status: ${renderStatusCounts(summary.statusCounts)}
+- Scenario coverage: ${summary.requiredScenarioIds?.length ? `${summary.coveredScenarioIds.length}/${summary.requiredScenarioIds.length}` : `${summary.coveredScenarioIds?.length || 0} covered`}
+- Waivers: ${summary.waiverCount}
+
+### Evidence Packs
+
+${evidenceLines}
+
+### Harness Trend
+
+${harnessLines}
+
+### Blockers
+
+${blockerLines}
+`;
+}
+
+function validateReleaseSummary(summary, { requireEvidence = true } = {}) {
+ const issues = [];
+ if (requireEvidence && summary.evidenceCount === 0) {
+ issues.push("缺少 Agent QC Evidence Pack。请先导出 qcloop evidence 或显式记录 waiver。");
+ }
+ if (summary.status !== "pass") {
+ issues.push(`Agent QC release summary 状态为 ${summary.status},不能作为绿色发布证据。`);
+ }
+ if (asArray(summary.missingRequiredScenarioIds).length > 0) {
+ issues.push(`Agent QC Evidence Pack 未覆盖必需场景:${summary.missingRequiredScenarioIds.join(", ")}。`);
+ }
+ if (asArray(summary.weakEvidenceScenarioIds).length > 0) {
+ issues.push(
+ `Agent QC Evidence Pack 存在缺少结构化 evidenceRefs 的 pass 场景:${summary.weakEvidenceScenarioIds.join(", ")}。`,
+ );
+ }
+ return {
+ valid: issues.length === 0,
+ issues,
+ };
+}
+
+export {
+ buildAgentQcReleaseSummary,
+ countByStatus,
+ hasStructuredEvidenceRef,
+ normalizeScenarioIds,
+ renderAgentQcReleaseMarkdown,
+ summarizeEvidencePack,
+ summarizeHarnessReport,
+ validateReleaseSummary,
+};
diff --git a/scripts/lib/agent-qc-release-summary-core.test.ts b/scripts/lib/agent-qc-release-summary-core.test.ts
new file mode 100644
index 000000000..2a02cbdcd
--- /dev/null
+++ b/scripts/lib/agent-qc-release-summary-core.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildAgentQcReleaseSummary,
+ renderAgentQcReleaseMarkdown,
+ validateReleaseSummary,
+} from "./agent-qc-release-summary-core.mjs";
+
+const passPack = {
+ runId: "run-pass",
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ verdict: { status: "pass", summary: "ok", blockers: [] },
+ laneResults: [{ laneId: "L4-behavior-eval", status: "pass" }],
+ scenarioResults: [
+ {
+ scenarioId: "command-bridge-contract",
+ status: "pass",
+ evidenceRefs: ["qcloop:item:item-1", ".lime/qc/contract-summary.json"],
+ },
+ {
+ scenarioId: "workspace-ready-session-restore",
+ status: "pass",
+ evidenceRefs: ["qcloop:item:item-2", ".lime/qc/gui-trace/workspace-ready.trace.zip"],
+ },
+ ],
+};
+
+const failPack = {
+ runId: "run-fail",
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ verdict: { status: "fail", summary: "bad", blockers: ["workspace: smoke failed"] },
+ laneResults: [{ laneId: "L4-behavior-eval", status: "fail" }],
+ scenarioResults: [{ scenarioId: "workspace-ready-session-restore", status: "fail", evidenceRefs: [] }],
+};
+
+describe("agent-qc-release-summary-core", () => {
+ it("应把全 pass Evidence Pack 汇总为 pass release summary", () => {
+ const summary = buildAgentQcReleaseSummary({
+ evidencePacks: [{ pack: passPack, sourcePath: "evidence.json" }],
+ harnessSummary: { generatedAt: "now", totals: { readyCount: 2, invalidCount: 0 } },
+ harnessTrend: { sampleCount: 3, signals: ["current gap 保持为 0。"] },
+ requiredScenarioIds: ["command-bridge-contract", "workspace-ready-session-restore"],
+ tag: "v1.2.3",
+ });
+
+ expect(summary.status).toBe("pass");
+ expect(summary.evidenceCount).toBe(1);
+ expect(summary.missingRequiredScenarioIds).toEqual([]);
+ expect(summary.harness.readyCount).toBe(2);
+ expect(validateReleaseSummary(summary).valid).toBe(true);
+ });
+
+ it("应把失败 Evidence Pack 汇总为 fail 并阻断 check", () => {
+ const summary = buildAgentQcReleaseSummary({
+ evidencePacks: [{ pack: failPack, sourcePath: "evidence.json" }],
+ });
+
+ expect(summary.status).toBe("fail");
+ expect(summary.blockers.join("\n")).toContain("workspace: smoke failed");
+ expect(validateReleaseSummary(summary).valid).toBe(false);
+ });
+
+ it("缺少 Evidence Pack 时默认 blocked", () => {
+ const summary = buildAgentQcReleaseSummary({ evidencePacks: [] });
+
+ expect(summary.status).toBe("blocked");
+ expect(validateReleaseSummary(summary).valid).toBe(false);
+ });
+
+ it("Evidence Pack pass 但缺必需 P0 场景时应阻断 release", () => {
+ const summary = buildAgentQcReleaseSummary({
+ evidencePacks: [{ pack: passPack, sourcePath: "evidence.json" }],
+ requiredScenarioIds: [
+ "command-bridge-contract",
+ "workspace-ready-session-restore",
+ "release-package-startup-smoke",
+ ],
+ });
+ const validation = validateReleaseSummary(summary);
+
+ expect(summary.status).toBe("pass");
+ expect(summary.missingRequiredScenarioIds).toEqual(["release-package-startup-smoke"]);
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.join("\n")).toContain("release-package-startup-smoke");
+ });
+
+ it("Evidence Pack pass 但 pass 场景只有 qcloop id 时应阻断 release", () => {
+ const weakPack = {
+ ...passPack,
+ scenarioResults: [
+ {
+ scenarioId: "command-bridge-contract",
+ status: "pass",
+ evidenceRefs: ["qcloop:item:item-1", "qcloop:attempt:attempt-1"],
+ },
+ ],
+ };
+ const summary = buildAgentQcReleaseSummary({
+ evidencePacks: [{ pack: weakPack, sourcePath: "evidence.json" }],
+ requiredScenarioIds: ["command-bridge-contract"],
+ });
+ const validation = validateReleaseSummary(summary);
+
+ expect(summary.status).toBe("fail");
+ expect(summary.weakEvidenceScenarioIds).toEqual(["command-bridge-contract"]);
+ expect(summary.blockers.join("\n")).toContain("structured-evidence:command-bridge-contract");
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.join("\n")).toContain("结构化 evidenceRefs");
+ });
+
+ it("应渲染 release note 可用的 Markdown", () => {
+ const summary = buildAgentQcReleaseSummary({
+ evidencePacks: [{ pack: passPack, sourcePath: "evidence.json" }],
+ requiredScenarioIds: ["command-bridge-contract", "workspace-ready-session-restore"],
+ tag: "v1.2.3",
+ });
+ const markdown = renderAgentQcReleaseMarkdown(summary);
+
+ expect(markdown).toContain("Agent QC Evidence (v1.2.3)");
+ expect(markdown).toContain("Verdict: pass");
+ expect(markdown).toContain("Scenario coverage: 2/2");
+ expect(markdown).toContain("run-pass");
+ });
+});
diff --git a/scripts/lib/agent-qc-report-core.mjs b/scripts/lib/agent-qc-report-core.mjs
new file mode 100644
index 000000000..8b745f255
--- /dev/null
+++ b/scripts/lib/agent-qc-report-core.mjs
@@ -0,0 +1,505 @@
+import fs from "node:fs";
+
+const VALID_LANE_IDS = new Set([
+ "L0-static-unit",
+ "L1-contract-bridge",
+ "L2-agent-runtime",
+ "L3-product-surface",
+ "L4-behavior-eval",
+ "L5-release-ops",
+]);
+const VALID_RISKS = new Set(["P0", "P1", "P2"]);
+const VALID_EXECUTORS = new Set([
+ "npm_command",
+ "rust_test",
+ "playwright_mcp",
+ "runtime_harness",
+ "qcloop",
+ "release_workflow",
+ "mixed",
+]);
+const VALID_STATUSES = new Set([
+ "pass",
+ "fail",
+ "blocked",
+ "needs-human-review",
+ "waived",
+ "skipped",
+]);
+const VALID_EVIDENCE_LAYERS = new Set([
+ "deterministic-smoke",
+ "gui-trace",
+ "runtime-transcript",
+ "release-artifact",
+]);
+
+function readJsonFile(filePath) {
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
+}
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function isNonEmptyString(value) {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function addIssue(issues, severity, path, message) {
+ issues.push({ severity, path, message });
+}
+
+function collectNpmScriptName(command) {
+ if (!isNonEmptyString(command)) {
+ return "";
+ }
+
+ const match = command.trim().match(/^npm\s+run\s+([^\s]+)/);
+ return match ? match[1] : "";
+}
+
+function validateNpmCommands(commands, packageScripts, pathPrefix, issues) {
+ for (const [index, command] of asArray(commands).entries()) {
+ if (!isNonEmptyString(command)) {
+ addIssue(issues, "error", `${pathPrefix}.commands[${index}]`, "命令必须是非空字符串。");
+ continue;
+ }
+
+ const scriptName = collectNpmScriptName(command);
+ if (scriptName && !Object.hasOwn(packageScripts, scriptName)) {
+ addIssue(
+ issues,
+ "error",
+ `${pathPrefix}.commands[${index}]`,
+ `package.json 中不存在 npm script: ${scriptName}`,
+ );
+ }
+ }
+}
+
+
+function validateQCLoopConfig(qcloop, issues) {
+ if (!qcloop || typeof qcloop !== "object") {
+ return;
+ }
+
+ const workerTemplate = qcloop.workerPromptTemplate;
+ if (!isNonEmptyString(workerTemplate)) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.workerPromptTemplate",
+ "qcloop workerPromptTemplate 必须存在,且要约束 worker 输出可审查证据。",
+ );
+ } else {
+ if (!workerTemplate.includes("evidence_required")) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.workerPromptTemplate",
+ "workerPromptTemplate 必须要求 worker 逐项列出 evidence_required,否则 repair 轮会再次缺证据。",
+ );
+ }
+
+ if (!workerTemplate.includes("failure_modes")) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.workerPromptTemplate",
+ "workerPromptTemplate 必须要求 worker 逐项说明 failure_modes 是否命中、覆盖或排除。",
+ );
+ }
+
+ if (!workerTemplate.includes("QCLOOP_WORKER_RESULT=BLOCKED")) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.workerPromptTemplate",
+ "workerPromptTemplate 必须显式约束环境阻断时输出 QCLOOP_WORKER_RESULT=BLOCKED。",
+ );
+ }
+
+ if (!workerTemplate.includes("QCLOOP_EVIDENCE_SUMMARY_JSON")) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.workerPromptTemplate",
+ "workerPromptTemplate 必须要求 worker 输出 QCLOOP_EVIDENCE_SUMMARY_JSON,避免 verifier 只能审查散文摘要。",
+ );
+ }
+ }
+
+ const template = qcloop.verifierPromptTemplate;
+ if (!isNonEmptyString(template)) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.verifierPromptTemplate",
+ "qcloop verifierPromptTemplate 必须存在,且要带 worker evidence 占位符。",
+ );
+ return;
+ }
+
+ if (!template.includes("{{stdout}}") && !template.includes("{{output}}")) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.verifierPromptTemplate",
+ "verifierPromptTemplate 必须包含 {{stdout}} 或 {{output}},否则 verifier 看不到 worker 输出。",
+ );
+ }
+
+ for (const placeholder of ["{{attempt_status}}", "{{exit_code}}"]) {
+ if (!template.includes(placeholder)) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.verifierPromptTemplate",
+ `verifierPromptTemplate 必须包含 ${placeholder},用于判定 worker 执行状态。`,
+ );
+ }
+ }
+
+ if (!template.includes("{{qc_history}}") && !template.includes("{{issue_ledger}}")) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.verifierPromptTemplate",
+ "verifierPromptTemplate 必须包含 {{qc_history}} 或 {{issue_ledger}},用于判断 repair 后旧问题是否仍开放。",
+ );
+ }
+
+ if (!template.includes("QCLOOP_EVIDENCE_SUMMARY_JSON")) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.verifierPromptTemplate",
+ "verifierPromptTemplate 必须要求审查 QCLOOP_EVIDENCE_SUMMARY_JSON,避免命令退出码被误判为证据完整。",
+ );
+ }
+
+ if (!template.includes('{"pass": true|false')) {
+ addIssue(
+ issues,
+ "error",
+ "qcloop.verifierPromptTemplate",
+ 'verifierPromptTemplate 必须声明只输出 {"pass": true|false, "feedback": "..."} JSON,避免 qcloop 报“verifier 输出格式错误”。',
+ );
+ }
+
+ if (template.includes("{{stderr}}")) {
+ addIssue(
+ issues,
+ "warn",
+ "qcloop.verifierPromptTemplate",
+ "verifierPromptTemplate 包含 {{stderr}},Codex / GUI 场景可能产生超长 stderr;优先让 worker 在 stdout 输出摘要。",
+ );
+ }
+}
+
+function validateEvidenceSchema(schema, issues) {
+ if (!schema || typeof schema !== "object") {
+ addIssue(issues, "error", "evidenceSchema", "Evidence schema 必须是 JSON object。");
+ return;
+ }
+
+ for (const field of ["$schema", "$id", "type", "required", "properties"]) {
+ if (!Object.hasOwn(schema, field)) {
+ addIssue(issues, "error", `evidenceSchema.${field}`, `缺少 ${field}。`);
+ }
+ }
+
+ const required = asArray(schema.required);
+ for (const field of ["schemaVersion", "runId", "generatedAt", "subject", "laneResults", "scenarioResults", "verdict"]) {
+ if (!required.includes(field)) {
+ addIssue(issues, "error", "evidenceSchema.required", `缺少必填字段 ${field}。`);
+ }
+ }
+
+ const verdictStatus = schema?.properties?.verdict?.properties?.status?.enum;
+ if (Array.isArray(verdictStatus)) {
+ for (const status of ["pass", "fail", "blocked", "needs-human-review", "waived"]) {
+ if (!verdictStatus.includes(status)) {
+ addIssue(issues, "error", "evidenceSchema.verdict.status", `verdict.status 缺少 ${status}。`);
+ }
+ }
+ }
+}
+
+function validateLane(lane, index, packageScripts, issues) {
+ const pathPrefix = `lanes[${index}]`;
+ if (!isNonEmptyString(lane?.id)) {
+ addIssue(issues, "error", `${pathPrefix}.id`, "Lane 必须有 id。");
+ return;
+ }
+
+ if (!VALID_LANE_IDS.has(lane.id)) {
+ addIssue(issues, "warn", `${pathPrefix}.id`, `Lane id ${lane.id} 不在当前标准集合中。`);
+ }
+
+ for (const field of ["title", "objective", "gatePolicy"]) {
+ if (!isNonEmptyString(lane[field])) {
+ addIssue(issues, "error", `${pathPrefix}.${field}`, `Lane 缺少 ${field}。`);
+ }
+ }
+
+ validateNpmCommands(lane.defaultCommands, packageScripts, pathPrefix, issues);
+}
+
+function validateScenario(scenario, index, laneIds, packageScripts, issues) {
+ const pathPrefix = `scenarios[${index}]`;
+ for (const field of ["id", "title", "risk", "executor", "goal", "verifier"]) {
+ if (!isNonEmptyString(scenario?.[field])) {
+ addIssue(issues, "error", `${pathPrefix}.${field}`, `Scenario 缺少 ${field}。`);
+ }
+ }
+
+ if (isNonEmptyString(scenario?.risk) && !VALID_RISKS.has(scenario.risk)) {
+ addIssue(issues, "error", `${pathPrefix}.risk`, `未知风险等级 ${scenario.risk}。`);
+ }
+
+ if (isNonEmptyString(scenario?.executor) && !VALID_EXECUTORS.has(scenario.executor)) {
+ addIssue(issues, "error", `${pathPrefix}.executor`, `未知执行器 ${scenario.executor}。`);
+ }
+
+ const lanes = asArray(scenario?.lanes);
+ if (lanes.length === 0) {
+ addIssue(issues, "error", `${pathPrefix}.lanes`, "Scenario 至少要归属一个 lane。");
+ }
+ for (const laneId of lanes) {
+ if (!laneIds.has(laneId)) {
+ addIssue(issues, "error", `${pathPrefix}.lanes`, `引用了不存在的 lane: ${laneId}`);
+ }
+ }
+
+ if (asArray(scenario?.evidenceRequired).length === 0) {
+ addIssue(issues, "error", `${pathPrefix}.evidenceRequired`, "Scenario 必须声明证据要求。");
+ }
+
+ if (asArray(scenario?.failureModes).length === 0) {
+ addIssue(issues, "error", `${pathPrefix}.failureModes`, "Scenario 必须声明失败模式。");
+ }
+
+ const evidenceLayers = asArray(scenario?.evidenceLayers);
+ if (scenario?.risk === "P0" && evidenceLayers.length === 0) {
+ addIssue(
+ issues,
+ "error",
+ `${pathPrefix}.evidenceLayers`,
+ "P0 Scenario 必须声明 evidenceLayers,避免 deterministic smoke 被误读成 deep evidence。",
+ );
+ }
+ for (const [layerIndex, layer] of evidenceLayers.entries()) {
+ if (!VALID_EVIDENCE_LAYERS.has(layer)) {
+ addIssue(
+ issues,
+ "error",
+ `${pathPrefix}.evidenceLayers[${layerIndex}]`,
+ `未知 evidence layer: ${layer}`,
+ );
+ }
+ }
+
+ validateNpmCommands(scenario?.commands, packageScripts, pathPrefix, issues);
+}
+
+function validateAgentQcManifest(manifest, { packageScripts = {}, evidenceSchema = null } = {}) {
+ const issues = [];
+ if (!manifest || typeof manifest !== "object") {
+ return {
+ valid: false,
+ issues: [{ severity: "error", path: "manifest", message: "Manifest 必须是 JSON object。" }],
+ };
+ }
+
+ for (const field of ["manifestVersion", "title", "evidenceSchema", "lanes", "scenarios"]) {
+ if (!Object.hasOwn(manifest, field)) {
+ addIssue(issues, "error", field, `Manifest 缺少 ${field}。`);
+ }
+ }
+
+ if (manifest.manifestVersion !== "v1") {
+ addIssue(issues, "error", "manifestVersion", "当前只接受 manifestVersion=v1。");
+ }
+
+ validateQCLoopConfig(manifest.qcloop, issues);
+
+ const lanes = asArray(manifest.lanes);
+ const scenarios = asArray(manifest.scenarios);
+ const laneIds = new Set();
+ const scenarioIds = new Set();
+
+ if (lanes.length === 0) {
+ addIssue(issues, "error", "lanes", "至少需要一个测试 lane。");
+ }
+
+ for (const [index, lane] of lanes.entries()) {
+ validateLane(lane, index, packageScripts, issues);
+ if (isNonEmptyString(lane?.id)) {
+ if (laneIds.has(lane.id)) {
+ addIssue(issues, "error", `lanes[${index}].id`, `重复 lane id: ${lane.id}`);
+ }
+ laneIds.add(lane.id);
+ }
+ }
+
+ if (scenarios.length === 0) {
+ addIssue(issues, "error", "scenarios", "至少需要一个 Agent QC 场景。");
+ }
+
+ for (const [index, scenario] of scenarios.entries()) {
+ validateScenario(scenario, index, laneIds, packageScripts, issues);
+ if (isNonEmptyString(scenario?.id)) {
+ if (scenarioIds.has(scenario.id)) {
+ addIssue(issues, "error", `scenarios[${index}].id`, `重复 scenario id: ${scenario.id}`);
+ }
+ scenarioIds.add(scenario.id);
+ }
+ }
+
+ if (evidenceSchema) {
+ validateEvidenceSchema(evidenceSchema, issues);
+ }
+
+ const errors = issues.filter((issue) => issue.severity === "error");
+ return {
+ valid: errors.length === 0,
+ issues,
+ };
+}
+
+function countBy(items, getKey) {
+ const counts = new Map();
+ for (const item of items) {
+ const key = getKey(item);
+ counts.set(key, (counts.get(key) ?? 0) + 1);
+ }
+ return Array.from(counts.entries())
+ .map(([name, count]) => ({ name, count }))
+ .sort((left, right) => left.name.localeCompare(right.name));
+}
+
+function summarizeAgentQcManifest(manifest, validation) {
+ const lanes = asArray(manifest?.lanes);
+ const scenarios = asArray(manifest?.scenarios);
+ return {
+ title: manifest?.title ?? "Lime Agent QC",
+ manifestVersion: manifest?.manifestVersion ?? "unknown",
+ evidenceSchema: manifest?.evidenceSchema ?? "",
+ valid: validation.valid,
+ issueCount: validation.issues.length,
+ errorCount: validation.issues.filter((issue) => issue.severity === "error").length,
+ warnCount: validation.issues.filter((issue) => issue.severity === "warn").length,
+ laneCount: lanes.length,
+ scenarioCount: scenarios.length,
+ p0ScenarioCount: scenarios.filter((scenario) => scenario.risk === "P0").length,
+ lanes: lanes.map((lane) => ({
+ id: lane.id,
+ title: lane.title,
+ defaultCommandCount: asArray(lane.defaultCommands).length,
+ scenarioCount: scenarios.filter((scenario) => asArray(scenario.lanes).includes(lane.id)).length,
+ })),
+ scenarios: scenarios.map((scenario) => ({
+ id: scenario.id,
+ title: scenario.title,
+ risk: scenario.risk,
+ executor: scenario.executor,
+ lanes: asArray(scenario.lanes),
+ })),
+ riskBreakdown: countBy(scenarios, (scenario) => scenario.risk || "unknown"),
+ executorBreakdown: countBy(scenarios, (scenario) => scenario.executor || "unknown"),
+ issues: validation.issues,
+ };
+}
+
+function renderIssueList(issues) {
+ if (issues.length === 0) {
+ return "- 无";
+ }
+
+ return issues
+ .map((issue) => `- ${issue.severity.toUpperCase()} ${issue.path}: ${issue.message}`)
+ .join("\n");
+}
+
+function renderNameCountList(items) {
+ if (items.length === 0) {
+ return "- 无";
+ }
+
+ return items.map((item) => `- ${item.name}: ${item.count}`).join("\n");
+}
+
+function renderAgentQcMarkdownReport(summary) {
+ const laneLines = summary.lanes.length
+ ? summary.lanes
+ .map(
+ (lane) =>
+ `- ${lane.id}: ${lane.title};场景 ${lane.scenarioCount};默认命令 ${lane.defaultCommandCount}`,
+ )
+ .join("\n")
+ : "- 无";
+ const scenarioLines = summary.scenarios.length
+ ? summary.scenarios
+ .map(
+ (scenario) =>
+ `- ${scenario.id}: ${scenario.title};${scenario.risk};${scenario.executor}`,
+ )
+ .join("\n")
+ : "- 无";
+
+ return `# ${summary.title} 场景报告
+
+## 结论
+
+- Manifest: ${summary.manifestVersion}
+- Evidence schema: ${summary.evidenceSchema || "未声明"}
+- 状态: ${summary.valid ? "PASS" : "FAIL"}
+- Lane 数: ${summary.laneCount}
+- Scenario 数: ${summary.scenarioCount}
+- P0 Scenario 数: ${summary.p0ScenarioCount}
+- Issue: ${summary.issueCount}(error ${summary.errorCount} / warn ${summary.warnCount})
+
+## Lane 覆盖
+
+${laneLines}
+
+## Scenario 清单
+
+${scenarioLines}
+
+## 风险分布
+
+${renderNameCountList(summary.riskBreakdown)}
+
+## 执行器分布
+
+${renderNameCountList(summary.executorBreakdown)}
+
+## 校验问题
+
+${renderIssueList(summary.issues)}
+`;
+}
+
+function createAgentQcReport({ manifest, packageJson = {}, evidenceSchema = null }) {
+ const validation = validateAgentQcManifest(manifest, {
+ packageScripts: packageJson.scripts ?? {},
+ evidenceSchema,
+ });
+ return summarizeAgentQcManifest(manifest, validation);
+}
+
+export {
+ VALID_EVIDENCE_LAYERS,
+ VALID_EXECUTORS,
+ VALID_LANE_IDS,
+ VALID_RISKS,
+ VALID_STATUSES,
+ collectNpmScriptName,
+ createAgentQcReport,
+ readJsonFile,
+ renderAgentQcMarkdownReport,
+ summarizeAgentQcManifest,
+ validateAgentQcManifest,
+};
diff --git a/scripts/lib/agent-qc-report-core.test.ts b/scripts/lib/agent-qc-report-core.test.ts
new file mode 100644
index 000000000..5c375cbcf
--- /dev/null
+++ b/scripts/lib/agent-qc-report-core.test.ts
@@ -0,0 +1,226 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ collectNpmScriptName,
+ createAgentQcReport,
+ renderAgentQcMarkdownReport,
+ validateAgentQcManifest,
+} from "./agent-qc-report-core.mjs";
+
+const packageJson = {
+ scripts: {
+ "verify:local": "node scripts/local-ci.mjs",
+ "test:contracts": "node scripts/check-command-contracts.mjs",
+ "verify:gui-smoke": "node scripts/verify-gui-smoke.mjs",
+ },
+};
+
+const evidenceSchema = {
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ $id: "https://lime.local/agent-qc-evidence.schema.json",
+ type: "object",
+ required: [
+ "schemaVersion",
+ "runId",
+ "generatedAt",
+ "subject",
+ "laneResults",
+ "scenarioResults",
+ "verdict",
+ ],
+ properties: {
+ verdict: {
+ type: "object",
+ properties: {
+ status: {
+ enum: ["pass", "fail", "blocked", "needs-human-review", "waived"],
+ },
+ },
+ },
+ },
+};
+
+const validManifest = {
+ manifestVersion: "v1",
+ title: "Lime Agent QC",
+ evidenceSchema: "docs/test/agent-qc-evidence.schema.json",
+ qcloop: {
+ workerPromptTemplate:
+ "worker {{item}} evidence_required failure_modes QCLOOP_WORKER_RESULT=BLOCKED QCLOOP_EVIDENCE_SUMMARY_JSON",
+ verifierPromptTemplate:
+ 'verifier {{item}} status={{attempt_status}} code={{exit_code}} stdout={{stdout}} ledger={{issue_ledger}} QCLOOP_EVIDENCE_SUMMARY_JSON,只输出 {"pass": true|false, "feedback": "..."} JSON',
+ },
+ lanes: [
+ {
+ id: "L0-static-unit",
+ title: "快速卫生",
+ objective: "验证低级错误不会进入主链。",
+ gatePolicy: "所有 PR 默认执行。",
+ defaultCommands: ["npm run verify:local"],
+ },
+ {
+ id: "L1-contract-bridge",
+ title: "契约桥接",
+ objective: "验证命令和 Bridge 合同不漂移。",
+ gatePolicy: "命令和 Bridge 改动必跑。",
+ defaultCommands: ["npm run test:contracts"],
+ },
+ ],
+ scenarios: [
+ {
+ id: "command-bridge-contract",
+ title: "命令桥接合同",
+ risk: "P0",
+ executor: "npm_command",
+ lanes: ["L1-contract-bridge"],
+ commands: ["npm run test:contracts"],
+ goal: "前端调用、Rust 注册、治理目录册、mock 四侧一致。",
+ verifier: "test:contracts 成功且没有新增 dead/compat 回流。",
+ evidenceRequired: ["command log", "contract summary"],
+ evidenceLayers: ["deterministic-smoke"],
+ failureModes: ["missing rust handler", "mock fallback drift"],
+ },
+ ],
+};
+
+describe("agent-qc-report-core", () => {
+ it("能从 npm run 命令中提取 script 名称", () => {
+ expect(collectNpmScriptName("npm run verify:gui-smoke -- --reuse-running")).toBe(
+ "verify:gui-smoke",
+ );
+ expect(collectNpmScriptName("node scripts/foo.mjs")).toBe("");
+ });
+
+ it("应接受完整的 Agent QC manifest 和 evidence schema", () => {
+ const result = validateAgentQcManifest(validManifest, {
+ packageScripts: packageJson.scripts,
+ evidenceSchema,
+ });
+
+ expect(result.valid).toBe(true);
+ expect(result.issues.filter((issue) => issue.severity === "error")).toEqual([]);
+ });
+
+ it("应阻止 qcloop verifier 缺少 worker stdout 占位符", () => {
+ const manifest = structuredClone(validManifest);
+ manifest.qcloop.verifierPromptTemplate =
+ "verifier {{item}} status={{attempt_status}} code={{exit_code}}";
+
+ const result = validateAgentQcManifest(manifest, {
+ packageScripts: packageJson.scripts,
+ evidenceSchema,
+ });
+
+ expect(result.valid).toBe(false);
+ expect(result.issues.map((issue) => issue.message).join("\n")).toContain(
+ "{{stdout}}",
+ );
+ });
+
+ it("应阻止 qcloop worker 缺少 evidence_required / failure_modes / BLOCKED / 结构化证据约束", () => {
+ const manifest = structuredClone(validManifest);
+ manifest.qcloop.workerPromptTemplate = "worker {{item}}";
+
+ const result = validateAgentQcManifest(manifest, {
+ packageScripts: packageJson.scripts,
+ evidenceSchema,
+ });
+
+ expect(result.valid).toBe(false);
+ const messages = result.issues.map((issue) => issue.message).join("\n");
+ expect(messages).toContain("evidence_required");
+ expect(messages).toContain("failure_modes");
+ expect(messages).toContain("QCLOOP_WORKER_RESULT=BLOCKED");
+ expect(messages).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON");
+ });
+
+ it("应阻止 qcloop verifier 缺少 attempt 状态占位符", () => {
+ const manifest = structuredClone(validManifest);
+ manifest.qcloop.verifierPromptTemplate = "verifier {{item}} stdout={{stdout}}";
+
+ const result = validateAgentQcManifest(manifest, {
+ packageScripts: packageJson.scripts,
+ evidenceSchema,
+ });
+
+ expect(result.valid).toBe(false);
+ expect(result.issues.map((issue) => issue.message).join("\n")).toContain(
+ "{{attempt_status}}",
+ );
+ });
+
+ it("应阻止 qcloop verifier 缺少 issue ledger 或结构化证据审查契约", () => {
+ const manifest = structuredClone(validManifest);
+ manifest.qcloop.verifierPromptTemplate =
+ "verifier {{item}} status={{attempt_status}} code={{exit_code}} stdout={{stdout}}";
+
+ const result = validateAgentQcManifest(manifest, {
+ packageScripts: packageJson.scripts,
+ evidenceSchema,
+ });
+
+ expect(result.valid).toBe(false);
+ const messages = result.issues.map((issue) => issue.message).join("\n");
+ expect(messages).toContain("{{qc_history}}");
+ expect(messages).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON");
+ expect(messages).toContain('{"pass": true|false');
+ });
+
+ it("应阻止引用不存在的 npm script", () => {
+ const manifest = structuredClone(validManifest);
+ manifest.scenarios[0].commands = ["npm run missing-script"];
+
+ const result = validateAgentQcManifest(manifest, {
+ packageScripts: packageJson.scripts,
+ evidenceSchema,
+ });
+
+ expect(result.valid).toBe(false);
+ expect(result.issues.map((issue) => issue.message).join("\n")).toContain(
+ "missing-script",
+ );
+ });
+
+ it("应阻止 P0 场景缺少 evidenceLayers", () => {
+ const manifest = structuredClone(validManifest);
+ delete manifest.scenarios[0].evidenceLayers;
+
+ const result = validateAgentQcManifest(manifest, {
+ packageScripts: packageJson.scripts,
+ evidenceSchema,
+ });
+
+ expect(result.valid).toBe(false);
+ expect(result.issues.map((issue) => issue.message).join("\n")).toContain(
+ "evidenceLayers",
+ );
+ });
+
+ it("应阻止未知 evidence layer", () => {
+ const manifest = structuredClone(validManifest);
+ manifest.scenarios[0].evidenceLayers = ["deterministic-smoke", "unknown-layer"];
+
+ const result = validateAgentQcManifest(manifest, {
+ packageScripts: packageJson.scripts,
+ evidenceSchema,
+ });
+
+ expect(result.valid).toBe(false);
+ expect(result.issues.map((issue) => issue.message).join("\n")).toContain(
+ "unknown-layer",
+ );
+ });
+
+ it("应把报告渲染成可读 Markdown", () => {
+ const report = createAgentQcReport({
+ manifest: validManifest,
+ packageJson,
+ evidenceSchema,
+ });
+ const markdown = renderAgentQcMarkdownReport(report);
+
+ expect(markdown).toContain("Lime Agent QC 场景报告");
+ expect(markdown).toContain("状态: PASS");
+ expect(markdown).toContain("command-bridge-contract");
+ });
+});
diff --git a/scripts/lib/agent-runtime-approval-sandbox-smoke-core.mjs b/scripts/lib/agent-runtime-approval-sandbox-smoke-core.mjs
new file mode 100644
index 000000000..062a3f55d
--- /dev/null
+++ b/scripts/lib/agent-runtime-approval-sandbox-smoke-core.mjs
@@ -0,0 +1,313 @@
+function createTranscriptSteps() {
+ return [
+ {
+ id: "tool-request",
+ category: "tool request",
+ eventTypes: ["tool.started", "tool.args"],
+ summary:
+ "允许工具请求会投影为 tool.started/tool.args,保留 tool id、tool name 与输入参数摘要。",
+ details: {
+ toolCallId: "tool-1",
+ toolName: "read_file",
+ phase: "acting",
+ inputAvailable: true,
+ inputSummary: '{"path":"README.md"}',
+ },
+ sourceRefs: [
+ "src/components/agent/chat/projection/agentUiEventProjection.test.ts:676",
+ ],
+ },
+ {
+ id: "approval-decision",
+ category: "approval decision",
+ eventTypes: ["run.status", "permission.changed"],
+ summary:
+ "需要授权时会先进入 permission_review,并通过 permission.changed 暴露 decision、askProfileKeys 与 confirmation request。",
+ details: {
+ phase: "waiting",
+ permissionStatus: "requires_confirmation",
+ confirmationStatus: "not_requested",
+ confirmationRequestId: "approval-1",
+ decisionSource: "runtime",
+ decisionScope: "turn",
+ askProfileKeys: ["read_files"],
+ requiredProfileKeys: ["read_files", "write_artifacts"],
+ },
+ sourceRefs: [
+ "src/components/agent/chat/projection/agentUiEventProjection.test.ts:85",
+ "src/components/agent/chat/components/AgentThreadTimeline.test.tsx:904",
+ "src/components/agent/chat/components/MessageList.test.tsx:1845",
+ ],
+ },
+ {
+ id: "sandbox-policy",
+ category: "sandbox policy",
+ eventTypes: ["permission.changed"],
+ summary:
+ "turn_context 会透传 approvalPolicy 与 sandboxPolicy,确保 runtime policy 不会在 UI 证据链中丢失。",
+ details: {
+ phase: "preparing",
+ approvalPolicy: "on-request",
+ sandboxPolicy: "workspace-write",
+ sourceEvent: "turn_context",
+ },
+ sourceRefs: [
+ "src/components/agent/chat/projection/agentUiEventProjection.test.ts:1480",
+ "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts:1",
+ ],
+ },
+ {
+ id: "tool-result",
+ category: "result",
+ eventTypes: ["tool.result"],
+ summary:
+ "成功工具结果会归档为 tool.result,并保留 artifact refs,避免出现 tool result missing。",
+ details: {
+ toolCallId: "tool-1",
+ success: true,
+ phase: "completed",
+ artifactPaths: [".lime/artifacts/demo.md"],
+ artifactIds: ["artifact-1"],
+ },
+ sourceRefs: [
+ "src/components/agent/chat/projection/agentUiEventProjection.test.ts:676",
+ ],
+ },
+ {
+ id: "tool-error",
+ category: "error",
+ eventTypes: ["tool.failed"],
+ summary:
+ "失败工具结果会归档为 tool.failed,并保留错误预览与 metadata,避免错误路径丢失。",
+ details: {
+ toolCallId: "tool-2",
+ success: false,
+ phase: "failed",
+ errorPreview: "Permission denied",
+ metadataKeys: ["sandboxed", "exit_code"],
+ },
+ sourceRefs: [
+ "src/components/agent/chat/projection/agentUiEventProjection.test.ts:676",
+ "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx:7801",
+ ],
+ },
+ {
+ id: "timeout-recovery",
+ category: "recovery action",
+ eventTypes: ["timeout.recover", "timeout.defer", "timeout.fail"],
+ summary:
+ "首包超时与 inactivity timeout 都有明确恢复动作矩阵和用户可见文案,拒绝或超时不会把用户卡死。",
+ details: {
+ firstEventActions: {
+ recover: "recover",
+ defer: "defer",
+ fail: "fail",
+ },
+ inactivityActions: {
+ recover: "recover",
+ fail: "fail",
+ },
+ warnings: [
+ "[AsterChat] 首个运行时事件静默,已降级切换为会话快照同步: event-a",
+ "[AsterChat] 首个运行时事件暂未到达,已基于提交派发继续等待后续进度: event-a",
+ "[AsterChat] 运行时事件静默,已降级切换为会话快照同步: event-a",
+ ],
+ failureMessage:
+ "执行已中断:运行时长时间没有返回新进度,请重试。",
+ },
+ sourceRefs: [
+ "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts:1",
+ ],
+ },
+ ];
+}
+
+function createEvidenceChecks() {
+ return [
+ {
+ id: "tool timeline",
+ satisfied: true,
+ summary:
+ "由 tool-request、tool-result 与 tool-error 三段 transcript 组成完整 tool timeline。",
+ transcriptStepIds: ["tool-request", "tool-result", "tool-error"],
+ },
+ {
+ id: "approval decision",
+ satisfied: true,
+ summary:
+ "approval-decision transcript 暴露 permission.changed waiting 状态、decision source/scope 与 confirmation request。",
+ transcriptStepIds: ["approval-decision"],
+ },
+ {
+ id: "sandbox policy",
+ satisfied: true,
+ summary:
+ "sandbox-policy transcript 暴露 turn_context approvalPolicy/sandboxPolicy。",
+ transcriptStepIds: ["sandbox-policy"],
+ },
+ {
+ id: "error recovery transcript",
+ satisfied: true,
+ summary:
+ "timeout-recovery transcript 暴露 recover/defer/fail 动作矩阵与用户可见 warning/message。",
+ transcriptStepIds: ["timeout-recovery"],
+ },
+ ];
+}
+
+function createFailureModeCoverage() {
+ return [
+ {
+ id: "approval bypass",
+ status: "covered",
+ summary:
+ "approval-decision 与 sandbox-policy transcript 证明 runtime 会先进入 waiting/policy gate,再继续工具执行。",
+ transcriptStepIds: ["approval-decision", "sandbox-policy"],
+ },
+ {
+ id: "tool result missing",
+ status: "covered",
+ summary:
+ "tool-result 与 tool-error transcript 同时存在,成功/失败路径都不会丢失结果。",
+ transcriptStepIds: ["tool-result", "tool-error"],
+ },
+ {
+ id: "timeout without recovery",
+ status: "covered",
+ summary:
+ "timeout-recovery transcript 暴露 recover/defer/fail 分支与用户可见 warning/message,不会让用户卡死。",
+ transcriptStepIds: ["timeout-recovery"],
+ },
+ {
+ id: "unsafe tool exposed",
+ status: "covered",
+ summary:
+ "工具权限与来源会进入 Harness 工具库存区块,危险工具暴露由 companion tool-surface smoke 共同覆盖。",
+ transcriptStepIds: ["approval-decision"],
+ companionCommand: "npm run smoke:agent-runtime-tool-surface",
+ },
+ ];
+}
+
+function liveRuntimeTranscriptSatisfiesReleaseGate(liveRuntimeTranscript) {
+ const assertions = liveRuntimeTranscript?.assertions;
+ return Boolean(
+ assertions?.devBridgeHealthy &&
+ assertions?.permissionRequestCreatedBeforeModel &&
+ assertions?.deniedDecisionClearsPendingRequest &&
+ assertions?.resolvedDecisionClearsPendingRequest &&
+ assertions?.approvalPolicySubmitted &&
+ assertions?.sandboxPolicySubmitted,
+ );
+}
+
+function buildApprovalSandboxSmokeEvidence({
+ commandResults,
+ generatedAt,
+ liveRuntimeTranscript = null,
+}) {
+ const transcriptSteps = createTranscriptSteps();
+ const evidenceChecks = createEvidenceChecks();
+ const failureModeCoverage = createFailureModeCoverage();
+ const liveRuntimeTranscriptVerified =
+ liveRuntimeTranscriptSatisfiesReleaseGate(liveRuntimeTranscript);
+
+ return {
+ schemaVersion: "v2",
+ scenarioId: "tool-approval-sandbox-boundary",
+ status: "pass",
+ generatedAt,
+ evidenceKind: "runtime-approval-sandbox-verified-transcript-smoke",
+ transcriptKind: liveRuntimeTranscriptVerified
+ ? "verified_projection_and_live_runtime_transcript"
+ : "verified_projection_summary",
+ limitation: liveRuntimeTranscriptVerified
+ ? "该 smoke 同时输出投影/组件事实源与 DevBridge live runtime permission confirmation transcript;tool result/error 和 timeout recovery 仍由定向回归覆盖。"
+ : "该 smoke 输出的是由前端提交、投影、UI 与超时恢复事实源生成的结构化 transcript summary;正式发布如需 live session 级证据,仍应由 qcloop 或 replay 采集。",
+ coverage: {
+ submitPreferences: true,
+ approvalDecisionProjection: true,
+ sandboxPolicyProjection: true,
+ toolRequestResultOrErrorProjection: true,
+ permissionRecoveryUi: true,
+ harnessPermissionInventory: true,
+ runtimeTranscriptSummary: true,
+ liveRuntimeTranscript: liveRuntimeTranscriptVerified,
+ },
+ liveRuntimeTranscript,
+ transcriptSteps,
+ evidenceChecks,
+ failureModeCoverage,
+ commandResults,
+ followUpForOfficialEvidence: [
+ liveRuntimeTranscriptVerified
+ ? "qcloop worker stdout 应保留 live.transcript.* 行,供 release verifier 审计。"
+ : "如需 live session 级发布证据,将同样的 transcript 维度写入 qcloop worker stdout 或 replay artifact。",
+ "保留 tool request / approval decision / sandbox policy / result-or-error / recovery action 五类 transcript 字段。",
+ ],
+ };
+}
+
+function renderApprovalSandboxTranscriptLines(evidence) {
+ const lines = [];
+
+ for (const step of evidence.transcriptSteps) {
+ const detailParts = [];
+ for (const [key, value] of Object.entries(step.details || {})) {
+ if (Array.isArray(value)) {
+ detailParts.push(`${key}=${value.join("|")}`);
+ } else if (value && typeof value === "object") {
+ detailParts.push(`${key}=${JSON.stringify(value)}`);
+ } else {
+ detailParts.push(`${key}=${String(value)}`);
+ }
+ }
+
+ lines.push(
+ `[smoke:agent-runtime-approval-sandbox] transcript.${step.id}: category=${step.category}; events=${step.eventTypes.join("/")}; ${step.summary}; ${detailParts.join("; ")}`,
+ );
+ }
+
+ for (const check of evidence.evidenceChecks) {
+ lines.push(
+ `[smoke:agent-runtime-approval-sandbox] evidence.${check.id}: ${
+ check.satisfied ? "satisfied" : "missing"
+ }; ${check.summary}; transcript=${check.transcriptStepIds.join(",")}`,
+ );
+ }
+
+ for (const mode of evidence.failureModeCoverage) {
+ lines.push(
+ `[smoke:agent-runtime-approval-sandbox] failure.${mode.id}: ${mode.status}; ${mode.summary}; transcript=${mode.transcriptStepIds.join(",")}${
+ mode.companionCommand ? `; companion=${mode.companionCommand}` : ""
+ }`,
+ );
+ }
+
+ const live = evidence.liveRuntimeTranscript;
+ if (live) {
+ lines.push(
+ `[smoke:agent-runtime-approval-sandbox] live.transcript.kind=${live.kind}; devBridgeStatus=${live.health?.status || "unknown"}; workspaceId=${live.workspaceId || ""}; liveRuntimeTranscript=${evidence.coverage.liveRuntimeTranscript ? "true" : "false"}`,
+ );
+ for (const flow of live.flows || []) {
+ lines.push(
+ `[smoke:agent-runtime-approval-sandbox] live.transcript.${flow.decision}.request: sessionId=${flow.sessionId}; turnId=${flow.turnId}; requestId=${flow.requestId}; permissionStatus=${flow.before?.permissionStatus}; confirmationStatus=${flow.before?.confirmationStatus}; pendingRequestCount=${flow.before?.pendingRequestCount}; latestTurnStatus=${flow.before?.latestTurnStatus}; approvalPolicy=${flow.submittedPolicies?.approvalPolicy}; sandboxPolicy=${flow.submittedPolicies?.sandboxPolicy}`,
+ );
+ lines.push(
+ `[smoke:agent-runtime-approval-sandbox] live.transcript.${flow.decision}.decision: confirmed=${flow.respond?.confirmed}; response=${flow.respond?.responseLabel}; afterConfirmationStatus=${flow.after?.confirmationStatus}; afterPendingRequestCount=${flow.after?.pendingRequestCount}; afterThreadStatus=${flow.after?.threadStatus}`,
+ );
+ }
+ for (const [key, value] of Object.entries(live.assertions || {})) {
+ lines.push(
+ `[smoke:agent-runtime-approval-sandbox] live.assertion.${key}: ${value ? "satisfied" : "missing"}`,
+ );
+ }
+ }
+
+ return lines;
+}
+
+export {
+ buildApprovalSandboxSmokeEvidence,
+ renderApprovalSandboxTranscriptLines,
+};
diff --git a/scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts b/scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts
new file mode 100644
index 000000000..923ebb897
--- /dev/null
+++ b/scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildApprovalSandboxSmokeEvidence,
+ renderApprovalSandboxTranscriptLines,
+} from "./agent-runtime-approval-sandbox-smoke-core.mjs";
+
+describe("agent-runtime-approval-sandbox-smoke-core", () => {
+ it("应生成覆盖 verifier 关键维度的 transcript 与 failure mode 摘要", () => {
+ const evidence = buildApprovalSandboxSmokeEvidence({
+ commandResults: [],
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ });
+
+ expect(evidence.transcriptKind).toBe("verified_projection_summary");
+ expect(evidence.transcriptSteps.map((entry) => entry.category)).toEqual(
+ expect.arrayContaining([
+ "tool request",
+ "approval decision",
+ "sandbox policy",
+ "result",
+ "error",
+ "recovery action",
+ ]),
+ );
+ expect(evidence.evidenceChecks.every((entry) => entry.satisfied)).toBe(true);
+ expect(evidence.failureModeCoverage.map((entry) => entry.id)).toEqual(
+ expect.arrayContaining([
+ "approval bypass",
+ "tool result missing",
+ "timeout without recovery",
+ "unsafe tool exposed",
+ ]),
+ );
+ });
+
+ it("live runtime transcript 满足门禁时应标记为 liveRuntimeTranscript", () => {
+ const evidence = buildApprovalSandboxSmokeEvidence({
+ commandResults: [],
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ liveRuntimeTranscript: {
+ kind: "devbridge-runtime-permission-confirmation",
+ health: { status: "ok" },
+ workspaceId: "workspace-1",
+ flows: [
+ {
+ decision: "denied",
+ sessionId: "session-denied",
+ turnId: "turn-denied",
+ requestId: "runtime_permission_confirmation:turn-denied",
+ submittedPolicies: {
+ approvalPolicy: "on-request",
+ sandboxPolicy: "workspace-write",
+ },
+ before: {
+ permissionStatus: "requires_confirmation",
+ confirmationStatus: "requested",
+ pendingRequestCount: 1,
+ latestTurnStatus: "failed",
+ },
+ respond: {
+ confirmed: false,
+ responseLabel: "拒绝",
+ },
+ after: {
+ confirmationStatus: "denied",
+ pendingRequestCount: 0,
+ threadStatus: "failed",
+ },
+ },
+ ],
+ assertions: {
+ devBridgeHealthy: true,
+ permissionRequestCreatedBeforeModel: true,
+ deniedDecisionClearsPendingRequest: true,
+ resolvedDecisionClearsPendingRequest: true,
+ approvalPolicySubmitted: true,
+ sandboxPolicySubmitted: true,
+ },
+ },
+ });
+
+ expect(evidence.transcriptKind).toBe(
+ "verified_projection_and_live_runtime_transcript",
+ );
+ expect(evidence.coverage.liveRuntimeTranscript).toBe(true);
+
+ const lines = renderApprovalSandboxTranscriptLines(evidence).join("\n");
+ expect(lines).toContain("live.transcript.denied.request");
+ expect(lines).toContain(
+ "live.assertion.permissionRequestCreatedBeforeModel: satisfied",
+ );
+ });
+
+ it("应把 transcript、evidence 与 failure mode 渲染到 stdout 友好的行文本", () => {
+ const evidence = buildApprovalSandboxSmokeEvidence({
+ commandResults: [],
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ });
+
+ const lines = renderApprovalSandboxTranscriptLines(evidence).join("\n");
+
+ expect(lines).toContain("transcript.tool-request");
+ expect(lines).toContain("transcript.approval-decision");
+ expect(lines).toContain("transcript.sandbox-policy");
+ expect(lines).toContain("transcript.tool-result");
+ expect(lines).toContain("transcript.tool-error");
+ expect(lines).toContain("transcript.timeout-recovery");
+ expect(lines).toContain("evidence.tool timeline: satisfied");
+ expect(lines).toContain("evidence.approval decision: satisfied");
+ expect(lines).toContain("evidence.sandbox policy: satisfied");
+ expect(lines).toContain("evidence.error recovery transcript: satisfied");
+ expect(lines).toContain("failure.approval bypass: covered");
+ expect(lines).toContain("failure.tool result missing: covered");
+ expect(lines).toContain("failure.timeout without recovery: covered");
+ expect(lines).toContain("failure.unsafe tool exposed: covered");
+ });
+});
diff --git a/scripts/lib/i18n-patch-metrics-report-core.mjs b/scripts/lib/i18n-patch-metrics-report-core.mjs
new file mode 100644
index 000000000..787d13340
--- /dev/null
+++ b/scripts/lib/i18n-patch-metrics-report-core.mjs
@@ -0,0 +1,221 @@
+const REPORT_SCHEMA_VERSION = "lime.i18n.patchMetricsReport.v1";
+
+function asFiniteNumber(value, fallback = 0) {
+ return Number.isFinite(Number(value)) ? Number(value) : fallback;
+}
+
+function asNonNegativeNumber(value, fallback = 0) {
+ return Math.max(0, asFiniteNumber(value, fallback));
+}
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function asString(value, fallback = "") {
+ return typeof value === "string" ? value : fallback;
+}
+
+function normalizeRun(run) {
+ return {
+ durationMs: asNonNegativeNumber(run?.durationMs),
+ language: asString(run?.language, "unknown"),
+ matchedSegments: asNonNegativeNumber(run?.matchedSegments),
+ replacedNodes: asNonNegativeNumber(run?.replacedNodes),
+ rootKind: asString(run?.rootKind, "unknown"),
+ timestamp: asNonNegativeNumber(run?.timestamp),
+ };
+}
+
+function sumRuns(runs, field) {
+ return runs.reduce((sum, run) => sum + asNonNegativeNumber(run[field]), 0);
+}
+
+function normalizeRuntimeMetrics(metrics) {
+ const recentRuns = asArray(metrics?.recentRuns ?? metrics?.runs).map(normalizeRun);
+ const patchTimes = asArray(metrics?.patchTimes).map((value) =>
+ asNonNegativeNumber(value),
+ );
+ const totalPatchTime = patchTimes.reduce((sum, value) => sum + value, 0);
+
+ const totalRuns = asNonNegativeNumber(
+ metrics?.totalRuns,
+ recentRuns.length,
+ );
+ const totalReplacedNodes = asNonNegativeNumber(
+ metrics?.totalReplacedNodes,
+ sumRuns(recentRuns, "replacedNodes"),
+ );
+ const totalMatchedSegments = asNonNegativeNumber(
+ metrics?.totalMatchedSegments,
+ sumRuns(recentRuns, "matchedSegments"),
+ );
+ const averagePatchTimeMs = asNonNegativeNumber(
+ metrics?.averagePatchTimeMs,
+ patchTimes.length > 0 ? totalPatchTime / patchTimes.length : 0,
+ );
+ const slowestPatchTimeMs = asNonNegativeNumber(
+ metrics?.slowestPatchTimeMs,
+ patchTimes.length > 0 ? Math.max(...patchTimes) : 0,
+ );
+ const lastRun = metrics?.lastRun
+ ? normalizeRun(metrics.lastRun)
+ : (recentRuns.at(-1) ?? null);
+
+ return {
+ averagePatchTimeMs,
+ languageChanges: asNonNegativeNumber(metrics?.languageChanges),
+ lastRun,
+ recentRuns,
+ slowestPatchTimeMs,
+ totalMatchedSegments,
+ totalReplacedNodes,
+ totalRuns,
+ };
+}
+
+function resolveStatus(summary) {
+ if (summary.totalRuns === 0) {
+ return "missing-metrics";
+ }
+
+ if (
+ summary.totalMatchedSegments === 0 &&
+ summary.totalReplacedNodes === 0
+ ) {
+ return "no-hit";
+ }
+
+ return "active-patch";
+}
+
+function buildRecommendations(status) {
+ if (status === "missing-metrics") {
+ return [
+ "先在 GUI smoke 或 Playwright 验证中导出 window.__I18N_METRICS__,再判断 Patch 退出进度。",
+ ];
+ }
+
+ if (status === "no-hit") {
+ return [
+ "当前样本未命中 legacy Patch,可进入 current 主路径依赖审计,但还不能直接删除 DOM replacer。",
+ ];
+ }
+
+ return [
+ "仍有 legacy Patch 命中,优先把 recentRuns 覆盖到的页面文案迁入 key-based namespace。",
+ ];
+}
+
+function normalizeThresholds(thresholds = {}) {
+ return {
+ maxMatchedSegments:
+ thresholds.maxMatchedSegments === undefined
+ ? null
+ : asNonNegativeNumber(thresholds.maxMatchedSegments),
+ maxReplacedNodes:
+ thresholds.maxReplacedNodes === undefined
+ ? null
+ : asNonNegativeNumber(thresholds.maxReplacedNodes),
+ maxRuns:
+ thresholds.maxRuns === undefined
+ ? null
+ : asNonNegativeNumber(thresholds.maxRuns),
+ };
+}
+
+function buildThresholdIssues(summary, thresholds) {
+ const issues = [];
+ const checks = [
+ ["maxMatchedSegments", "totalMatchedSegments", "命中文本段数"],
+ ["maxReplacedNodes", "totalReplacedNodes", "替换节点数"],
+ ["maxRuns", "totalRuns", "Patch 运行次数"],
+ ];
+
+ for (const [thresholdKey, summaryKey, label] of checks) {
+ const threshold = thresholds[thresholdKey];
+ if (threshold === null || threshold === undefined) {
+ continue;
+ }
+ const actual = summary[summaryKey];
+ if (actual > threshold) {
+ issues.push({
+ actual,
+ field: summaryKey,
+ message: `${label} ${actual} 超过门限 ${threshold}`,
+ threshold,
+ });
+ }
+ }
+
+ return issues;
+}
+
+export function createI18nPatchMetricsReport({
+ generatedAt = new Date().toISOString(),
+ metrics,
+ sourcePath = "",
+ thresholds,
+} = {}) {
+ const normalizedMetrics = normalizeRuntimeMetrics(metrics ?? {});
+ const status = resolveStatus(normalizedMetrics);
+ const normalizedThresholds = normalizeThresholds(thresholds);
+ const thresholdIssues = buildThresholdIssues(
+ normalizedMetrics,
+ normalizedThresholds,
+ );
+
+ return {
+ schemaVersion: REPORT_SCHEMA_VERSION,
+ generatedAt,
+ sourcePath,
+ status,
+ retirementCandidate: status === "no-hit",
+ summary: {
+ averagePatchTimeMs: normalizedMetrics.averagePatchTimeMs,
+ languageChanges: normalizedMetrics.languageChanges,
+ lastRunAt: normalizedMetrics.lastRun?.timestamp ?? null,
+ lastRunLanguage: normalizedMetrics.lastRun?.language ?? null,
+ lastRunRootKind: normalizedMetrics.lastRun?.rootKind ?? null,
+ recentRunCount: normalizedMetrics.recentRuns.length,
+ slowestPatchTimeMs: normalizedMetrics.slowestPatchTimeMs,
+ totalMatchedSegments: normalizedMetrics.totalMatchedSegments,
+ totalReplacedNodes: normalizedMetrics.totalReplacedNodes,
+ totalRuns: normalizedMetrics.totalRuns,
+ },
+ thresholds: normalizedThresholds,
+ thresholdIssues,
+ recentRuns: normalizedMetrics.recentRuns,
+ recommendations: buildRecommendations(status),
+ };
+}
+
+export function renderI18nPatchMetricsTextReport(report) {
+ const lines = [
+ "Lime i18n Patch Metrics Report",
+ `状态: ${report.status}`,
+ `来源: ${report.sourcePath || "(未指定)"}`,
+ `生成时间: ${report.generatedAt}`,
+ `Patch 运行次数: ${report.summary.totalRuns}`,
+ `替换节点数: ${report.summary.totalReplacedNodes}`,
+ `命中文本段数: ${report.summary.totalMatchedSegments}`,
+ `语言切换次数: ${report.summary.languageChanges}`,
+ `平均耗时: ${report.summary.averagePatchTimeMs.toFixed(2)}ms`,
+ `最慢耗时: ${report.summary.slowestPatchTimeMs.toFixed(2)}ms`,
+ `退出候选: ${report.retirementCandidate ? "yes" : "no"}`,
+ ];
+
+ if (report.thresholdIssues.length > 0) {
+ lines.push("", "门限问题:");
+ for (const issue of report.thresholdIssues) {
+ lines.push(`- ${issue.message}`);
+ }
+ }
+
+ lines.push("", "建议:");
+ for (const recommendation of report.recommendations) {
+ lines.push(`- ${recommendation}`);
+ }
+
+ return `${lines.join("\n")}\n`;
+}
diff --git a/scripts/lib/i18n-patch-metrics-report-core.test.ts b/scripts/lib/i18n-patch-metrics-report-core.test.ts
new file mode 100644
index 000000000..173351d7c
--- /dev/null
+++ b/scripts/lib/i18n-patch-metrics-report-core.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createI18nPatchMetricsReport,
+ renderI18nPatchMetricsTextReport,
+} from "./i18n-patch-metrics-report-core.mjs";
+
+describe("i18n-patch-metrics-report-core", () => {
+ it("应把 runtime metrics 转成稳定报告,并保留门限问题", () => {
+ const report = createI18nPatchMetricsReport({
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ sourcePath: ".lime/i18n/patch-metrics.json",
+ thresholds: {
+ maxMatchedSegments: 1,
+ maxReplacedNodes: 1,
+ },
+ metrics: {
+ patchTimes: [10, 20],
+ languageChanges: 2,
+ totalRuns: 2,
+ totalReplacedNodes: 3,
+ totalMatchedSegments: 4,
+ runs: [
+ {
+ durationMs: 10,
+ language: "en",
+ matchedSegments: 4,
+ replacedNodes: 3,
+ rootKind: "document",
+ timestamp: 1778400000000,
+ },
+ ],
+ },
+ });
+
+ expect(report.schemaVersion).toBe("lime.i18n.patchMetricsReport.v1");
+ expect(report.status).toBe("active-patch");
+ expect(report.retirementCandidate).toBe(false);
+ expect(report.summary.totalMatchedSegments).toBe(4);
+ expect(report.summary.averagePatchTimeMs).toBe(15);
+ expect(report.thresholdIssues.map((issue) => issue.field)).toEqual([
+ "totalMatchedSegments",
+ "totalReplacedNodes",
+ ]);
+ });
+
+ it("应识别零命中样本为退出候选,但不等价于可直接删除", () => {
+ const report = createI18nPatchMetricsReport({
+ metrics: {
+ languageChanges: 1,
+ recentRuns: [
+ {
+ durationMs: 2,
+ language: "en",
+ matchedSegments: 0,
+ replacedNodes: 0,
+ rootKind: "document",
+ timestamp: 1778400000000,
+ },
+ ],
+ totalMatchedSegments: 0,
+ totalReplacedNodes: 0,
+ totalRuns: 1,
+ },
+ });
+
+ expect(report.status).toBe("no-hit");
+ expect(report.retirementCandidate).toBe(true);
+ expect(report.recommendations.join("\n")).toContain("不能直接删除");
+ });
+
+ it("应渲染可读文本报告", () => {
+ const report = createI18nPatchMetricsReport({
+ generatedAt: "2026-05-10T00:00:00.000Z",
+ metrics: {},
+ sourcePath: "",
+ });
+ const text = renderI18nPatchMetricsTextReport(report);
+
+ expect(text).toContain("Lime i18n Patch Metrics Report");
+ expect(text).toContain("状态: missing-metrics");
+ expect(text).toContain("退出候选: no");
+ });
+});
diff --git a/scripts/lib/vitest-smoke-runner.mjs b/scripts/lib/vitest-smoke-runner.mjs
new file mode 100644
index 000000000..461e19ef5
--- /dev/null
+++ b/scripts/lib/vitest-smoke-runner.mjs
@@ -0,0 +1,111 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+import { pathToFileURL } from "node:url";
+
+const TauriAliasPatterns = [
+ ["^@tauri-apps/api/core$", "core.ts"],
+ ["^@tauri-apps/api/event$", "event.ts"],
+ ["^@tauri-apps/api/window$", "window.ts"],
+ ["^@tauri-apps/api/app$", "window.ts"],
+ ["^@tauri-apps/api/path$", "window.ts"],
+ ["^@tauri-apps/plugin-dialog$", "plugin-dialog.ts"],
+ ["^@tauri-apps/plugin-shell$", "plugin-shell.ts"],
+ ["^@tauri-apps/plugin-deep-link$", "plugin-deep-link.ts"],
+ ["^@tauri-apps/plugin-global-shortcut$", "plugin-global-shortcut.ts"],
+];
+
+function toFileUrl(filePath) {
+ return pathToFileURL(filePath).href;
+}
+
+function createVitestSmokeConfig(rootDir) {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lime-vitest-smoke-"));
+ const configPath = path.join(tempDir, "vitest.config.mjs");
+ const cacheDir = path.join(tempDir, "vite-cache");
+ const tauriMockDir = path.join(rootDir, "src/lib/tauri-mock");
+ const aliasSpecs = TauriAliasPatterns.map(([pattern, target]) => ({
+ pattern,
+ replacement: path.join(tauriMockDir, target),
+ }));
+
+ fs.writeFileSync(
+ configPath,
+ `import path from "node:path";\n` +
+ `import { defineConfig } from ${JSON.stringify(
+ toFileUrl(path.join(rootDir, "node_modules/vite/dist/node/index.js")),
+ )};\n` +
+ `import react from ${JSON.stringify(
+ toFileUrl(
+ path.join(rootDir, "node_modules/@vitejs/plugin-react/dist/index.js"),
+ ),
+ )};\n` +
+ `import svgr from ${JSON.stringify(
+ toFileUrl(path.join(rootDir, "node_modules/vite-plugin-svgr/dist/index.js")),
+ )};\n` +
+ `const rootDir = ${JSON.stringify(rootDir)};\n` +
+ `const aliasSpecs = ${JSON.stringify(aliasSpecs)};\n` +
+ `export default defineConfig({\n` +
+ ` root: rootDir,\n` +
+ ` cacheDir: ${JSON.stringify(cacheDir)},\n` +
+ ` define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify(process.env.VITE_APP_VERSION || "test") },\n` +
+ ` plugins: [react({ jsxRuntime: "automatic", jsxImportSource: "react", babel: { compact: true } }), svgr()],\n` +
+ ` resolve: { alias: [\n` +
+ ` { find: "@", replacement: path.resolve(rootDir, "src") },\n` +
+ ` ...aliasSpecs.map((entry) => ({ find: new RegExp(entry.pattern), replacement: entry.replacement })),\n` +
+ ` ] },\n` +
+ ` test: {\n` +
+ ` globals: true,\n` +
+ ` environment: "jsdom",\n` +
+ ` exclude: ["**/node_modules/**", "**/dist/**", "**/src-tauri/target/**"],\n` +
+ ` },\n` +
+ `});\n`,
+ );
+
+ return {
+ configPath,
+ cleanup() {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ },
+ };
+}
+
+export function runVitestSmoke({ rootDir, label, args, logPrefix, env }) {
+ const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
+ const config = createVitestSmokeConfig(rootDir);
+ const startedAt = Date.now();
+
+ console.log(`\n[${logPrefix}] > ${label}`);
+
+ try {
+ const result = spawnSync(
+ npmCommand,
+ ["exec", "--", "vitest", "run", ...args, "--config", config.configPath],
+ {
+ cwd: rootDir,
+ stdio: "inherit",
+ env: { ...process.env, ...env },
+ },
+ );
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ if (typeof result.status === "number" && result.status !== 0) {
+ const error = new Error(`[${logPrefix}] ${label} 失败`);
+ error.exitCode = result.status;
+ throw error;
+ }
+
+ return {
+ label,
+ status: "pass",
+ durationMs: Date.now() - startedAt,
+ args,
+ };
+ } finally {
+ config.cleanup();
+ }
+}
diff --git a/scripts/local-ci.mjs b/scripts/local-ci.mjs
index 77c8d718e..74c2db76b 100644
--- a/scripts/local-ci.mjs
+++ b/scripts/local-ci.mjs
@@ -11,6 +11,7 @@ const rootDir = process.cwd();
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const cargoCommand = process.platform === "win32" ? "cargo.exe" : "cargo";
const BRIDGE_REASON_LABELS = {
+ agent_qc_contract: "Agent QC contract",
bridge_contracts: "bridge/contracts",
bridge_runtime: "DevBridge / mock / bridge runtime",
fallback_full_suite: "兜底全量",
diff --git a/scripts/quality-task-planner.mjs b/scripts/quality-task-planner.mjs
index 9d6603e32..be3433076 100644
--- a/scripts/quality-task-planner.mjs
+++ b/scripts/quality-task-planner.mjs
@@ -42,11 +42,26 @@ const FRONTEND_TOOLING_FILES = new Set([
const BRIDGE_FILES = new Set([
"vite.config.ts",
+ "scripts/agent-qc-completion-audit.mjs",
+ "docs/test/agent-qc-evidence.schema.json",
+ "docs/test/agent-qc-gui-flows.manifest.json",
+ "docs/test/agent-qc-scenarios.manifest.json",
+ "scripts/agent-qc-export-evidence.mjs",
+ "scripts/agent-qc-gui-flow-report.mjs",
+ "scripts/agent-qc-qcloop-job.mjs",
+ "scripts/agent-qc-release-summary.mjs",
+ "scripts/agent-qc-report.mjs",
"scripts/check-command-contracts.mjs",
"scripts/check-generated-slop-report.mjs",
"scripts/check-dev-bridge-health.mjs",
"scripts/harness-eval-history-record.mjs",
"scripts/harness-eval-trend-report.mjs",
+ "scripts/lib/agent-qc-completion-audit-core.mjs",
+ "scripts/lib/agent-qc-evidence-core.mjs",
+ "scripts/lib/agent-qc-gui-flow-core.mjs",
+ "scripts/lib/agent-qc-qcloop-job-core.mjs",
+ "scripts/lib/agent-qc-release-summary-core.mjs",
+ "scripts/lib/agent-qc-report-core.mjs",
"scripts/report-generated-slop.mjs",
"scripts/social-workbench-e2e-smoke.mjs",
"scripts/chrome-bridge-e2e.mjs",
@@ -65,6 +80,27 @@ const HARNESS_CLEANUP_CONTRACT_FILES = new Set([
"scripts/lib/harness-dashboard-core.mjs",
]);
+const AGENT_QC_CONTRACT_FILES = new Set([
+ "docs/tests/agent-ops-qc.md",
+ "docs/tests/agent-qc-p0-scenarios.md",
+ "docs/tests/lime-agent-qc-rollout-plan.md",
+ "docs/test/agent-qc-evidence.schema.json",
+ "scripts/agent-qc-completion-audit.mjs",
+ "docs/test/agent-qc-gui-flows.manifest.json",
+ "docs/test/agent-qc-scenarios.manifest.json",
+ "scripts/agent-qc-export-evidence.mjs",
+ "scripts/agent-qc-gui-flow-report.mjs",
+ "scripts/agent-qc-qcloop-job.mjs",
+ "scripts/agent-qc-release-summary.mjs",
+ "scripts/agent-qc-report.mjs",
+ "scripts/lib/agent-qc-completion-audit-core.mjs",
+ "scripts/lib/agent-qc-evidence-core.mjs",
+ "scripts/lib/agent-qc-gui-flow-core.mjs",
+ "scripts/lib/agent-qc-qcloop-job-core.mjs",
+ "scripts/lib/agent-qc-release-summary-core.mjs",
+ "scripts/lib/agent-qc-report-core.mjs",
+]);
+
const INTEGRITY_FILES = new Set([
"package.json",
"packages/lime-cli-npm/package.json",
@@ -264,7 +300,10 @@ function isDocsChange(file) {
}
function isDocsOnlyChange(files) {
- return files.length > 0 && files.every((file) => isDocsChange(file));
+ return (
+ files.length > 0 &&
+ files.every((file) => isDocsChange(file) && !isAgentQcContractChange(file))
+ );
}
function isFrontendChange(file) {
@@ -291,6 +330,10 @@ function isHarnessCleanupContractChange(file) {
return HARNESS_CLEANUP_CONTRACT_FILES.has(file);
}
+function isAgentQcContractChange(file) {
+ return AGENT_QC_CONTRACT_FILES.has(file);
+}
+
function collectBridgeReasons(
changedFiles,
{ full = false, fallback = false, workflow = false } = {},
@@ -309,6 +352,10 @@ function collectBridgeReasons(
const reasons = [];
+ if (changedFiles.some(isAgentQcContractChange)) {
+ reasons.push("agent_qc_contract");
+ }
+
if (changedFiles.some(isHarnessCleanupContractChange)) {
reasons.push("harness_cleanup_contract");
}
diff --git a/scripts/quality-task-planner.test.ts b/scripts/quality-task-planner.test.ts
index d84ef1ceb..c62073522 100644
--- a/scripts/quality-task-planner.test.ts
+++ b/scripts/quality-task-planner.test.ts
@@ -3,6 +3,26 @@ import { describe, expect, it } from "vitest";
import { detectTasks } from "./quality-task-planner.mjs";
describe("quality-task-planner", () => {
+ it("应把 Agent QC manifest/schema 归到 bridge/contracts 风险", () => {
+ const tasks = detectTasks([
+ "docs/tests/agent-ops-qc.md",
+ "docs/tests/agent-qc-p0-scenarios.md",
+ "docs/tests/lime-agent-qc-rollout-plan.md",
+ "docs/test/agent-qc-scenarios.manifest.json",
+ "docs/test/agent-qc-evidence.schema.json",
+ "docs/test/agent-qc-gui-flows.manifest.json",
+ "scripts/lib/agent-qc-completion-audit-core.mjs",
+ "scripts/lib/agent-qc-evidence-core.mjs",
+ "scripts/lib/agent-qc-gui-flow-core.mjs",
+ "scripts/lib/agent-qc-qcloop-job-core.mjs",
+ "scripts/lib/agent-qc-release-summary-core.mjs",
+ ]);
+
+ expect(tasks.bridge).toBe(true);
+ expect(tasks.bridgeReasons).toContain("agent_qc_contract");
+ expect(tasks.docsOnly).toBe(false);
+ });
+
it("应把 harness cleanup/report 主链文件归到 bridge/contracts 风险", () => {
const tasks = detectTasks([
"scripts/lib/generated-slop-report-core.mjs",
diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs
index 6970d13a4..2287e9f8b 100644
--- a/scripts/verify-gui-smoke.mjs
+++ b/scripts/verify-gui-smoke.mjs
@@ -6,6 +6,10 @@ import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
+import {
+ createI18nPatchMetricsReport,
+ renderI18nPatchMetricsTextReport,
+} from "./lib/i18n-patch-metrics-report-core.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -40,6 +44,18 @@ const HEADLESS_TAURI_CONFIG_PATH = path.join(
"src-tauri",
"tauri.conf.headless.json",
);
+const DEFAULT_I18N_PATCH_METRICS_OUTPUT = path.join(
+ rootDir,
+ ".lime",
+ "i18n",
+ "patch-metrics.json",
+);
+const DEFAULT_I18N_PATCH_REPORT_OUTPUT = path.join(
+ rootDir,
+ ".lime",
+ "i18n",
+ "patch-metrics-report.json",
+);
const tauriCommand =
process.platform === "win32"
? path.join(rootDir, "node_modules", ".bin", "tauri.cmd")
@@ -173,6 +189,9 @@ const DEFAULTS = {
intervalMs: 1_000,
reuseRunning: false,
includeKnowledgeProductE2e: false,
+ i18nPatchMetricsOutput: DEFAULT_I18N_PATCH_METRICS_OUTPUT,
+ i18nPatchReportOutput: DEFAULT_I18N_PATCH_REPORT_OUTPUT,
+ skipI18nPatchMetrics: false,
sampleProjectName: "Lime Smoke Workspace",
};
DEFAULTS.timeoutMs = resolveDefaultTimeoutMs(DEFAULTS.cargoTargetDir);
@@ -213,6 +232,11 @@ Lime GUI 冒烟入口
--reuse-running 复用已启动的 headless Tauri,不主动拉起
--include-knowledge-product-e2e
额外执行项目资料产品 E2E 闭环验收
+ --i18n-patch-metrics-output
+ 导出 GUI 页面上的 window.__I18N_METRICS__,默认 .lime/i18n/patch-metrics.json
+ --i18n-patch-report-output
+ 写入 Patch metrics JSON 报告,默认 .lime/i18n/patch-metrics-report.json
+ --skip-i18n-patch-metrics 跳过 Patch metrics 导出与报告
-h, --help 显示帮助
`);
}
@@ -278,6 +302,27 @@ function parseArgs(argv) {
continue;
}
+ if (arg === "--i18n-patch-metrics-output" && argv[index + 1]) {
+ options.i18nPatchMetricsOutput = path.resolve(
+ String(argv[index + 1]).trim(),
+ );
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--i18n-patch-report-output" && argv[index + 1]) {
+ options.i18nPatchReportOutput = path.resolve(
+ String(argv[index + 1]).trim(),
+ );
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--skip-i18n-patch-metrics") {
+ options.skipI18nPatchMetrics = true;
+ continue;
+ }
+
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
@@ -315,6 +360,15 @@ function parseArgs(argv) {
throw new Error("--cargo-target-dir 不能为空");
}
+ if (!options.skipI18nPatchMetrics) {
+ if (!options.i18nPatchMetricsOutput) {
+ throw new Error("--i18n-patch-metrics-output 不能为空");
+ }
+ if (!options.i18nPatchReportOutput) {
+ throw new Error("--i18n-patch-report-output 不能为空");
+ }
+ }
+
return options;
}
@@ -855,6 +909,45 @@ async function cleanupStaleGuiSmokeChromeProfiles(
}
}
+function writeI18nPatchMetricsReport(options) {
+ if (options.skipI18nPatchMetrics) {
+ return;
+ }
+
+ if (!fs.existsSync(options.i18nPatchMetricsOutput)) {
+ console.warn(
+ `[verify:gui-smoke] 未发现 i18n Patch metrics: ${options.i18nPatchMetricsOutput}`,
+ );
+ return;
+ }
+
+ const metrics = JSON.parse(
+ fs.readFileSync(options.i18nPatchMetricsOutput, "utf8"),
+ );
+ const report = createI18nPatchMetricsReport({
+ metrics,
+ sourcePath: path.relative(rootDir, options.i18nPatchMetricsOutput),
+ });
+ fs.mkdirSync(path.dirname(options.i18nPatchReportOutput), {
+ recursive: true,
+ });
+ fs.writeFileSync(
+ options.i18nPatchReportOutput,
+ JSON.stringify(report, null, 2),
+ "utf8",
+ );
+ console.log(
+ renderI18nPatchMetricsTextReport(report)
+ .trim()
+ .split("\n")
+ .map((line) => `[verify:gui-smoke] ${line}`)
+ .join("\n"),
+ );
+ console.log(
+ `[verify:gui-smoke] i18n Patch metrics report: ${options.i18nPatchReportOutput}`,
+ );
+}
+
function listListeningCommandsForPort(port) {
if (!port || process.platform === "win32") {
return [];
@@ -1481,7 +1574,7 @@ async function main() {
npmCommand,
[
"run",
- "smoke:knowledge-gui",
+ "smoke:claw-chat-ready-streaming",
"--",
"--app-url",
options.appUrl,
@@ -1494,10 +1587,39 @@ async function main() {
"--interval-ms",
String(options.intervalMs),
],
+ "smoke:claw-chat-ready-streaming",
+ options.timeoutMs + 30_000,
+ );
+
+ await runCommand(
+ npmCommand,
+ [
+ "run",
+ "smoke:knowledge-gui",
+ "--",
+ "--app-url",
+ options.appUrl,
+ "--health-url",
+ options.healthUrl,
+ "--invoke-url",
+ options.invokeUrl,
+ "--timeout-ms",
+ String(options.timeoutMs),
+ "--interval-ms",
+ String(options.intervalMs),
+ ...(options.skipI18nPatchMetrics
+ ? []
+ : [
+ "--i18n-patch-metrics-output",
+ options.i18nPatchMetricsOutput,
+ ]),
+ ],
"smoke:knowledge-gui",
options.timeoutMs + 30_000,
);
+ writeI18nPatchMetricsReport(options);
+
if (options.includeKnowledgeProductE2e) {
await runCommand(
npmCommand,
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index efe622d3c..a21f96d55 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -5065,7 +5065,7 @@ dependencies = [
[[package]]
name = "lime"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"anyhow",
"arboard",
@@ -5172,7 +5172,7 @@ dependencies = [
[[package]]
name = "lime-agent"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5201,7 +5201,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"chrono",
"futures",
@@ -5218,7 +5218,7 @@ dependencies = [
[[package]]
name = "lime-cli"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"clap",
"lime-core",
@@ -5230,7 +5230,7 @@ dependencies = [
[[package]]
name = "lime-config"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5246,7 +5246,7 @@ dependencies = [
[[package]]
name = "lime-core"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5299,7 +5299,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5329,7 +5329,7 @@ dependencies = [
[[package]]
name = "lime-infra"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5349,7 +5349,7 @@ dependencies = [
[[package]]
name = "lime-knowledge"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"chrono",
"hex",
@@ -5362,7 +5362,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5378,7 +5378,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5411,7 +5411,7 @@ dependencies = [
[[package]]
name = "lime-processor"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5430,7 +5430,7 @@ dependencies = [
[[package]]
name = "lime-providers"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5485,7 +5485,7 @@ dependencies = [
[[package]]
name = "lime-server"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5529,7 +5529,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5544,7 +5544,7 @@ dependencies = [
[[package]]
name = "lime-services"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5589,7 +5589,7 @@ dependencies = [
[[package]]
name = "lime-skills"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5607,7 +5607,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
-version = "1.32.0"
+version = "1.33.0"
dependencies = [
"axum 0.7.9",
"chrono",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 834d79d92..c03625a16 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -9,7 +9,7 @@ exclude = [
resolver = "2"
[workspace.package]
-version = "1.32.0"
+version = "1.33.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -198,7 +198,7 @@ version = "2.4"
[package]
name = "lime"
-version = "1.32.0"
+version = "1.33.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs
index 5ac4effa4..615043b66 100644
--- a/src-tauri/crates/agent/src/aster_state.rs
+++ b/src-tauri/crates/agent/src/aster_state.rs
@@ -505,10 +505,14 @@ impl AsterAgentState {
/// 创建新的取消令牌
pub async fn create_cancel_token(&self, session_id: &str) -> CancellationToken {
+ let should_cancel_immediately = {
+ let markers = self.interrupt_markers.read().await;
+ markers.contains_key(session_id)
+ };
let token = CancellationToken::new();
- let mut markers = self.interrupt_markers.write().await;
- markers.remove(session_id);
- drop(markers);
+ if should_cancel_immediately {
+ token.cancel();
+ }
let mut tokens = self.cancel_tokens.write().await;
tokens.insert(session_id.to_string(), token.clone());
token
@@ -809,8 +813,9 @@ mod tests {
Some("用户主动停止当前执行")
);
- let _token = state.create_cancel_token(session_id).await;
- assert!(state.get_interrupt_marker(session_id).await.is_none());
+ let token = state.create_cancel_token(session_id).await;
+ assert!(token.is_cancelled());
+ assert!(state.get_interrupt_marker(session_id).await.is_some());
state
.record_interrupt_request(session_id, "user", "第二次停止")
diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs
index a4f89ab57..ed035533b 100644
--- a/src-tauri/crates/agent/src/lib.rs
+++ b/src-tauri/crates/agent/src/lib.rs
@@ -90,10 +90,10 @@ pub use provider_runtime_governor::{
pub use queued_turn::QueuedTurnSnapshot;
pub use request_tool_policy::{
execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy,
- merge_system_prompt_with_web_search_preflight_context, message_suggests_news_expansion,
- resolve_request_tool_policy, resolve_request_tool_policy_with_mode, stream_reply_with_policy,
- ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, StreamReplyExecution,
- WebSearchExecutionTracker, WebSearchPreflightRequest, REQUEST_TOOL_POLICY_MARKER,
+ merge_system_prompt_with_web_search_preflight_context, resolve_request_tool_policy,
+ resolve_request_tool_policy_with_mode, stream_reply_with_policy, ReplyAttemptError,
+ RequestToolPolicy, RequestToolPolicyMode, StreamReplyExecution, WebSearchExecutionTracker,
+ WebSearchPreflightRequest, REQUEST_TOOL_POLICY_MARKER,
};
pub use runtime_projection_snapshot::RuntimeProjectionSnapshot;
pub use runtime_queue::{
diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs
index fb8b396df..f3f44d263 100644
--- a/src-tauri/crates/agent/src/request_tool_policy.rs
+++ b/src-tauri/crates/agent/src/request_tool_policy.rs
@@ -10,8 +10,8 @@ use crate::protocol_projection::project_runtime_event;
use crate::write_artifact_events::WriteArtifactEventEmitter;
use aster::agents::{Agent, AgentEvent as AsterAgentEvent};
use aster::conversation::message::{Message, MessageContent, SystemNotificationType};
+use aster::session::SessionManager;
use aster::tools::ToolContext;
-use chrono::{Datelike, Local, NaiveDate};
use futures::{stream, StreamExt};
use lime_core::env_compat;
use regex::Regex;
@@ -52,7 +52,6 @@ const STREAM_EVENT_DIAG_WARN_TEXT_DELTA_CHARS: usize = 2_000;
const STREAM_EVENT_DIAG_WARN_TOOL_OUTPUT_CHARS: usize = 8_000;
const STREAM_EVENT_DIAG_WARN_CONTEXT_STEPS: usize = 24;
const TEXT_DELTA_BATCH_BACKLOG_CHARS: usize = 120;
-const NEWS_PREFLIGHT_QUERY_LIMIT: usize = 4;
const NEWS_PREFLIGHT_QUERY_PARALLELISM: usize = 4;
const NEWS_PREFLIGHT_QUERY_OUTPUT_CHAR_LIMIT: usize = 1_600;
const NEWS_PREFLIGHT_CONTEXT_CHAR_LIMIT: usize = 6_000;
@@ -63,7 +62,8 @@ const ASTER_AUTO_COMPACTION_COMPLETE_TEXT: &str = "Compaction complete";
const ASTER_AUTO_COMPACTION_THINKING_TEXT: &str = "aster is compacting the conversation...";
const ASTER_AUTO_COMPACTION_ERROR_PREFIX: &str = "Ran into this error trying to compact:";
const ASTER_AUTO_COMPACTION_DISABLED_TEXT: &str = "Automatic compaction is disabled for this turn. The conversation reached the context limit. Compact the session manually or start a new session before retrying.";
-const OPTIONAL_WEB_SEARCH_PREFLIGHT_TIMEOUT_MS: u64 = 1_500;
+const CANCELLED_TURN_CONTEXT_MARKER: &str =
+ "上一回合已被用户停止,不要继续回答被停止的请求;等待并仅处理后续用户消息。";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
@@ -165,19 +165,6 @@ impl WebSearchExecutionTracker {
}
}
- fn absorb(&mut self, mut other: Self) {
- for tool_id in other.ordered_tool_ids.drain(..) {
- if self.attempts_by_id.contains_key(&tool_id) {
- continue;
- }
-
- if let Some(record) = other.attempts_by_id.remove(&tool_id) {
- self.ordered_tool_ids.push(tool_id.clone());
- self.attempts_by_id.insert(tool_id, record);
- }
- }
- }
-
pub fn validate_web_search_requirement(
&self,
policy: &RequestToolPolicy,
@@ -262,7 +249,6 @@ pub struct PreflightToolExecution {
pub planned_queries: Vec,
pub system_prompt_appendix: Option,
pub coverage_summary: Option,
- pub expanded_news_search: bool,
}
pub struct WebSearchPreflightRequest<'a> {
@@ -282,7 +268,6 @@ impl PreflightToolExecution {
planned_queries: Vec::new(),
system_prompt_appendix: None,
coverage_summary: None,
- expanded_news_search: false,
}
}
}
@@ -460,6 +445,52 @@ pub struct StreamReplyExecution {
pub event_errors: Vec,
pub emitted_any: bool,
pub attempts_summary: String,
+ pub cancelled: bool,
+}
+
+fn is_reply_cancelled(cancel_token: &Option) -> bool {
+ cancel_token
+ .as_ref()
+ .is_some_and(CancellationToken::is_cancelled)
+}
+
+fn build_stream_reply_execution(
+ text_output: String,
+ event_errors: Vec,
+ emitted_any: bool,
+ attempts_summary: String,
+ cancelled: bool,
+) -> StreamReplyExecution {
+ StreamReplyExecution {
+ text_output,
+ event_errors,
+ emitted_any,
+ attempts_summary,
+ cancelled,
+ }
+}
+
+fn cancelled_turn_context_marker_message() -> Message {
+ Message::assistant()
+ .with_text(CANCELLED_TURN_CONTEXT_MARKER)
+ .agent_only()
+}
+
+async fn persist_cancelled_turn_context_marker(agent: &Agent, session_id: &str) {
+ let message = cancelled_turn_context_marker_message();
+ let result = if let Some(store) = agent.session_store() {
+ store.add_message(session_id, &message).await
+ } else {
+ SessionManager::add_message(session_id, &message).await
+ };
+
+ if let Err(error) = result {
+ tracing::warn!(
+ "[AsterAgent][ReplyPolicy] 写入取消上下文标记失败,已降级继续: session_id={}, error={}",
+ session_id,
+ error
+ );
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -858,93 +889,6 @@ fn normalize_tool_name(value: &str) -> String {
.collect::()
}
-pub fn message_suggests_news_expansion(message: &str) -> bool {
- let trimmed = message.trim();
- if trimmed.is_empty() {
- return false;
- }
-
- let normalized = trimmed.to_ascii_lowercase();
- let has_news_keyword = [
- "新闻",
- "快讯",
- "头条",
- "要闻",
- "news",
- "headline",
- "headlines",
- "briefing",
- "roundup",
- "recap",
- "digest",
- ]
- .iter()
- .any(|keyword| normalized.contains(keyword));
- if !has_news_keyword {
- return false;
- }
-
- let has_time_keyword = [
- "今天",
- "今日",
- "昨天",
- "昨晚",
- "最新",
- "实时",
- "本周",
- "这周",
- "today",
- "latest",
- "breaking",
- "march",
- "april",
- "may",
- "june",
- "july",
- "august",
- "september",
- "october",
- "november",
- "december",
- "january",
- "february",
- ]
- .iter()
- .any(|keyword| normalized.contains(keyword));
- let has_summary_keyword = [
- "汇总",
- "综述",
- "盘点",
- "整理",
- "总结",
- "写一篇",
- "写成",
- "简报",
- "日报",
- "报道",
- "summary",
- "summarize",
- "wrap up",
- "report",
- "brief",
- "briefing",
- ]
- .iter()
- .any(|keyword| normalized.contains(keyword));
- let has_explicit_date = Regex::new(r"\d{1,2}月\d{1,2}日")
- .ok()
- .map(|re| re.is_match(trimmed))
- .unwrap_or(false)
- || Regex::new(
- r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\s+\d{1,2}\b",
- )
- .ok()
- .map(|re| re.is_match(&normalized))
- .unwrap_or(false);
-
- has_time_keyword || has_summary_keyword || has_explicit_date
-}
-
pub fn merge_system_prompt_with_web_search_preflight_context(
base_prompt: Option,
appendix: Option,
@@ -965,201 +909,15 @@ pub fn merge_system_prompt_with_web_search_preflight_context(
}
}
-fn should_run_web_search_preflight(policy: &RequestToolPolicy, message_text: &str) -> bool {
+fn should_run_web_search_preflight(policy: &RequestToolPolicy, _message_text: &str) -> bool {
if !is_web_search_preflight_enabled() {
return false;
}
policy.requires_web_search()
- || (policy.allows_web_search() && message_suggests_news_expansion(message_text))
}
-fn split_before_followup_clause(message_text: &str) -> String {
- let mut candidate = message_text.trim().replace('\n', " ");
- for delimiter in [
- ",并",
- ",并",
- "并将",
- "并且",
- "然后",
- "再把",
- "再将",
- "再帮我",
- " afterwards ",
- " and then ",
- " then ",
- ] {
- if let Some((head, _)) = candidate.split_once(delimiter) {
- candidate = head.trim().to_string();
- break;
- }
- }
- candidate
-}
-
-fn sanitize_news_search_clause(message_text: &str) -> String {
- let mut candidate = split_before_followup_clause(message_text);
- for prefix in [
- "请帮我",
- "帮我",
- "麻烦你",
- "请你",
- "请",
- "帮忙",
- "替我",
- "能否",
- "可以",
- ] {
- candidate = candidate.trim_start_matches(prefix).trim().to_string();
- }
- for verb in [
- "找一下",
- "搜一下",
- "搜索",
- "查一下",
- "查找",
- "检索",
- "收集",
- "整理",
- "找",
- "搜",
- "查",
- ] {
- candidate = candidate.trim_start_matches(verb).trim().to_string();
- }
-
- let collapsed = candidate
- .replace(['?', '?', '。', ',', ','], " ")
- .split_whitespace()
- .collect::>()
- .join(" ");
- if collapsed.is_empty() {
- derive_preflight_query(message_text)
- } else {
- collapsed
- }
-}
-
-fn month_name(month: u32) -> &'static str {
- match month {
- 1 => "January",
- 2 => "February",
- 3 => "March",
- 4 => "April",
- 5 => "May",
- 6 => "June",
- 7 => "July",
- 8 => "August",
- 9 => "September",
- 10 => "October",
- 11 => "November",
- 12 => "December",
- _ => "March",
- }
-}
-
-fn resolve_topic_labels(message_text: &str) -> (&'static str, &'static str) {
- let normalized = message_text.to_ascii_lowercase();
- if normalized.contains("国际")
- || normalized.contains("international")
- || normalized.contains("world")
- {
- ("国际新闻", "international news")
- } else if normalized.contains("国内") || normalized.contains("china") {
- ("国内新闻", "china news")
- } else if normalized.contains("科技")
- || normalized.contains("ai ")
- || normalized.contains("ai新闻")
- {
- ("科技新闻", "technology news")
- } else {
- ("新闻", "news")
- }
-}
-
-fn resolve_absolute_news_date(message_text: &str, today: NaiveDate) -> Option<(String, String)> {
- let normalized = message_text.to_ascii_lowercase();
- if normalized.contains("今天") || normalized.contains("今日") || normalized.contains("today")
- {
- return Some((
- format!("{}年{}月{}日", today.year(), today.month(), today.day()),
- format!(
- "{} {} {}",
- month_name(today.month()),
- today.day(),
- today.year()
- ),
- ));
- }
-
- if let Ok(re) = Regex::new(r"(?P\d{1,2})月(?P\d{1,2})日") {
- if let Some(captures) = re.captures(message_text) {
- let month = captures
- .name("month")
- .and_then(|value| value.as_str().parse::().ok())?;
- let day = captures
- .name("day")
- .and_then(|value| value.as_str().parse::().ok())?;
- if (1..=12).contains(&month) && (1..=31).contains(&day) {
- return Some((
- format!("{}年{}月{}日", today.year(), month, day),
- format!("{} {} {}", month_name(month), day, today.year()),
- ));
- }
- }
- }
-
- None
-}
-
-fn dedup_queries(values: Vec) -> Vec {
- let mut seen = HashSet::new();
- let mut result = Vec::new();
- for value in values {
- let normalized = value.trim();
- if normalized.is_empty() {
- continue;
- }
- let key = normalized.to_ascii_lowercase();
- if seen.insert(key) {
- result.push(normalized.to_string());
- }
- }
- result
-}
-
-fn build_news_preflight_queries_with_reference(
- message_text: &str,
- today: NaiveDate,
-) -> Vec {
- let base_clause = sanitize_news_search_clause(message_text);
- let (zh_topic, en_topic) = resolve_topic_labels(message_text);
- let mut queries = vec![derive_preflight_query(&base_clause)];
-
- if let Some((zh_date, en_date)) = resolve_absolute_news_date(message_text, today) {
- queries.push(format!("{zh_date} {zh_topic}"));
- queries.push(format!("{en_date} {en_topic}"));
- queries.push(format!("{en_date} world headlines"));
- } else {
- queries.push(format!("{base_clause} {zh_topic}"));
- queries.push(format!("{base_clause} {en_topic}"));
- queries.push(format!("{base_clause} latest headlines"));
- }
-
- dedup_queries(queries)
- .into_iter()
- .take(NEWS_PREFLIGHT_QUERY_LIMIT)
- .collect()
-}
-
-fn build_preflight_queries(message_text: &str, policy: &RequestToolPolicy) -> Vec {
- if message_suggests_news_expansion(message_text) && policy.allows_web_search() {
- return build_news_preflight_queries_with_reference(
- message_text,
- Local::now().date_naive(),
- );
- }
-
+fn build_preflight_queries(message_text: &str, _policy: &RequestToolPolicy) -> Vec {
vec![derive_preflight_query(message_text)]
}
@@ -1506,9 +1264,7 @@ fn resolve_reply_retry_mode(
return ReplyRetryMode::None;
}
- if preflight_execution.system_prompt_appendix.is_some()
- || preflight_execution.expanded_news_search
- || !tracker.ordered_tool_ids.is_empty()
+ if preflight_execution.system_prompt_appendix.is_some() || !tracker.ordered_tool_ids.is_empty()
{
return ReplyRetryMode::WebSearchSynthesis;
}
@@ -1583,6 +1339,7 @@ where
session_id,
user_message.as_concat_text().chars().count()
);
+ let cancel_probe = cancel_token.clone();
let mut stream = agent
.reply(user_message, session_config, cancel_token)
.await
@@ -1595,7 +1352,19 @@ where
started_at.elapsed().as_millis()
);
- while let Some(event_result) = stream.next().await {
+ loop {
+ let event_result = match cancel_probe.as_ref() {
+ Some(token) => {
+ tokio::select! {
+ _ = token.cancelled() => break,
+ next = stream.next() => next,
+ }
+ }
+ None => stream.next().await,
+ };
+ let Some(event_result) = event_result else {
+ break;
+ };
match event_result {
Ok(agent_event) => {
let provider_error_for_event = match &agent_event {
@@ -1750,7 +1519,7 @@ fn extract_inline_agent_provider_error(message: &Message) -> Option {
/// 当开启联网搜索时,在正式回复前执行 WebSearch 预检索。
///
/// 目标:
-/// - 在需要时通过执行层主动完成新闻类扩搜,而不是只依赖模型自己多次调用搜索。
+/// - 仅在显式 `required` 搜索模式下先完成一次必需 WebSearch。
/// - 统一生成 tool_start/tool_end 事件,供前端 harness 展示。
/// - 将预检索结果压缩注入 system prompt,帮助模型做更深的事实整合。
/// - 若本回合被明确要求必须先搜索,且预检索全部失败,则由上层中断本次回答。
@@ -1808,8 +1577,6 @@ pub async fn execute_web_search_preflight_if_needed(
}
})
.collect::>();
- let expanded_news_search = planned_queries.len() > 1;
-
let working_directory = working_directory
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
@@ -1917,7 +1684,6 @@ pub async fn execute_web_search_preflight_if_needed(
planned_queries: planned_query_texts,
system_prompt_appendix,
coverage_summary,
- expanded_news_search,
})
}
}
@@ -1961,6 +1727,7 @@ where
{
let started_at = Instant::now();
let message_text = user_message.as_concat_text();
+ let cancel_probe = cancel_token.clone();
let mut web_search_tracker = WebSearchExecutionTracker::default();
tracing::info!(
"[AsterAgent][TTFT] stream policy start: session_id={}, message_chars={}, search_mode={}",
@@ -1969,8 +1736,7 @@ where
request_tool_policy.search_mode.as_str()
);
- // 对于 Required 模式,preflight 是必须的,必须等待完成
- // 对于 Allowed 模式,preflight 是可选优化,设置超时避免阻塞首字
+ // 只在显式 Required 模式做预检索;Allowed 只是暴露工具候选能力,由模型按需决定。
let preflight = if request_tool_policy.requires_web_search() {
execute_web_search_preflight_if_needed(
WebSearchPreflightRequest {
@@ -1986,36 +1752,7 @@ where
)
.await
} else {
- let mut optional_preflight_tracker = WebSearchExecutionTracker::default();
- match tokio::time::timeout(
- std::time::Duration::from_millis(OPTIONAL_WEB_SEARCH_PREFLIGHT_TIMEOUT_MS),
- execute_web_search_preflight_if_needed(
- WebSearchPreflightRequest {
- agent,
- session_id: &session_config.id,
- message_text: &message_text,
- working_directory,
- cancel_token: cancel_token.clone(),
- turn_context: session_config.turn_context.clone(),
- policy: request_tool_policy,
- },
- &mut optional_preflight_tracker,
- ),
- )
- .await
- {
- Ok(result) => {
- web_search_tracker.absorb(optional_preflight_tracker);
- result
- }
- Err(_) => {
- tracing::warn!(
- "[AsterAgent][TTFT] optional web search preflight timed out ({}ms), proceeding without preflight context",
- OPTIONAL_WEB_SEARCH_PREFLIGHT_TIMEOUT_MS
- );
- Ok(PreflightToolExecution::none())
- }
- }
+ Ok(PreflightToolExecution::none())
};
let preflight_execution = match preflight {
Ok(preflight_execution) => {
@@ -2081,8 +1818,8 @@ where
diagnostics.saved_site_content_count
);
let fallback_text = text_chunks.join("").trim().to_string();
- return Ok(StreamReplyExecution {
- text_output: if fallback_text.is_empty() {
+ return Ok(build_stream_reply_execution(
+ if fallback_text.is_empty() {
match build_output_preserved_reply_fallback(&diagnostics) {
Some(output) => output,
None => return Err(error),
@@ -2092,12 +1829,24 @@ where
},
event_errors,
emitted_any,
- attempts_summary: web_search_tracker.format_attempts(),
- });
+ web_search_tracker.format_attempts(),
+ is_reply_cancelled(&cancel_probe),
+ ));
}
return Err(error);
}
+ if is_reply_cancelled(&cancel_probe) {
+ persist_cancelled_turn_context_marker(agent, &session_config.id).await;
+ return Ok(build_stream_reply_execution(
+ text_chunks.join(""),
+ event_errors,
+ emitted_any,
+ web_search_tracker.format_attempts(),
+ true,
+ ));
+ }
+
let current_text_output = text_chunks.join("");
match resolve_reply_retry_mode(
&preflight_execution,
@@ -2155,12 +1904,13 @@ where
else {
return Err(error);
};
- return Ok(StreamReplyExecution {
- text_output: fallback_text,
+ return Ok(build_stream_reply_execution(
+ fallback_text,
event_errors,
emitted_any,
- attempts_summary: web_search_tracker.format_attempts(),
- });
+ web_search_tracker.format_attempts(),
+ is_reply_cancelled(&cancel_probe),
+ ));
}
return Err(error);
}
@@ -2207,12 +1957,13 @@ where
else {
return Err(error);
};
- return Ok(StreamReplyExecution {
- text_output: fallback_text,
+ return Ok(build_stream_reply_execution(
+ fallback_text,
event_errors,
emitted_any,
- attempts_summary: web_search_tracker.format_attempts(),
- });
+ web_search_tracker.format_attempts(),
+ is_reply_cancelled(&cancel_probe),
+ ));
}
return Err(error);
}
@@ -2260,12 +2011,13 @@ where
else {
return Err(error);
};
- return Ok(StreamReplyExecution {
- text_output: fallback_text,
+ return Ok(build_stream_reply_execution(
+ fallback_text,
event_errors,
emitted_any,
- attempts_summary: web_search_tracker.format_attempts(),
- });
+ web_search_tracker.format_attempts(),
+ is_reply_cancelled(&cancel_probe),
+ ));
}
return Err(error);
}
@@ -2273,6 +2025,17 @@ where
ReplyRetryMode::None => {}
}
+ if is_reply_cancelled(&cancel_probe) {
+ persist_cancelled_turn_context_marker(agent, &session_config.id).await;
+ return Ok(build_stream_reply_execution(
+ text_chunks.join(""),
+ event_errors,
+ emitted_any,
+ web_search_tracker.format_attempts(),
+ true,
+ ));
+ }
+
if let Err(validation_error) =
web_search_tracker.validate_web_search_requirement(request_tool_policy)
{
@@ -2311,12 +2074,13 @@ where
diagnostics.tool_end_count,
web_search_tracker.format_attempts()
);
- return Ok(StreamReplyExecution {
- text_output: fallback_text,
+ return Ok(build_stream_reply_execution(
+ fallback_text,
event_errors,
emitted_any,
- attempts_summary: web_search_tracker.format_attempts(),
- });
+ web_search_tracker.format_attempts(),
+ is_reply_cancelled(&cancel_probe),
+ ));
}
return Err(ReplyAttemptError {
message: build_empty_final_reply_error_message(&diagnostics, &web_search_tracker),
@@ -2324,12 +2088,13 @@ where
});
}
- Ok(StreamReplyExecution {
- text_output: final_text_output,
+ Ok(build_stream_reply_execution(
+ final_text_output,
event_errors,
emitted_any,
- attempts_summary: web_search_tracker.format_attempts(),
- })
+ web_search_tracker.format_attempts(),
+ false,
+ ))
}
fn is_web_search_preflight_enabled() -> bool {
@@ -2362,15 +2127,293 @@ fn derive_preflight_query(message_text: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
+ use aster::conversation::Conversation;
use aster::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
use aster::providers::errors::ProviderError;
- use aster::session::{SessionManager, SessionType, TurnContextOverride, TurnStatus};
+ use aster::session::{
+ ChatHistoryMatch, CommitOptions, CommitReport, MemoryCategory, MemoryHealth, MemoryRecord,
+ MemorySearchResult, Session, SessionInsights, SessionStore, SessionType, TokenStatsUpdate,
+ TurnContextOverride, TurnStatus,
+ };
use aster::tools::{PermissionCheckResult, Tool, ToolError, ToolResult};
use async_trait::async_trait;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
- use std::sync::Arc;
+ use std::sync::{Arc, Mutex};
+ use std::time::Duration;
+
+ struct TestSessionStore {
+ session: Mutex,
+ }
+
+ impl TestSessionStore {
+ fn new(session: Session) -> Self {
+ Self {
+ session: Mutex::new(session),
+ }
+ }
+
+ fn current_session(&self, include_messages: bool) -> Session {
+ let mut session = self.session.lock().expect("锁测试 session").clone();
+ if !include_messages {
+ session.conversation = None;
+ }
+ session
+ }
+ }
+
+ fn create_test_session_store(name: &str) -> (Arc, Session) {
+ let now = chrono::Utc::now();
+ let session = Session {
+ id: format!("test-{}-{}", name, Uuid::new_v4()),
+ working_dir: PathBuf::default(),
+ name: name.to_string(),
+ user_set_name: false,
+ session_type: SessionType::Hidden,
+ created_at: now,
+ updated_at: now,
+ extension_data: Default::default(),
+ total_tokens: None,
+ input_tokens: None,
+ output_tokens: None,
+ cached_input_tokens: None,
+ cache_creation_input_tokens: None,
+ accumulated_total_tokens: None,
+ accumulated_input_tokens: None,
+ accumulated_output_tokens: None,
+ schedule_id: None,
+ recipe: None,
+ user_recipe_values: None,
+ conversation: Some(Conversation::default()),
+ message_count: 0,
+ provider_name: None,
+ model_config: None,
+ };
+ (Arc::new(TestSessionStore::new(session.clone())), session)
+ }
+
+ #[async_trait]
+ impl SessionStore for TestSessionStore {
+ async fn create_session(
+ &self,
+ _working_dir: PathBuf,
+ _name: String,
+ _session_type: SessionType,
+ ) -> anyhow::Result {
+ Ok(self.current_session(true))
+ }
+
+ async fn get_session(&self, _id: &str, include_messages: bool) -> anyhow::Result {
+ Ok(self.current_session(include_messages))
+ }
+
+ async fn add_message(&self, _session_id: &str, message: &Message) -> anyhow::Result<()> {
+ let mut session = self.session.lock().expect("锁测试 session");
+ let conversation = session
+ .conversation
+ .get_or_insert_with(Conversation::default);
+ conversation.push(message.clone());
+ session.message_count = conversation.len();
+ session.updated_at = chrono::Utc::now();
+ Ok(())
+ }
+
+ async fn replace_conversation(
+ &self,
+ _session_id: &str,
+ conversation: &Conversation,
+ ) -> anyhow::Result<()> {
+ let mut session = self.session.lock().expect("锁测试 session");
+ session.conversation = Some(conversation.clone());
+ session.message_count = conversation.len();
+ session.updated_at = chrono::Utc::now();
+ Ok(())
+ }
+
+ async fn list_sessions(&self) -> anyhow::Result> {
+ Ok(vec![self.current_session(false)])
+ }
+
+ async fn list_sessions_by_types(
+ &self,
+ _types: &[SessionType],
+ ) -> anyhow::Result> {
+ Ok(vec![self.current_session(false)])
+ }
+
+ async fn delete_session(&self, _id: &str) -> anyhow::Result<()> {
+ Ok(())
+ }
+
+ async fn get_insights(&self) -> anyhow::Result {
+ Ok(SessionInsights {
+ total_sessions: 1,
+ total_tokens: 0,
+ })
+ }
+
+ async fn export_session(&self, _id: &str) -> anyhow::Result {
+ Ok("{}".to_string())
+ }
+
+ async fn import_session(&self, _json: &str) -> anyhow::Result {
+ Ok(self.current_session(true))
+ }
+
+ async fn copy_session(
+ &self,
+ _session_id: &str,
+ _new_name: String,
+ ) -> anyhow::Result {
+ Ok(self.current_session(true))
+ }
+
+ async fn truncate_conversation(
+ &self,
+ _session_id: &str,
+ _timestamp: i64,
+ ) -> anyhow::Result<()> {
+ Ok(())
+ }
+
+ async fn update_session_name(
+ &self,
+ _session_id: &str,
+ name: String,
+ user_set: bool,
+ ) -> anyhow::Result<()> {
+ let mut session = self.session.lock().expect("锁测试 session");
+ session.name = name;
+ session.user_set_name = user_set;
+ session.updated_at = chrono::Utc::now();
+ Ok(())
+ }
+
+ async fn update_working_dir(
+ &self,
+ _session_id: &str,
+ working_dir: PathBuf,
+ ) -> anyhow::Result<()> {
+ let mut session = self.session.lock().expect("锁测试 session");
+ session.working_dir = working_dir;
+ session.updated_at = chrono::Utc::now();
+ Ok(())
+ }
+
+ async fn update_session_type(
+ &self,
+ _session_id: &str,
+ session_type: SessionType,
+ ) -> anyhow::Result<()> {
+ let mut session = self.session.lock().expect("锁测试 session");
+ session.session_type = session_type;
+ session.updated_at = chrono::Utc::now();
+ Ok(())
+ }
+
+ async fn update_extension_data(
+ &self,
+ _session_id: &str,
+ extension_data: aster::session::extension_data::ExtensionData,
+ ) -> anyhow::Result<()> {
+ let mut session = self.session.lock().expect("锁测试 session");
+ session.extension_data = extension_data;
+ session.updated_at = chrono::Utc::now();
+ Ok(())
+ }
+
+ async fn update_token_stats(
+ &self,
+ _session_id: &str,
+ _stats: TokenStatsUpdate,
+ ) -> anyhow::Result<()> {
+ Ok(())
+ }
+
+ async fn update_provider_config(
+ &self,
+ _session_id: &str,
+ provider_name: Option,
+ model_config: Option,
+ ) -> anyhow::Result<()> {
+ let mut session = self.session.lock().expect("锁测试 session");
+ if let Some(provider_name) = provider_name {
+ session.provider_name = Some(provider_name);
+ }
+ if let Some(model_config) = model_config {
+ session.model_config = Some(model_config);
+ }
+ session.updated_at = chrono::Utc::now();
+ Ok(())
+ }
+
+ async fn update_recipe(
+ &self,
+ _session_id: &str,
+ _recipe: Option,
+ _user_recipe_values: Option>,
+ ) -> anyhow::Result<()> {
+ Ok(())
+ }
+
+ async fn search_chat_history(
+ &self,
+ _query: &str,
+ _limit: Option,
+ _after_date: Option>,
+ _before_date: Option>,
+ _exclude_session_id: Option,
+ ) -> anyhow::Result> {
+ Ok(Vec::new())
+ }
+
+ async fn commit_session(
+ &self,
+ _id: &str,
+ _options: CommitOptions,
+ ) -> anyhow::Result {
+ Ok(CommitReport {
+ session_id: "test-session-store".to_string(),
+ messages_scanned: 0,
+ memories_created: 0,
+ memories_merged: 0,
+ source_start_ts: None,
+ source_end_ts: None,
+ warnings: Vec::new(),
+ })
+ }
+
+ async fn search_memories(
+ &self,
+ _query: &str,
+ _limit: Option,
+ _session_scope: Option<&str>,
+ _categories: Option>,
+ ) -> anyhow::Result> {
+ Ok(Vec::new())
+ }
+
+ async fn retrieve_context_memories(
+ &self,
+ _session_id: &str,
+ _query: &str,
+ _limit: usize,
+ ) -> anyhow::Result> {
+ Ok(Vec::new())
+ }
+
+ async fn memory_stats(&self) -> anyhow::Result {
+ Ok(aster::session::MemoryStats::default())
+ }
+
+ async fn memory_health(&self) -> anyhow::Result {
+ Ok(MemoryHealth {
+ healthy: true,
+ message: "test session store".to_string(),
+ })
+ }
+ }
struct TurnContextGatedWebSearchTool;
@@ -2509,6 +2552,62 @@ mod tests {
}
}
+ struct SlowStreamingProvider;
+
+ #[async_trait]
+ impl Provider for SlowStreamingProvider {
+ fn metadata() -> ProviderMetadata
+ where
+ Self: Sized,
+ {
+ ProviderMetadata::empty()
+ }
+
+ fn get_name(&self) -> &str {
+ "slow-streaming-provider"
+ }
+
+ async fn complete_with_model(
+ &self,
+ _model_config: &aster::model::ModelConfig,
+ _system: &str,
+ _messages: &[Message],
+ _tools: &[rmcp::model::Tool],
+ ) -> Result<(Message, ProviderUsage), ProviderError> {
+ Ok((
+ Message::assistant().with_text("非流式兜底不应被调用"),
+ ProviderUsage::new("gpt-5.3-codex".to_string(), Usage::default()),
+ ))
+ }
+
+ async fn stream(
+ &self,
+ _system: &str,
+ _messages: &[Message],
+ _tools: &[rmcp::model::Tool],
+ ) -> Result {
+ Ok(Box::pin(async_stream::try_stream! {
+ yield (
+ Some(Message::assistant().with_text("第一段")),
+ None,
+ );
+ tokio::time::sleep(Duration::from_secs(30)).await;
+ yield (
+ Some(Message::assistant().with_text("第二段")),
+ Some(ProviderUsage::new("gpt-5.3-codex".to_string(), Usage::default())),
+ );
+ }))
+ }
+
+ fn supports_streaming(&self) -> bool {
+ true
+ }
+
+ fn get_model_config(&self) -> aster::model::ModelConfig {
+ aster::model::ModelConfig::new("gpt-5.3-codex").expect("test model config")
+ }
+ }
+
struct AuthenticationErrorProvider;
#[async_trait]
@@ -2751,22 +2850,35 @@ mod tests {
}
#[test]
- fn tracker_absorb_preserves_completed_optional_preflight_attempts() {
- let policy = resolve_request_tool_policy(Some(true), false);
- let mut target = WebSearchExecutionTracker::default();
- let mut optional_tracker = WebSearchExecutionTracker::default();
- optional_tracker.record_tool_start(&policy, "tool-1", "WebSearch");
- optional_tracker.record_tool_end(&policy, "tool-1", true, None);
+ fn allowed_web_search_should_not_run_preflight_from_message_keywords() {
+ let policy = resolve_request_tool_policy_with_mode(
+ Some(true),
+ Some(RequestToolPolicyMode::Allowed),
+ false,
+ );
- target.absorb(optional_tracker);
-
- assert_eq!(target.ordered_tool_ids, vec!["tool-1".to_string()]);
+ assert!(!should_run_web_search_preflight(
+ &policy,
+ "请搜索今天最新 AI 新闻"
+ ));
assert_eq!(
- target
- .attempts_by_id
- .get("tool-1")
- .and_then(|record| record.success),
- Some(true)
+ build_preflight_queries("请搜索今天最新 AI 新闻", &policy),
+ vec!["请搜索今天最新 AI 新闻".to_string()]
+ );
+ }
+
+ #[test]
+ fn required_web_search_should_run_preflight_without_keyword_detection() {
+ let policy = resolve_request_tool_policy_with_mode(
+ Some(true),
+ Some(RequestToolPolicyMode::Required),
+ false,
+ );
+
+ assert!(should_run_web_search_preflight(&policy, "继续"));
+ assert_eq!(
+ build_preflight_queries("继续", &policy),
+ vec!["继续".to_string()]
);
}
@@ -2995,28 +3107,6 @@ mod tests {
));
}
- #[test]
- fn detects_news_expansion_for_daily_news_summary_requests() {
- assert!(message_suggests_news_expansion("帮我汇总3月13日国际新闻"));
- assert!(message_suggests_news_expansion(
- "Please summarize the latest world news for March 13"
- ));
- assert!(!message_suggests_news_expansion("帮我解释一下牛顿第二定律"));
- }
-
- #[test]
- fn builds_news_preflight_queries_with_absolute_date_variants() {
- let queries = build_news_preflight_queries_with_reference(
- "帮我汇总3月13日国际新闻",
- NaiveDate::from_ymd_opt(2026, 3, 13).expect("valid date"),
- );
-
- assert_eq!(queries[0], "汇总3月13日国际新闻");
- assert!(queries.contains(&"2026年3月13日 国际新闻".to_string()));
- assert!(queries.contains(&"March 13 2026 international news".to_string()));
- assert!(queries.contains(&"March 13 2026 world headlines".to_string()));
- }
-
#[test]
fn merges_web_search_preflight_context_without_duplication() {
let merged = merge_system_prompt_with_web_search_preflight_context(
@@ -3137,14 +3227,8 @@ mod tests {
#[tokio::test]
async fn stream_message_reply_with_policy_should_surface_disabled_auto_compaction_limit_from_aster(
) {
- let session = SessionManager::create_session(
- PathBuf::default(),
- "lime-auto-compact-disabled".to_string(),
- SessionType::Hidden,
- )
- .await
- .expect("应创建测试 session");
- let agent = Agent::new();
+ let (store, session) = create_test_session_store("lime-auto-compact-disabled");
+ let agent = Agent::new().with_session_store(store.clone());
agent
.update_provider(Arc::new(ContextLengthExceededProvider), &session.id)
.await
@@ -3206,14 +3290,8 @@ mod tests {
#[tokio::test]
async fn stream_message_reply_with_policy_should_retry_empty_reply_without_tool_activity() {
- let session = SessionManager::create_session(
- PathBuf::default(),
- "lime-empty-reply-retry".to_string(),
- SessionType::Hidden,
- )
- .await
- .expect("应创建测试 session");
- let agent = Agent::new();
+ let (store, session) = create_test_session_store("lime-empty-reply-retry");
+ let agent = Agent::new().with_session_store(store.clone());
let attempts = Arc::new(AtomicUsize::new(0));
agent
.update_provider(
@@ -3265,16 +3343,85 @@ mod tests {
}
#[tokio::test]
- async fn stream_message_reply_with_policy_should_drain_inline_provider_error_and_mark_turn_failed(
- ) {
- let session = SessionManager::create_session(
- PathBuf::default(),
- "lime-inline-provider-error".to_string(),
- SessionType::Hidden,
+ async fn stream_message_reply_with_policy_should_return_cancelled_without_waiting_next_chunk() {
+ let (store, session) = create_test_session_store("lime-stream-cancel");
+ let agent = Agent::new().with_session_store(store.clone());
+ agent
+ .update_provider(Arc::new(SlowStreamingProvider), &session.id)
+ .await
+ .expect("应配置测试 provider");
+
+ let session_config = aster::agents::SessionConfig {
+ id: session.id.clone(),
+ thread_id: None,
+ turn_id: Some("turn-stream-cancel".to_string()),
+ schedule_id: None,
+ max_turns: None,
+ retry_config: None,
+ system_prompt: None,
+ system_prompt_override: None,
+ include_context_trace: None,
+ turn_context: None,
+ };
+ let policy = resolve_request_tool_policy(Some(false), false);
+ let cancel_token = CancellationToken::new();
+ let cancel_for_task = cancel_token.clone();
+ tokio::spawn(async move {
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ cancel_for_task.cancel();
+ });
+
+ let reply = tokio::time::timeout(
+ Duration::from_secs(2),
+ stream_message_reply_with_policy(
+ &agent,
+ Message::user().with_text("请流式输出"),
+ None,
+ session_config,
+ Some(cancel_token),
+ &policy,
+ |_| {},
+ ),
)
.await
- .expect("应创建测试 session");
- let agent = Agent::new();
+ .expect("取消后不应继续等待 provider 下一段")
+ .expect("取消应作为可识别执行结果返回");
+
+ assert!(reply.cancelled);
+ assert!(
+ !reply.text_output.contains("第二段"),
+ "取消后不应等待或拼接 provider 的后续分片"
+ );
+
+ let stored_session = store
+ .get_session(&session.id, true)
+ .await
+ .expect("应读取取消后的 session");
+ let stored_conversation = stored_session.conversation.expect("应有会话上下文");
+ let stored_messages = stored_conversation.iter().collect::>();
+ assert_eq!(
+ stored_messages
+ .iter()
+ .filter(|message| message.is_user_visible())
+ .count(),
+ 1,
+ "取消上下文标记不应作为普通用户消息展示"
+ );
+ assert!(
+ stored_messages.iter().any(|message| {
+ !message.is_user_visible()
+ && message.is_agent_visible()
+ && message.as_concat_text().contains("上一回合已被用户停止")
+ }),
+ "取消后应写入仅 Agent 可见的上下文标记,避免下一轮继续回答已停止请求"
+ );
+ }
+
+ #[tokio::test]
+ async fn stream_message_reply_with_policy_should_drain_inline_provider_error_and_mark_turn_failed(
+ ) {
+ let (store, session) = create_test_session_store("lime-inline-provider-error");
+ let agent = Agent::new().with_session_store(store.clone());
agent
.update_provider(Arc::new(AuthenticationErrorProvider), &session.id)
.await
diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs
index c3da7f202..e0dfe3126 100644
--- a/src-tauri/crates/agent/src/session_store.rs
+++ b/src-tauri/crates/agent/src/session_store.rs
@@ -138,7 +138,24 @@ fn should_load_subagent_runtime_context(
detail: &SessionDetail,
history_limit: Option,
) -> bool {
- should_load_runtime_overlay(detail, history_limit)
+ history_limit.is_none()
+ || detail.is_persisted_empty()
+ || !detail.turns.is_empty()
+ || !detail.items.is_empty()
+}
+
+fn should_load_runtime_overlay_for_runtime_detail(
+ detail: &SessionDetail,
+ history_limit: Option,
+) -> bool {
+ detail.is_persisted_empty() || should_load_runtime_overlay(detail, history_limit)
+}
+
+fn should_load_subagent_runtime_context_for_runtime_detail(
+ detail: &SessionDetail,
+ history_limit: Option,
+) -> bool {
+ should_load_subagent_runtime_context(detail, history_limit)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -801,6 +818,18 @@ fn sort_runtime_items(items: &mut [AgentThreadItem], turn_started_at: &HashMap bool {
+ matches!(
+ persisted.status,
+ AgentThreadTurnStatus::Completed
+ | AgentThreadTurnStatus::Failed
+ | AgentThreadTurnStatus::Aborted
+ ) && matches!(runtime.status, AgentThreadTurnStatus::Running)
+}
+
fn apply_aster_runtime_snapshot(detail: &mut SessionDetail, snapshot: &SessionRuntimeSnapshot) {
if let Some(thread) = snapshot.threads.first() {
detail.thread_id = thread.thread.id.clone();
@@ -817,7 +846,15 @@ fn apply_aster_runtime_snapshot(detail: &mut SessionDetail, snapshot: &SessionRu
.collect::>();
for thread in &snapshot.threads {
for turn in &thread.turns {
- turns_by_id.insert(turn.id.clone(), project_turn_runtime(turn.clone()));
+ let runtime_turn = project_turn_runtime(turn.clone());
+ if turns_by_id
+ .get(&runtime_turn.id)
+ .map(|persisted| should_preserve_persisted_terminal_turn(persisted, &runtime_turn))
+ .unwrap_or(false)
+ {
+ continue;
+ }
+ turns_by_id.insert(runtime_turn.id.clone(), runtime_turn);
}
}
detail.turns = turns_by_id.into_values().collect();
@@ -1518,24 +1555,11 @@ pub async fn get_runtime_session_detail_with_history_page(
return Ok(detail);
}
- if detail.is_persisted_empty() {
- let total_ms = started_at.elapsed().as_millis();
- tracing::info!(
- "[SessionStore] get_runtime_session_detail 空会话快路径完成: session_id={}, total_ms={}, detail_ms={}, archive_check_ms={}, history_limit={:?}, history_offset={}, before_message_id={:?}",
- session_id,
- total_ms,
- detail_ms,
- archive_check_ms,
- history_limit,
- history_offset,
- before_message_id,
- );
- return Ok(detail);
- }
+ let was_persisted_empty = detail.is_persisted_empty();
let load_runtime_overlay = history_offset == 0
&& before_message_id.is_none()
- && should_load_runtime_overlay(&detail, history_limit);
+ && should_load_runtime_overlay_for_runtime_detail(&detail, history_limit);
let overlay_started_at = Instant::now();
let (session, runtime_snapshot) = if load_runtime_overlay {
@@ -1632,7 +1656,8 @@ pub async fn get_runtime_session_detail_with_history_page(
let load_subagent_runtime_context = history_offset == 0
&& before_message_id.is_none()
- && should_load_subagent_runtime_context(&detail, history_limit);
+ && (was_persisted_empty
+ || should_load_subagent_runtime_context_for_runtime_detail(&detail, history_limit));
let child_subagents_started_at = Instant::now();
if load_subagent_runtime_context {
@@ -1667,6 +1692,24 @@ pub async fn get_runtime_session_detail_with_history_page(
}
}
let parent_context_ms = parent_context_started_at.elapsed().as_millis();
+
+ if was_persisted_empty && detail.is_persisted_empty() {
+ let total_ms = started_at.elapsed().as_millis();
+ tracing::info!(
+ "[SessionStore] get_runtime_session_detail 空会话快路径完成: session_id={}, total_ms={}, detail_ms={}, archive_check_ms={}, overlay_ms={}, child_subagents_ms={}, parent_context_ms={}, history_limit={:?}, history_offset={}, before_message_id={:?}",
+ session_id,
+ total_ms,
+ detail_ms,
+ archive_check_ms,
+ overlay_ms,
+ child_subagents_ms,
+ parent_context_ms,
+ history_limit,
+ history_offset,
+ before_message_id,
+ );
+ return Ok(detail);
+ }
let total_ms = started_at.elapsed().as_millis();
tracing::info!(
@@ -2060,8 +2103,8 @@ fn convert_agent_message_with_options(
mod tests {
use super::*;
use aster::session::{
- SessionType as AsterSessionType, SubagentSessionMetadata, ThreadRuntime,
- ThreadRuntimeSnapshot, TurnRuntime, TurnStatus,
+ SessionRuntimeSnapshot, SessionType as AsterSessionType, SubagentSessionMetadata,
+ ThreadRuntime, ThreadRuntimeSnapshot, TurnRuntime, TurnStatus,
};
use chrono::{Duration, Utc};
use lime_core::agent::types::{FunctionCall, ImageUrl, ToolCall};
@@ -2156,6 +2199,27 @@ mod tests {
}
}
+ fn build_empty_runtime_detail() -> SessionDetail {
+ SessionDetail {
+ id: "session-empty-runtime".to_string(),
+ name: "空运行态会话".to_string(),
+ created_at: 0,
+ updated_at: 0,
+ thread_id: "session-empty-runtime".to_string(),
+ model: Some("agent:test".to_string()),
+ working_dir: None,
+ workspace_id: None,
+ messages: Vec::new(),
+ execution_strategy: Some("react".to_string()),
+ execution_runtime: None,
+ turns: Vec::new(),
+ items: Vec::new(),
+ todo_items: Vec::new(),
+ child_subagent_sessions: Vec::new(),
+ subagent_parent_context: None,
+ }
+ }
+
fn insert_test_session_with_message(
db: &DbConnection,
session_id: &str,
@@ -2224,6 +2288,50 @@ mod tests {
assert!(!should_load_runtime_overlay_at(&detail, Some(80), now));
}
+ #[test]
+ fn should_probe_runtime_overlay_for_empty_limited_history() {
+ let detail = build_empty_runtime_detail();
+
+ assert!(detail.is_persisted_empty());
+ assert!(should_load_runtime_overlay_for_runtime_detail(
+ &detail,
+ Some(20)
+ ));
+ }
+
+ #[test]
+ fn apply_runtime_snapshot_should_not_regress_aborted_turn_to_running() {
+ let mut detail = build_detail_with_turn_status(AgentThreadTurnStatus::Aborted);
+ let thread_id = detail.thread_id.clone();
+ let turn_id = detail.turns[0].id.clone();
+ let mut runtime_turn = TurnRuntime::new(
+ turn_id.clone(),
+ detail.id.clone(),
+ thread_id.clone(),
+ Some("测试".to_string()),
+ None,
+ );
+ runtime_turn.status = TurnStatus::Running;
+ let snapshot = SessionRuntimeSnapshot {
+ session_id: detail.id.clone(),
+ threads: vec![ThreadRuntimeSnapshot {
+ thread: ThreadRuntime::new(
+ thread_id,
+ detail.id.clone(),
+ std::path::PathBuf::from("/tmp/lime-runtime-overlay-test"),
+ ),
+ turns: vec![runtime_turn],
+ items: Vec::new(),
+ }],
+ };
+
+ apply_aster_runtime_snapshot(&mut detail, &snapshot);
+
+ assert_eq!(detail.turns.len(), 1);
+ assert_eq!(detail.turns[0].id, turn_id);
+ assert_eq!(detail.turns[0].status, AgentThreadTurnStatus::Aborted);
+ }
+
#[test]
fn should_load_subagent_runtime_context_for_full_history() {
let detail = build_detail_with_turn_status(AgentThreadTurnStatus::Completed);
@@ -2232,10 +2340,10 @@ mod tests {
}
#[test]
- fn should_skip_subagent_runtime_context_for_completed_limited_history() {
+ fn should_load_subagent_runtime_context_for_completed_limited_history() {
let detail = build_detail_with_turn_status(AgentThreadTurnStatus::Completed);
- assert!(!should_load_subagent_runtime_context(&detail, Some(80)));
+ assert!(should_load_subagent_runtime_context(&detail, Some(80)));
}
#[test]
@@ -2245,6 +2353,17 @@ mod tests {
assert!(should_load_subagent_runtime_context(&detail, Some(80)));
}
+ #[test]
+ fn should_probe_subagent_context_for_empty_limited_history() {
+ let detail = build_empty_runtime_detail();
+
+ assert!(detail.is_persisted_empty());
+ assert!(should_load_subagent_runtime_context_for_runtime_detail(
+ &detail,
+ Some(20)
+ ));
+ }
+
fn build_test_subagent_session(
session_id: &str,
name: &str,
diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs
index bfb78b9b2..c7fc0532f 100644
--- a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs
+++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs
@@ -144,6 +144,8 @@ const SUBAGENT_TEAMMATE_ALLOWED_TOOL_NAMES: [&str; 5] = [
"CronList",
"CronDelete",
];
+const CANCELLED_TURN_CONTEXT_MARKER: &str =
+ "上一回合已被用户停止,不要继续回答被停止的请求;等待并仅处理后续用户消息。";
const AUTO_COMPACTION_DISABLED_CONTEXT_LIMIT_TEXT: &str =
"Automatic compaction is disabled for this turn. The conversation reached the context limit. Compact the session manually or start a new session before retrying.";
const PROPOSED_PLAN_OPEN: &str = "";
@@ -160,6 +162,12 @@ const FILE_ARTIFACT_METADATA_KEYS: [&str; 9] = [
"absolute_path",
];
+fn cancelled_turn_context_marker_message() -> Message {
+ Message::assistant()
+ .with_text(CANCELLED_TURN_CONTEXT_MARKER)
+ .agent_only()
+}
+
#[derive(Debug, Clone)]
struct ResolvedOutputSchema {
schema: Value,
@@ -3545,6 +3553,11 @@ impl Agent {
}
Err(err) => {
turn_status = if is_token_cancelled(&cancel_token) {
+ self.store_add_message(
+ &scoped_session_config.id,
+ &cancelled_turn_context_marker_message(),
+ )
+ .await?;
TurnStatus::Aborted
} else {
TurnStatus::Failed
@@ -3687,7 +3700,24 @@ impl Agent {
let mut tools_updated = false;
let mut did_recovery_compact_this_iteration = false;
- while let Some(next) = stream.next().await {
+ loop {
+ let next = if cancel_token.is_some() {
+ match tokio::time::timeout(Duration::from_millis(100), stream.next()).await
+ {
+ Ok(next) => next,
+ Err(_) => {
+ if is_token_cancelled(&cancel_token) {
+ break;
+ }
+ continue;
+ }
+ }
+ } else {
+ stream.next().await
+ };
+ let Some(next) = next else {
+ break;
+ };
if is_token_cancelled(&cancel_token) {
break;
}
@@ -4062,7 +4092,11 @@ impl Agent {
).await?;
}
let mut exit_chat = false;
- if no_tools_called {
+ if is_token_cancelled(&cancel_token) {
+ messages_to_add.clear();
+ messages_to_add.push(cancelled_turn_context_marker_message());
+ exit_chat = true;
+ } else if no_tools_called {
if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() {
if final_output_tool.final_output.is_none() {
warn!("Final output tool has not been called yet. Continuing agent loop.");
diff --git a/src-tauri/crates/core/src/config/tests.rs b/src-tauri/crates/core/src/config/tests.rs
index 2f7751364..04edf7207 100644
--- a/src-tauri/crates/core/src/config/tests.rs
+++ b/src-tauri/crates/core/src/config/tests.rs
@@ -189,7 +189,7 @@ fn arb_config() -> impl Strategy {
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
- language: "zh".to_string(),
+ language: "zh-CN".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
tool_calling: crate::config::ToolCallingConfig::default(),
workspace_preferences: WorkspacePreferencesConfig::default(),
@@ -431,7 +431,7 @@ fn arb_valid_config() -> impl Strategy {
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
- language: "zh".to_string(),
+ language: "zh-CN".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
tool_calling: crate::config::ToolCallingConfig::default(),
workspace_preferences: WorkspacePreferencesConfig::default(),
@@ -483,7 +483,7 @@ fn arb_invalid_config() -> impl Strategy {
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
- language: "zh".to_string(),
+ language: "zh-CN".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
tool_calling: crate::config::ToolCallingConfig::default(),
workspace_preferences: WorkspacePreferencesConfig::default(),
diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs
index 11370c561..f38cf60b5 100644
--- a/src-tauri/crates/core/src/config/types.rs
+++ b/src-tauri/crates/core/src/config/types.rs
@@ -423,7 +423,10 @@ pub struct Config {
/// 关闭时最小化到托盘(而不是退出应用)
#[serde(default = "default_minimize_to_tray")]
pub minimize_to_tray: bool,
- /// 用户界面语言 ("zh" 或 "en")
+ /// 用户界面语言。
+ ///
+ /// 新配置使用 BCP 47 风格 locale(如 "zh-CN"、"en-US")或 "auto";
+ /// 历史配置中的 "zh" / "en" 仅作为前端读取兼容值保留。
#[serde(default = "default_language")]
pub language: String,
/// 模型配置(动态加载 Provider 和模型列表)
@@ -1328,7 +1331,7 @@ fn default_minimize_to_tray() -> bool {
}
fn default_language() -> String {
- "zh".to_string()
+ "zh-CN".to_string()
}
/// 服务器配置
diff --git a/src-tauri/crates/mcp/src/manager.rs b/src-tauri/crates/mcp/src/manager.rs
index 3004a546a..8283dabd5 100644
--- a/src-tauri/crates/mcp/src/manager.rs
+++ b/src-tauri/crates/mcp/src/manager.rs
@@ -888,7 +888,7 @@ impl McpClientManager {
let tags = tool.tags.as_deref().unwrap_or(&[]);
let inner_tool_name = extract_mcp_runtime_inner_tool_name(&tool.server_name, &tool.name)
.unwrap_or(&tool.name);
- let score = [
+ let mut score = [
lime_core::tool_calling::score_tool_match(&tool.name, &tool.description, tags, query),
lime_core::tool_calling::score_tool_match(
inner_tool_name,
@@ -901,6 +901,10 @@ impl McpClientManager {
.max()
.unwrap_or(0);
+ if inner_tool_name.eq_ignore_ascii_case(query) {
+ score += 10;
+ }
+
if tool.always_visible.unwrap_or(false) {
return score + 5;
}
diff --git a/src-tauri/crates/media-runtime/src/lib.rs b/src-tauri/crates/media-runtime/src/lib.rs
index 1d3271b97..8f3ab1586 100644
--- a/src-tauri/crates/media-runtime/src/lib.rs
+++ b/src-tauri/crates/media-runtime/src/lib.rs
@@ -1635,7 +1635,7 @@ fn parse_sse_event(raw_event: &str) -> Option<(String, String)> {
fn extract_responses_image_generation_result(
response_body_raw: &str,
-) -> Result<(Value, Value), TaskErrorRecord> {
+) -> Result<(Value, Value), Box> {
let mut event_count = 0u32;
let mut output_item_count = 0u32;
@@ -1696,12 +1696,12 @@ fn extract_responses_image_generation_result(
));
}
- Err(build_image_task_error(
+ Err(Box::new(build_image_task_error(
"image_result_empty",
"Responses 图片生成已返回成功,但 SSE 流里没有 image_generation_call.result",
false,
"result",
- ))
+ )))
}
fn summarize_response_body(body: &str) -> String {
@@ -1967,7 +1967,7 @@ async fn request_single_responses_image_generation(
));
}
- extract_responses_image_generation_result(&response_body_raw)
+ extract_responses_image_generation_result(&response_body_raw).map_err(|error| *error)
}
async fn request_single_image_generation_for_executor(
diff --git a/src-tauri/crates/server/src/handlers/image_api_provider.rs b/src-tauri/crates/server/src/handlers/image_api_provider.rs
index 0f919aa1e..6c9a2faf2 100644
--- a/src-tauri/crates/server/src/handlers/image_api_provider.rs
+++ b/src-tauri/crates/server/src/handlers/image_api_provider.rs
@@ -1895,7 +1895,8 @@ mod tests {
});
let client = Client::builder()
- .timeout(Duration::from_millis(250))
+ .no_proxy()
+ .timeout(Duration::from_secs(2))
.build()
.expect("build client");
let result = request_fal_queue_images_with_options(
@@ -1911,8 +1912,14 @@ mod tests {
.await;
let error = result.expect_err("queue timeout should fail");
- assert!(error.contains("Fal 队列任务超过"));
- assert!(error.contains("IN_QUEUE"));
+ assert!(
+ error.contains("Fal 队列任务超过"),
+ "unexpected queue timeout error: {error}"
+ );
+ assert!(
+ error.contains("IN_QUEUE"),
+ "unexpected queue status error: {error}"
+ );
server.abort();
}
diff --git a/src-tauri/resources/default-skills/cover_generate/SKILL.md b/src-tauri/resources/default-skills/cover_generate/SKILL.md
index 2d110f37b..a9d21f851 100644
--- a/src-tauri/resources/default-skills/cover_generate/SKILL.md
+++ b/src-tauri/resources/default-skills/cover_generate/SKILL.md
@@ -1,11 +1,11 @@
---
name: cover_generate
description: 为文章或视频生成平台封面图,并写回主稿(封面场景优先使用本技能)。
-allowed-tools: social_generate_cover_image, Bash, lime_create_cover_generation_task
+allowed-tools: lime_create_cover_generation_task
metadata:
lime_argument_hint: 输入平台、标题、受众、视觉风格、尺寸要求。
lime_when_to_use: 用户明确要求“封面图”时使用,不要被普通配图任务替代。
- lime_version: 1.4.0
+ lime_version: 1.4.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: media
@@ -23,12 +23,11 @@ metadata:
- 若结构化上下文里已有 `cover_task`,必须优先复用其中的 `prompt`、`raw_text`、`title`、`platform`、`size`、`style`、`session_id`、`project_id`、`content_id`、`entry_source` 等字段。
- 根据平台特性控制视觉:主体清晰、构图简洁、避免密集小字。
- 默认尺寸 `1024x1024`,用户指定时优先按用户要求。
-- 使用 `social_generate_cover_image` 生成封面。
-- 生成成功后,优先调用 `Bash` 执行 `lime task create cover --json` 创建任务,并把 `social_generate_cover_image` 返回的 `image_url` 作为 `--image-url` 传入;如当前环境只暴露 `lime media cover generate --json`,也可以使用。
-- 创建任务时尽量透传 `--raw-text`、`--title`、`--platform`、`--style`、`--size`、`--session-id`、`--project-id`、`--content-id`、`--entry-source` 等字段,不要丢掉工作台上下文。
-- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_cover_generation_task`。
-- 任务结果必须兼容 `lime task create cover --json` 的任务文件契约。
-- 封面生成失败时不能中断:保留占位、给出重试建议,并使用 `lime_create_cover_generation_task` 提交失败任务记录。
+- 必须直接调用 `lime_create_cover_generation_task` 创建真实封面任务记录,不要通过 `Bash` 拼接 CLI 命令,也不要自行生成 HTML/SVG/Markdown 假封面。
+- 调用 `lime_create_cover_generation_task` 时,参数必须直接使用扁平任务对象:`prompt`、`rawText`、`title`、`platform`、`style`、`size`、`sessionId`、`projectId`、`contentId`、`entrySource`;不要包成 `{"cover_task": ...}`,也不要把整个任务对象序列化成字符串。
+- 创建任务时尽量透传 `cover_task` 中的 `raw_text`、`title`、`platform`、`style`、`size`、`session_id`、`project_id`、`content_id`、`entry_source` 等字段,不要丢掉工作台上下文。
+- 任务结果必须兼容标准 `cover_generate` 任务文件契约。
+- 封面生成失败时不能伪造完成态;如参数不足,最多追问 1 个关键问题。
## 输出格式(固定)
diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs
index 7dab614c3..48f847b54 100644
--- a/src-tauri/src/commands/agent_cmd.rs
+++ b/src-tauri/src/commands/agent_cmd.rs
@@ -22,9 +22,24 @@ use futures::StreamExt;
use lime_agent::merge_system_prompt_with_runtime_agents;
use serde::{Deserialize, Serialize};
use serde_json::Value;
+use std::fmt::Display;
+use std::future::Future;
+use std::sync::{Arc, OnceLock};
+use std::time::Duration;
use tauri::{AppHandle, State};
+use tokio::sync::{OwnedSemaphorePermit, Semaphore};
+use tokio::time::Instant;
+use tokio_util::sync::CancellationToken;
use uuid::Uuid;
+const TITLE_GENERATION_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024;
+const TITLE_GENERATION_RUNTIME_SHUTDOWN_TIMEOUT_SECS: u64 = 2;
+const TITLE_GENERATION_TOTAL_TIMEOUT_SECS: u64 = 45;
+const TITLE_GENERATION_STREAM_START_TIMEOUT_SECS: u64 = 20;
+const TITLE_GENERATION_STREAM_IDLE_TIMEOUT_SECS: u64 = 20;
+const TITLE_GENERATION_QUEUE_TIMEOUT_SECS: u64 = 2;
+const TITLE_GENERATION_MAX_CONCURRENCY: usize = 2;
+
const TITLE_FALLBACK_PROVIDER_CHAIN: [(&str, &str); 4] = [
("deepseek", "deepseek-chat"),
("openai", "gpt-4o-mini"),
@@ -187,6 +202,255 @@ fn force_direct_answer_tool_surface(metadata: &mut Option) {
);
}
+#[derive(Debug, Clone, Copy)]
+struct TitleGenerationLimits {
+ total_timeout: Duration,
+ stream_start_timeout: Duration,
+ stream_idle_timeout: Duration,
+}
+
+impl TitleGenerationLimits {
+ fn production() -> Self {
+ Self {
+ total_timeout: Duration::from_secs(TITLE_GENERATION_TOTAL_TIMEOUT_SECS),
+ stream_start_timeout: Duration::from_secs(TITLE_GENERATION_STREAM_START_TIMEOUT_SECS),
+ stream_idle_timeout: Duration::from_secs(TITLE_GENERATION_STREAM_IDLE_TIMEOUT_SECS),
+ }
+ }
+
+ #[cfg(test)]
+ fn new(
+ total_timeout: Duration,
+ stream_start_timeout: Duration,
+ stream_idle_timeout: Duration,
+ ) -> Self {
+ Self {
+ total_timeout,
+ stream_start_timeout,
+ stream_idle_timeout,
+ }
+ }
+}
+
+struct TitleGenerationCancelOnDrop {
+ token: CancellationToken,
+}
+
+impl TitleGenerationCancelOnDrop {
+ fn new(token: CancellationToken) -> Self {
+ Self { token }
+ }
+}
+
+impl Drop for TitleGenerationCancelOnDrop {
+ fn drop(&mut self) {
+ self.token.cancel();
+ }
+}
+
+fn title_generation_semaphore() -> Arc {
+ static TITLE_GENERATION_SEMAPHORE: OnceLock> = OnceLock::new();
+ TITLE_GENERATION_SEMAPHORE
+ .get_or_init(|| Arc::new(Semaphore::new(TITLE_GENERATION_MAX_CONCURRENCY)))
+ .clone()
+}
+
+async fn acquire_title_generation_permit() -> Result {
+ let semaphore = title_generation_semaphore();
+ match tokio::time::timeout(
+ Duration::from_secs(TITLE_GENERATION_QUEUE_TIMEOUT_SECS),
+ semaphore.acquire_owned(),
+ )
+ .await
+ {
+ Ok(Ok(permit)) => Ok(permit),
+ Ok(Err(_)) => Err("标题生成并发队列已关闭".to_string()),
+ Err(_) => Err(format!(
+ "标题生成繁忙,等待超过 {} 后已降级",
+ format_title_generation_duration(Duration::from_secs(
+ TITLE_GENERATION_QUEUE_TIMEOUT_SECS
+ ))
+ )),
+ }
+}
+
+fn format_title_generation_duration(duration: Duration) -> String {
+ if duration.as_secs() > 0 {
+ format!("{}秒", duration.as_secs())
+ } else {
+ format!("{}毫秒", duration.as_millis())
+ }
+}
+
+fn title_generation_timeout_error(kind: &str, timeout: Duration) -> String {
+ format!(
+ "标题生成{}超时({})",
+ kind,
+ format_title_generation_duration(timeout)
+ )
+}
+
+fn title_generation_remaining_total(
+ started_at: Instant,
+ limits: TitleGenerationLimits,
+) -> Option {
+ match limits.total_timeout.checked_sub(started_at.elapsed()) {
+ Some(remaining) if !remaining.is_zero() => Some(remaining),
+ _ => None,
+ }
+}
+
+async fn await_title_generation_stream_start(
+ stream_future: F,
+ cancel_token: CancellationToken,
+ started_at: Instant,
+ limits: TitleGenerationLimits,
+) -> Result
+where
+ F: Future