Преглед на файлове

Merge origin/master into worktree/composer-plus-menu

creatixchu преди 1 седмица
родител
ревизия
883e7f80fc
променени са 100 файла, в които са добавени 1025 реда и са изтрити 1951 реда
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml
  2. 2 3
      .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
  3. 2 3
      .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml
  5. 3 1
      .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md
  6. 3 1
      .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md
  7. 6 0
      .agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.i18n.yaml
  8. 25 0
      .agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md
  9. 25 0
      .agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.zh.md
  10. 6 0
      .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml
  11. 21 0
      .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md
  12. 21 0
      .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  14. 3 1
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  15. 3 1
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  16. 0 41
      .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md
  17. 0 41
      .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md
  18. 0 55
      .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md
  19. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
  20. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
  21. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
  22. 2 2
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
  23. 1 1
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
  24. 1 1
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
  25. 3 3
      .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml
  26. 45 0
      .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md
  27. 45 0
      .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md
  28. 0 59
      .github/review-ownership/CODEOWNERS
  29. 6 36
      .github/review-ownership/README.md
  30. 2 5
      .github/review-ownership/check-approval.mjs
  31. 3 2
      .github/review-ownership/check-approval.test.mjs
  32. 0 660
      .github/review-ownership/request-review.mjs
  33. 0 869
      .github/review-ownership/request-review.test.mjs
  34. 0 31
      .github/workflows/request-review.yml
  35. 1 3
      .github/workflows/weighted-approval-review-event.yml
  36. 2 2
      apps/web/tests/README.i18n.yaml
  37. 4 0
      apps/web/tests/README.md
  38. 4 0
      apps/web/tests/README.zh.md
  39. 1 1
      apps/web/tests/clickable-links-gallery.e2e.ts
  40. 72 0
      apps/web/tests/composer-placeholder.e2e.ts
  41. 6 1
      apps/web/tests/details-session-lifecycle.e2e.ts
  42. 1 1
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  43. 8 0
      apps/web/tests/expected/composer-placeholder/visibility.expected.md
  44. 2 2
      apps/web/tests/expected/settings-chrome/dialog.expected.md
  45. 20 1
      apps/web/tests/github-ready-review.e2e.ts
  46. 20 16
      apps/web/tests/message-feedback-layout.e2e.ts
  47. 121 0
      apps/web/tests/present-svg.e2e.ts
  48. 1 1
      apps/web/tests/produced-file-mentions.e2e.ts
  49. 19 5
      apps/web/tests/produced-files.e2e.ts
  50. 46 12
      apps/web/tests/queue-image.e2e.ts
  51. 7 7
      apps/web/tests/settings-chrome.e2e.ts
  52. 19 2
      apps/web/tests/steering.e2e.ts
  53. 25 6
      apps/web/tests/workspace-management.e2e.ts
  54. 2 0
      apps/web/tsconfig.json
  55. 2 2
      benchmarks/agent-continuation/README.i18n.yaml
  56. 1 1
      benchmarks/agent-continuation/README.md
  57. 1 1
      benchmarks/agent-continuation/README.zh.md
  58. 7 1
      benchmarks/agent-continuation/agent-continuation.bench.ts
  59. 2 2
      docs/i18n/README.i18n.yaml
  60. 1 1
      docs/i18n/README.md
  61. 1 1
      docs/i18n/README.zh.md
  62. 2 2
      docs/tool-catalog.i18n.yaml
  63. 1 1
      docs/tool-catalog.md
  64. 1 1
      docs/tool-catalog.zh.md
  65. 0 1
      package.json
  66. 2 2
      packages/client/ui-chat/README.i18n.yaml
  67. 0 0
      packages/client/ui-chat/README.md
  68. 0 0
      packages/client/ui-chat/README.zh.md
  69. 2 2
      packages/client/ui-chat/src/client/locale.ts
  70. 10 3
      packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
  71. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  72. 1 1
      packages/client/ui-conversation/README.md
  73. 1 1
      packages/client/ui-conversation/README.zh.md
  74. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  75. 43 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  76. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  77. 1 1
      packages/client/ui-deliverables/README.md
  78. 1 1
      packages/client/ui-deliverables/README.zh.md
  79. 2 2
      packages/client/ui-deliverables/src/client/locales.ts
  80. 3 3
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  81. 2 2
      packages/client/ui-model-selection/README.i18n.yaml
  82. 1 1
      packages/client/ui-model-selection/README.md
  83. 1 1
      packages/client/ui-model-selection/README.zh.md
  84. 16 1
      packages/client/ui-model-selection/src/client/index.ts
  85. 4 0
      packages/client/ui-model-selection/src/client/locales.ts
  86. 35 8
      packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
  87. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  88. 1 1
      packages/client/ui-settings-models/README.md
  89. 1 1
      packages/client/ui-settings-models/README.zh.md
  90. 21 6
      packages/client/ui-settings-models/src/client/CustomProviderCard.tsx
  91. 2 0
      packages/client/ui-settings-models/src/client/locales.ts
  92. 67 0
      packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
  93. 95 0
      packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts
  94. 2 2
      packages/experimental/webworker-runtime/README.i18n.yaml
  95. 2 0
      packages/experimental/webworker-runtime/README.md
  96. 2 0
      packages/experimental/webworker-runtime/README.zh.md
  97. 7 2
      packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts
  98. 50 2
      packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts
  99. 2 2
      packages/fs/tool-present/README.i18n.yaml
  100. 1 1
      packages/fs/tool-present/README.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
-2026-08-04-draft-provider-endpoint-interrogation.md: 462b8ba3e771018126aa6483a8cd72999361144d
-2026-08-04-draft-provider-endpoint-interrogation.zh.md: c7612513b95bc3dc9736b9bfaf70cca26a776181
+2026-08-04-draft-provider-endpoint-interrogation.md: 469ab38b0f5609808a9df03237d4d21c0080d843
+2026-08-04-draft-provider-endpoint-interrogation.zh.md: 74c6bc7ed73572d5e99ea973c9ea94d22319cbdf

Файловите разлики са ограничени, защото са твърде много
+ 2 - 3
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md


Файловите разлики са ограничени, защото са твърде много
+ 2 - 3
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md


+ 2 - 2
.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md
-2026-08-23-locale-owned-client-ui-copy.md: 623189b8faa45f0ef2a1b724d3e050d33f7d59bd
-2026-08-23-locale-owned-client-ui-copy.zh.md: e2a6f49de89e6e6ba2734dd69cf536a3e13d2aea
+2026-08-23-locale-owned-client-ui-copy.md: bc4d8efbce1f246f712e3afe6af40d1518e8cb17
+2026-08-23-locale-owned-client-ui-copy.zh.md: ce996a4aafc392e739bec2eccd8714cfeff7d8e9

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md

@@ -12,6 +12,8 @@ Typed locale namespaces and bilingual dictionary parity proved that registered d
 
 **Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated.
 
+Product-owned catalog descriptions follow the same rule. The client maps an exact built-in provider, model, and description to a locale key; a changed description or an external provider description remains provider data and renders verbatim.
+
 **Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionIndicator` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning.
 
 **Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state.
@@ -22,7 +24,7 @@ The product-authored error and design-literal exclusions, primitive defaults, an
 
 ## Verification
 
-The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface.
+The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client suites exercise direct translated seats, locale-prop adapters, and built-in catalog-description localization without altering external descriptions. The assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface.
 
 ## Alternatives considered
 

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md

@@ -12,6 +12,8 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整
 
 **所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。
 
+产品持有的目录说明遵循同一规则。client 将完全匹配的内置提供方、模型与说明映射到 locale key;说明发生变化或来自外部提供方时,它仍是提供方数据并原样渲染。
+
 **Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionIndicator` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。
 
 **本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。
@@ -22,7 +24,7 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整
 
 ## Verification
 
-AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。
+AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 测试覆盖直接翻译席位、locale prop 适配器,以及不改变外部说明的内置目录说明本地化。组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。
 
 ## Alternatives considered
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md
+2026-09-07-win32-picker-foreground-alt-key.md: cd1bae56845c74bf8fb0b3c0e6b8e6bab8032daf
+2026-09-07-win32-picker-foreground-alt-key.zh.md: 8cbacaee97892b2ba6feb3d0250212fa9d393376

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md

@@ -0,0 +1,25 @@
+# Agent Note: Foreground activation for the Win32 picker via a synthesized Alt press
+
+Status: implemented
+
+English | [中文](2026-09-07-win32-picker-foreground-alt-key.zh.md)
+
+## Problem
+
+The web GUI host picks a workspace directory through the native Win32 folder dialog, which runs in a child process the host spawns (issue #3543). Windows grants the foreground only to the foreground process, to a process it started, or to a process that received recent input; a child of a background server process qualifies for none of these, so the dialog that `Show` opens sits behind every visible window even though it is the child's first window. The first-window activation assumption behind the spawn design ([archived feature note](../../archived/feature/2026-08-02-win32-in-process-folder-dialog.md)) holds only when the spawner chain owns the console foreground, as in a console-launched CLI.
+
+## Decision
+
+`runFolderDialog` calls a new `pressAltForForeground` binding between the `showing` notice and the blocking `Show`. The binding synthesizes one Alt press (`keybd_event` with `VK_MENU`, down then up) on the dialog thread, which makes Windows count this process as the most recent input owner — one of the documented grounds for foreground activation — so the dialog window `Show` creates activates as foreground. The bindings module already loads koffi's `user32`, so the change adds one function fetch and two invocations. The press is unconditional on Windows. When the process already holds foreground rights (a console-launched CLI), the dialog activates anyway and the press is inert; the window focused at that moment still receives the lone Alt and may briefly highlight its menu bar. Environments that suppress injected input (secure desktops, restricted remote sessions, an elevated foreground window) leave the dialog behind other windows, and the package README records that limit.
+
+## Alternatives considered
+
+**Custom URL protocol with a browser click gesture.** Draft PR #3544 granted the foreground by navigating the foreground browser to a registered `dsh-picker://` URL, which makes the shell launch the dialog process as a foreground descendant. The grant is deterministic by design, but the mechanism spans registry and VBS launcher files, a protocol entry point, a picker-result HTTP route with per-boot tokens, and a first-use browser confirmation, and it adds a server route the browser can reach. The synthesized press removes that entire surface.
+
+**AllowSetForegroundWindow from the clicker.** The API must be called by the current foreground process — the browser — and may name only one permitted process; the spawner cannot invoke it on the browser's behalf.
+
+**AttachThreadInput to the focused thread.** Attaching the dialog thread to the focused window's thread also bypasses the foreground restriction and avoids the keystroke side effect, but it is equally undocumented, needs the focused window's thread id at show time, and fails when the focused window belongs to a higher-integrity process; it was not prototyped.
+
+## Consequences
+
+The picker keeps its single spawned-child design and gains foreground behavior in the background-host case at the cost of one koffi call pair. The bindings spec pins the Alt down/up sequence and its position immediately before `Show` over the fake COM world; the logic spec pins the full showing → press → `Show` order. The Windows CI lane still opens and abort-closes a real dialog with the press present but asserts no activation. Validation on a Windows 11 machine with the foreground lock forced to its maximum reproduced the failure without the press (dialog behind other windows) and the foreground dialog with it in five of five repeat runs; Windows 10 is unverified. Synthesized input is consumed asynchronously by the raw input thread, so the activation grant is in principle race-prone; no miss appeared across the repeat runs, and fragile environments stay a documented package limitation rather than a second mechanism, because the browse backend remains the composition-level answer where native picking cannot be trusted.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 通过合成的 Alt 按键让 Win32 选择器获得前台激活
+
+Status: implemented
+
+[English](2026-09-07-win32-picker-foreground-alt-key.md) | 中文
+
+## Problem
+
+web GUI 宿主通过原生 Win32 文件夹对话框选择工作区目录,对话框运行在宿主 spawn 的子进程中(issue #3543)。Windows 只把前台授予前台进程、由前台进程启动的进程或最近收到输入的进程;后台服务器进程的子进程三者都不满足,因此 `Show` 打开的对话框即使属于子进程的首个窗口,也会落在所有可见窗口之后。spawn 设计背后的"首窗口即激活"假设([归档功能记录](../../archived/feature/2026-08-02-win32-in-process-folder-dialog.md))只在 spawn 链持有控制台前台时成立,例如控制台启动的 CLI。
+
+## Decision
+
+`runFolderDialog` 在 `showing` 通知与阻塞式 `Show` 之间调用新增的 `pressAltForForeground` 绑定。该绑定在对话框线程上合成一次 Alt 按键(`keybd_event` 携带 `VK_MENU`,先按下后抬起),使 Windows 把该进程计为最近的输入所有者——文档记载的允许前台激活的理由之一——于是 `Show` 创建的对话框窗口以前台方式激活。bindings 模块已经加载 koffi 的 `user32`,因此改动只增加一次函数获取与两次调用。该按键在 Windows 上无条件执行。当进程已经持有前台权利(控制台启动的 CLI)时,对话框本来就会激活,按键不起作用;此刻获得焦点的窗口仍会收到这一次单独的 Alt,可能短暂高亮其菜单栏。在合成输入被抑制的环境(安全桌面、受限远程会话、提权前台窗口)中,对话框仍会落在其他窗口后面,包 README 记录了该限制。
+
+## Alternatives considered
+
+**自定义 URL 协议加浏览器点击手势。** 草稿 PR #3544 通过让前台浏览器导航到已注册的 `dsh-picker://` URL 来授予前台,使 shell 把对话框进程作为前台进程的后代启动。该授权按设计具有确定性,但机制横跨注册表与 VBS 启动器文件、协议入口点、携带每次启动令牌的 picker-result HTTP 路由,以及首次使用时的浏览器确认,还增加了一条浏览器可达的服务端路由。合成按键删除了整个这一面。
+
+**由点击方调用 AllowSetForegroundWindow。** 该 API 必须由当前前台进程——浏览器——调用,并且只能点名一个被允许的进程;spawner 无法代替浏览器调用它。
+
+**对聚焦线程 AttachThreadInput。** 把对话框线程附着到焦点窗口所属线程同样能绕过前台限制且没有按键副作用,但同样没有文档契约,需要在显示时取得焦点窗口的线程 id,并在焦点窗口属于更高完整性进程时失败;未做原型验证。
+
+## Consequences
+
+选择器保持单一 spawn 子进程设计,并在后台宿主场景获得前台行为,代价是一次 koffi 调用对。bindings spec 在假 COM 世界上固定了 Alt 按下/抬起序列及其紧邻 `Show` 之前的位置;logic spec 固定完整的 showing → press → `Show` 顺序。Windows CI lane 仍会真实打开并中止关闭一个对话框(按键存在),但不断言激活。在一台把前台锁强制到最大值的 Windows 11 机器上做了验证:不加按键复现失败(对话框在其他窗口后面),加上按键后对话框获得前台,五轮重复全部成功;Windows 10 尚未验证。合成输入由 raw input thread 异步消费,因此激活授权原则上存在竞争窗口;重复运行中未出现错过,脆弱环境仍是记录的包限制而非第二套机制,因为浏览后端仍是原生选择不可信场景在组合层面的答案。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md
+2026-09-09-composer-placeholder-whitespace.md: c5d778f8fb1fdfc742636819a9ff27ddeeb0234e
+2026-09-09-composer-placeholder-whitespace.zh.md: b5f82bcd86f8567976bba96095d4de9d42bae9b1

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md

@@ -0,0 +1,21 @@
+# Agent Note: Composer placeholder emptiness
+
+Status: implemented
+
+English | [中文](2026-09-09-composer-placeholder-whitespace.zh.md)
+
+## Problem
+
+Sharing the whitespace-trimmed submission check with placeholder rendering leaves guidance drawn over a draft containing spaces.
+
+## Decision
+
+The Composer hides its placeholder whenever the raw draft is nonempty. Submission keeps its trimmed-content check. Attachments and claimed commands retain their existing placeholder suppression.
+
+## Alternatives considered
+
+**Reuse the submission check.** Whitespace has no sendable message content, but it occupies the editor and moves its caret. A shared check conflates these two states.
+
+## Consequences
+
+All placeholder variants, including queued-message steering guidance, disappear after whitespace input and return after deletion. A whitespace-only draft without attachments remains unsendable. [Component tests](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) cover visibility, composition, rerendering and submission; the [browser regression](../../../../apps/web/tests/composer-placeholder.e2e.ts) checks keyboard and clipboard gestures against built UI.

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md

@@ -0,0 +1,21 @@
+# Agent Note: Composer 占位提示的判空规则
+
+Status: implemented
+
+[English](2026-09-09-composer-placeholder-whitespace.md) | 中文
+
+## Problem
+
+占位提示复用去除首尾空白后的提交判断,会让提示文字覆盖已经包含空格的草稿。
+
+## Decision
+
+原始草稿非空时,Composer 隐藏占位提示。提交仍检查去除首尾空白后的内容。附件和已认领指令沿用现有的占位提示隐藏规则。
+
+## Alternatives considered
+
+**复用提交判断。** 空白字符没有可发送的消息内容,但会占据编辑器并移动光标。共用判断会混淆这两种状态。
+
+## Consequences
+
+所有占位提示,包括排队消息的插话提示,都会在输入空白字符后隐藏,删除后恢复。没有附件的纯空白草稿仍无法发送。[组件测试](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 覆盖显示、输入法组合、重新渲染和提交;[浏览器回归](../../../../apps/web/tests/composer-placeholder.e2e.ts) 使用构建后的界面检查键盘和剪贴板操作。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
-2026-09-08-present-workspace-source-files.md: 6239c9bd920849f3c8cf4fdee2e1ded4b758b01d
-2026-09-08-present-workspace-source-files.zh.md: 7aa5bebc97558b9b0cb74406303d038483c39d94
+2026-09-08-present-workspace-source-files.md: 6a8ebfcf1fd7be22a5bda5a209d0fdc3586931bb
+2026-09-08-present-workspace-source-files.zh.md: d9361f2281eeb027e4c44b84c7f01af1639b66f2

+ 3 - 1
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md

@@ -12,9 +12,11 @@ Users need to open and edit the files produced in their workspace, including she
 
 The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
 
+The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
+
 The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results.
 
-An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The existing produced-file row retains its separate text-preview behavior.
+An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery.
 
 ## Alternatives considered
 

+ 3 - 1
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md

@@ -12,9 +12,11 @@ Status: implemented
 
 [present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
 
+工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
+
 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
 
-经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为
+经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付
 
 ## 考虑过的替代方案
 

+ 0 - 41
.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md

@@ -1,41 +0,0 @@
-# Agent Note: Exclude documentation and comment-only changes from review routing
-
-Status: implemented
-
-English | [中文](2026-09-08-comment-only-review-routing.zh.md)
-
-## Problem
-
-Directory ownership alone treats documentation and comment edits like executable changes. These edits do not require the automatic code-owner request that protects behavior changes.
-
-GitHub may omit or truncate a file patch. A scanner that assumes every patch is complete can miss executable changes that occur outside the supplied hunks.
-
-## Decision
-
-Review routing classifies every old and new path in this order: test, documentation, comment-only, then reviewable code. Test classification wins when a test path also has a documentation extension. Every filename ending in `.md` or `.yaml`, matched without case sensitivity, is documentation. A `.yml` file is not documentation under this rule.
-
-Comment-only classification applies only to files with `status: modified` and a declared source-comment syntax. The scanner reconstructs the before and after text for each patch hunk, removes comments outside quoted strings, removes empty lines left by comments, and requires the remaining text to be identical.
-
-The scanner counts added and deleted patch lines and compares them with GitHub's file record before accepting a comment-only result. A missing patch, a count mismatch, a rename, an unsupported extension, or a comment form that remains visible to the lexer keeps the file reviewable. This fail-safe result can request an unnecessary review but cannot suppress a known code change.
-
-The supported lexical rules cover C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for an explicit extension set in the scanner. Comment directives such as JSDoc tags, lint controls, compiler controls, and coverage controls are comments for routing purposes.
-
-## Verification
-
-[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover documentation extensions, supported comment forms, quoted comment markers, executable token changes, incomplete patches, renames, unsupported extensions, exclusion precedence, and the no-request result when every file is excluded.
-
-## Alternatives considered
-
-**Keep every non-test file reviewable.** This requests code owners for documentation and comment maintenance even though the routing policy is intended to identify executable changes.
-
-**Infer arbitrary semantic equivalence.** Proving behavior equivalence across the repository's languages requires language toolchains and still cannot assign one stable meaning to generated files, configuration, or build directives. The scanner performs only lexical comment removal.
-
-**Trust every patch returned by GitHub.** GitHub can omit or truncate patches. Matching the patch's added and deleted line counts to the file record prevents a partial patch from producing a comment-only verdict.
-
-**Fetch and parse every complete file revision.** Per-file content requests multiply API traffic for large pull requests and still require the same language-specific parsing. The changed-file response already carries enough evidence for complete ordinary patches.
-
-## Consequences
-
-Documentation and proven comment-only changes request nobody. The workflow logs them separately from tests so maintainers can audit why owner matching ignored a file.
-
-Unsupported or incomplete inputs remain reviewable. Comment directives do not request owners even when another tool interprets them, because this policy classifies their lexical form rather than downstream tool behavior.

+ 0 - 41
.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md

@@ -1,41 +0,0 @@
-# Agent Note: 从评审路由中排除文档和纯注释变更
-
-Status: implemented
-
-[English](2026-09-08-comment-only-review-routing.md) | 中文
-
-## 问题
-
-只按目录分配 owner 会把文档和注释编辑视为可执行变更。这些编辑不需要用于保护行为变更的自动代码 owner 请求。
-
-GitHub 可能省略或截断文件 patch。如果扫描器假定每个 patch 都完整,就可能漏掉位于已提供 hunk 之外的可执行变更。
-
-## 决策
-
-评审路由按测试、文档、纯注释、可评审代码的顺序对每个新旧路径分类。当测试路径同时具有文档扩展名时,测试分类优先。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,扩展名匹配不区分大小写;此规则不把 `.yml` 文件视为文档。
-
-纯注释分类只适用于 `status: modified` 且已声明源码注释语法的文件。扫描器重建每个 patch hunk 的变更前后文本,移除引号字符串外的注释和注释留下的空行,并要求其余文本完全相同。
-
-扫描器会统计 patch 的新增行和删除行,并在接受纯注释结果前与 GitHub 文件记录比较。缺失 patch、计数不符、重命名、不受支持的扩展名,或词法分析器仍能看到的注释形式都会使文件保持可评审状态。该保守结果可能产生不必要的评审请求,但不会隐藏已知代码变更。
-
-受支持的词法规则按扫描器中显式的扩展名集合覆盖 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释及 HTML 注释。JSDoc 标签、lint 控制、编译器控制和覆盖率控制等注释指令在评审路由中仍属于注释。
-
-## 验证
-
-[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖文档扩展名、受支持的注释形式、引号内的注释标记、可执行 token 变更、不完整 patch、重命名、不受支持的扩展名、排除优先级,以及所有文件均被排除时不发出请求的结果。
-
-## 考虑过的替代方案
-
-**让每个非测试文件都保持可评审。** 这会为文档和注释维护请求代码 owner,但该路由策略的目标是识别可执行变更。
-
-**推断任意语义等价。** 证明仓库中多种语言的行为等价需要各语言工具链,而且仍然无法为生成文件、配置或构建指令提供一种稳定含义。扫描器只执行词法注释移除。
-
-**信任 GitHub 返回的每个 patch。** GitHub 可能省略或截断 patch。将 patch 的新增和删除行数与文件记录匹配,可以防止不完整 patch 产生纯注释结论。
-
-**获取并解析每个文件的完整修订版本。** 对于大型 PR,逐文件内容请求会增加多倍 API 流量,而且仍需相同的语言专用解析。普通完整 patch 所需的证据已包含在变更文件响应中。
-
-## 后果
-
-文档和确认的纯注释变更不会请求任何人。Workflow 会将它们与测试分开记录,以便维护者检查 owner 匹配忽略文件的原因。
-
-不受支持或不完整的输入仍需评审。即使其他工具会解释注释指令,这些指令也不会请求 owner,因为该策略按词法形式分类,而不是按下游工具行为分类。

+ 0 - 55
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md

@@ -1,55 +0,0 @@
-# Agent Note: Route reviews from trusted changed-file policy
-
-Status: implemented
-
-## Problem
-
-GitHub's native CODEOWNERS behavior requests reviewers whenever a matching path changes. It cannot apply this repository's distinction between reviewable implementation or documentation files and test-only evidence. A native CODEOWNERS file also makes GitHub, rather than an inspected repository program, responsible for the request decision.
-
-Review routing needs an observable changed-file input, explicit owner rules, complete test exclusions, and a write-capable workflow that remains safe for pull requests from forks.
-
-## Decision
-
-The repository keeps a CODEOWNERS-compatible map at [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS), outside GitHub's native CODEOWNERS locations. The map accepts only explicit absolute directory patterns with one or two individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, more than two owners, duplicate patterns, and duplicate owners. Later matching patterns replace earlier matches.
-
-The policy test counts non-test tracked lines in directories that match an ownership rule. It rejects a map in which `@turtle1999` owns more than one third of that eligible owned codebase.
-
-The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on `pull_request_target` events for opened, synchronized, reopened, ready-for-review, and converted-to-draft pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets.
-
-The scanner fetches every changed-file record before deciding. It fails if the pull request reports more than GitHub's 3,000-file API limit or if pagination returns an incomplete list. It normalizes repository paths, evaluates old and new paths of a rename independently, and escapes filenames before logging them.
-
-The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.<ext>`, `.corpus.<ext>`, `.e2e.<ext>`, `.perf.<ext>`, `.snapshot.<ext>`, `.spec.<ext>`, `.stress.<ext>`, or `.test.<ext>`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced. The [comment-only routing decision](2026-09-08-comment-only-review-routing.md) owns the additional documentation and comment exclusions.
-
-The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, approved owners omitted from new requests, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it fetches the complete chronological review list and reduces each owner's undismissed `APPROVED` and `CHANGES_REQUESTED` reviews to the latest decisive state; `COMMENTED` and `PENDING` reviews leave that state unchanged. It removes the pull-request author, owners with an active approval, and users who remain requested from the matched individual owners. An active approval remains sufficient after later synchronize events, while a later changes-requested review makes the owner eligible again. The review-list operation fails before mutation at 3,000 entries or on an invalid record.
-
-The workflow keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores.
-
-When current review requests exist, the workflow reads the complete review-request timeline before mutation. A current reviewer is workflow-authored only when its latest matching `review_requested` event identifies `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. A non-draft run cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit; current relevance order selects which matching workflow reviewer remains. Planned cancellations release capacity before the workflow selects a new reviewer. A draft run cancels every current workflow-authored request. Requests made by people remain unchanged. An attributable event with invalid provenance and timelines above 3,000 events fail before mutation.
-
-## Verification
-
-[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, changed-LOC aggregation and ranking, complete pagination, file and review limits, approval-state reduction, approved-owner suppression and next-owner selection, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
-
-## Alternatives considered
-
-**Use native CODEOWNERS.** Native routing cannot ignore test-only changes and offers no repository-owned decision log before requesting reviewers.
-
-**Run under `pull_request` and check out the pull-request head.** A fork workflow does not receive a write-capable token, while granting a write token to code from an untrusted head is unsafe.
-
-**Execute the pull request's scanner or owner map under `pull_request_target`.** This lets an untrusted pull request choose its own write-capable behavior or owners.
-
-**Select capped candidates by login order.** Login order is stable but ignores how much reviewable code changed under each owner's directories. Changed LOC makes the limited requests follow the pull request's strongest ownership relevance while retaining login order for ties.
-
-**Cancel every reviewer that no longer matches.** A person may request a reviewer for reasons outside the ownership map. Only requests attributed to the workflow identity are safe for automated reconciliation.
-
-**Treat an empty current request as an owner who still needs review.** GitHub removes the pending request when the reviewer submits a review. Requesting an owner with an active approval again adds no ownership coverage and creates repeated notifications after later synchronize events.
-
-**Infer arbitrary semantic source changes from patches or language parsers.** GitHub can omit or truncate patches, and the repository spans many languages. The scanner does not try to prove that two programs behave identically. The later [comment-only routing decision](2026-09-08-comment-only-review-routing.md) adds a narrow lexical comparison only when changed-line counts prove that GitHub supplied the complete patch.
-
-## Consequences
-
-Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, rule and changed-file updates remove obsolete workflow-authored requests on the next run, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself.
-
-The workflow requests at most one reviewer per run, does not repeat a request while that owner has an active approval, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger.
-
-Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file, review, or timeline API limit fail without applying a partial reviewer mutation.

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: 07a37ada9c2a43f04612048f9bff6b22d022ec40
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 668e612712175821d6ad123ce364a7cb96e01272
+2026-07-24-web-gui-browser-e2e-lane.md: 8276ed1982a7472ba1b825837a933058c32bf840
+2026-07-24-web-gui-browser-e2e-lane.zh.md: 3a7e5ea5547cf5584a2ad3f61b90c91326379de5

+ 2 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md

@@ -26,6 +26,8 @@ Keyless model displacement is the disabled adapter row plus `installLlmReplay` f
 
 The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); polling persistence files as a turn-completion or durability barrier is banned (slow on NFS, superseded by `whenIdle`), while a tool-controlled temp readiness marker may be polled only as an interaction gate before that completion barrier; `networkidle` is banned outright (never resolves while an SSE stream is open). Navigation assertions arm both initial `session.list` and `workspace.list` responses before page load, then wait for the seeded DOM projection; the mounted shell alone is not readiness because late bootstrap can replace controlled state.
 
+Layout assertions wait for loaded fonts, completed frame transitions, and the Conversation width publication before measuring; a synthetic resize also waits for the scheduled React update before reading a portaled panel. Directory-tree reads use the same Remote-read budget for child and root listings. Workspace reload scenarios wait for restored Session selection and composer focus before opening another path editor, because that late focus can cancel its draft. Responsive file-chip scenarios keep their measured lane inside a container-query band with explicit margin for platform font metrics.
+
 No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted through the ordered `agent/assistant-stream` follow path, while the final durable `assistant/message` or `assistant/attempt` embeds the exact stream used for replay. `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
 
 Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat.

+ 2 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md

@@ -10,6 +10,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 ## 决策
 
+布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。
+
 `pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
 
 ### Scaffold:`apps/web/tests/scaffold.ts`

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
-2026-09-06-backend-continuation-performance.md: 62d8af10015cc2da7b399aae3c9d197f75931753
-2026-09-06-backend-continuation-performance.zh.md: 7c8904fa019b27362e2cdb0e50697921913e5d09
+2026-09-06-backend-continuation-performance.md: 94e9192df473515d39014c8be9e82e0413df47bc
+2026-09-06-backend-continuation-performance.zh.md: 92556ad8e3434a3219ea585503bec9b17bbf576a

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md

@@ -25,7 +25,7 @@ The tool execution pipeline, request preparation, Session projections required b
 
 The SDK fixture explicitly inserts `fs-local` and `str_replace_editor` through its profile patch. This preserves the calibrated file-view workload independently of the [minimal profile's shell-only defaults](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md). File reads, timing endpoints, and budgets remain the same.
 
-Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations.
+Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Each aggregate report also records CPU models, available parallelism, platform, architecture, and Node/V8 versions after the timed workers exit. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations.
 
 The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage.
 

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md

@@ -25,7 +25,7 @@ Status: implemented
 
 SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor`。这使经校准的文件查看负载不依赖[极简 profile 只提供 shell 的默认组合](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)。文件读取、计时终点和预算保持不变。
 
-五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
+五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。每份汇总报告还在计时 worker 退出后记录 CPU 型号、可用并行度、平台、架构以及 Node/V8 版本。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
 
 父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。
 

+ 3 - 3
.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml → .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md
-2026-09-08-comment-only-review-routing.md: 050905285b2291b34da9873d19c2f122c088a9e5
-2026-09-08-comment-only-review-routing.zh.md: b98f5d70b4d5c0fd27df1c393238b0802e420e09
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md
+2026-09-08-ci-completion-observations.md: 8af4685a5e2c51c1edf4a41b47088916223e7a6c
+2026-09-08-ci-completion-observations.zh.md: e3147bb7ac39877b46820862e9af4645803a37a2

+ 45 - 0
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md

@@ -0,0 +1,45 @@
+# Agent Note: CI fixture completion and isolation
+
+Status: implemented
+
+English | [中文](2026-09-08-ci-completion-observations.zh.md)
+
+## Problem
+
+The [reference CI run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34206953049) reports a webhook-created Session absent after a one-second poll and empty PowerShell output before a five-second read deadline. HTTP acceptance, projected UI state, process startup, and durable completion are separate observations. Tests need an explicit completion condition and controls that prevent an intermediate state from satisfying it. The [completion-wait decision](2026-09-08-ci-readiness-and-completion.md) owns those conditions and lane budgets; these fixtures make their ordering and cleanup observable under controlled delays.
+
+## Decision
+
+The [GitHub review browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) holds real Workspace creation after HTTP 202, verifies that neither the Agent nor the model request exists, then releases creation and awaits the matching Session's `turn/end`. Cleanup releases the barrier, restores the method, and removes the event listener even when the test times out. Workspace membership, request counts, prompt content, and browser expectations retain their original assertions.
+
+The [PowerShell executor tests](../../../../packages/shell/pwsh-local/tests/executor.spec.ts) hold startup and consuming reads at private file barriers. The test controls when later output becomes available; final stdin/environment output is read after `done`. Polling uses the active test budget, and every constructed Context is registered before plugin initialization. Teardown captures Contexts and directories before awaiting disposal and removes directories only after that disposal completes.
+
+The [queued-image test](../../../../apps/web/tests/queue-image.e2e.ts) separately holds admission and attachment retrieval, then captures the admitted row's loaded thumbnail. Cleanup shares one promise, releases held requests, and drains their handlers before closing the browser.
+
+The [Details Session-lifecycle test](../../../../apps/web/tests/details-session-lifecycle.e2e.ts) awaits the frame's captured animation promises after closed state appears, then checks the zero-width track. Cancelled transitions also reach that assertion; animation settlement cannot make a persistent nonzero track pass.
+
+The [whole-queue steering test](../../../../apps/web/tests/steering.e2e.ts) waits for enabled steering actions and the composer's queue-steering hint. A model-stream barrier keeps the following question-composer takeover pending while the test observes steering. Teardown releases that barrier before browser closure.
+
+The [workspace-management test](../../../../apps/web/tests/workspace-management.e2e.ts) waits for restored composer focus before the next directory-dialog gesture. Its archive case gives the known seed id an explicit user title through the Session controller, then uses that exact title to identify the row across reload. An unrelated restored row cannot satisfy that locator; the durable archive assertion still checks the seed id and retained log.
+
+The [worker budget tests](../../../../packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts) retain real worker execution and binding transport while controlling host timers and ELU samples. They acknowledge binding entry before exercising idle, active, and wall-clock decisions, so a bootstrap timeout cannot stand in for a budget decision during a binding. The [real-worker tests](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts) independently retain actual ELU, idle-binding, and hot-loop coverage.
+
+The [detached-launch tests](../../../../packages/host/open-in-app/tests/launch-detached.spec.ts) control watch time and deliver late process events through the real launcher's registered callbacks. They check one settlement, one unref, and no child kill. Real-process environment and early-exit cases remain in the [resolver tests](../../../../packages/host/open-in-app/tests/resolver.spec.ts).
+
+The [LSP backpressure test](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts) preserves the real paused-reader fixture and large native pipe write. Before accepting the abort error, it verifies that the pending write callback settled and the captured subprocess completed; `instance.dead` alone can be true as soon as disposal starts.
+
+### Built-client import classification
+
+The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits the Dockkit bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for its exact `dockkit.module.css` path. Other errors and unexpectedly successful exempt imports fail. Scoped resolve/load hooks exercise expected CSS failure, arbitrary failure, another stylesheet, another error code, and stale exemption without modifying shared build artifacts.
+
+## Alternatives considered
+
+**Production timeouts, retries, or suite serialization.** Rejected because none establishes the missing completion observation.
+
+**Completion inferred from acceptance or a preview.** HTTP 202 and an optimistic image can precede the operation being asserted.
+
+**Controlled samples replacing measured worker coverage.** Rejected because they omit verification of Node's actual ELU and transport behavior.
+
+## Consequences
+
+Each fixture owns its clocks, barriers, callbacks, processes, and temporary paths. Controlled observations supplement real worker, subprocess, browser, and persistence paths. Product behavior, production timing, benchmark budgets, CI scheduling, and recorded expectations remain unchanged.

+ 45 - 0
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md

@@ -0,0 +1,45 @@
+# Agent Note: CI fixture 的完成与隔离
+
+Status: implemented
+
+[English](2026-09-08-ci-completion-observations.md) | 中文
+
+## 问题
+
+[参考 CI 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34206953049)报告:轮询一秒后 webhook 创建的 Session 仍不存在,五秒读取期限内 PowerShell 输出为空。HTTP 接受、UI 投影状态、进程启动和持久化完成是不同的观察。测试需要明确的完成条件,并用对照阻止中间状态满足该条件。[完成等待决策](2026-09-08-ci-readiness-and-completion.zh.md)拥有这些条件与 lane 预算;这些 fixture 通过受控延迟使顺序与清理可观察。
+
+## 决策
+
+[GitHub 评审浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)在 HTTP 202 后阻塞真实 Workspace 创建,验证 Agent 和模型请求均不存在,再释放创建并等待对应 Session 的 `turn/end`。即使测试超时,清理也会释放屏障、恢复方法并移除事件监听器。Workspace 归属、请求数量、提示词内容和浏览器预期保留原有断言。
+
+[PowerShell 执行器测试](../../../../packages/shell/pwsh-local/tests/executor.spec.ts)用私有文件屏障控制启动与消费式读取。测试决定后续输出何时可用;最终 stdin/环境变量输出在 `done` 后读取。轮询使用当前测试预算,每个创建的 Context 都在插件初始化前登记。清理在等待释放前同时取得 Context 与目录,完成释放后才删除目录。
+
+[排队图片测试](../../../../apps/web/tests/queue-image.e2e.ts)分别阻塞接纳和附件读取,再捕获已接纳行中加载完成的缩略图。清理共享一个 Promise,释放保留的请求,并在关闭浏览器前等待其 handler 完成。
+
+[详情 Session 生命周期测试](../../../../apps/web/tests/details-session-lifecycle.e2e.ts)在关闭状态出现后等待框架已捕获的动画 Promise,再检查轨道宽度为零。取消的过渡同样进入该断言;动画结束不能让持续非零的轨道通过。
+
+[整队列 steering 测试](../../../../apps/web/tests/steering.e2e.ts)等待 steering 操作可用以及 composer 显示队列 steering 提示。模型流屏障在测试观察 steering 时阻止后续问题 composer 接管。清理在关闭浏览器前释放该屏障。
+
+[Workspace 管理测试](../../../../apps/web/tests/workspace-management.e2e.ts)在下一次目录对话框操作前等待恢复后的 composer 焦点。归档用例通过 Session controller 为已知 seed id 设置显式用户标题,再用该精确标题跨重载定位行。无关的恢复行无法匹配该定位器;持久化归档断言仍检查 seed id 和保留的日志。
+
+[Worker 预算测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts)保留真实 worker 执行与绑定传输,只控制 Host 定时器和 ELU 样本。测试先确认绑定已进入,再检验 idle、active 和壁钟决策,使启动超时不能冒充绑定期间的预算决策。[真实 worker 测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts)独立保留实际 ELU、空闲绑定和热循环覆盖。
+
+[分离启动测试](../../../../packages/host/open-in-app/tests/launch-detached.spec.ts)控制观察时间,并通过真实 launcher 登记的回调发送迟到进程事件。测试检查仅完成一次、仅 unref 一次且不终止子进程。[Resolver 测试](../../../../packages/host/open-in-app/tests/resolver.spec.ts)保留真实进程的环境变量和提前退出用例。
+
+[LSP 背压测试](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts)保留真实暂停读取的 fixture 与大型原生管道写入。接受 abort 错误前,测试验证待处理写入回调已完成、捕获的子进程也已结束;`instance.dead` 在释放开始时就可能为真。
+
+### 已构建 Client 的导入分类
+
+[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)只有在 Node 针对准确的 `dockkit.module.css` 路径报告 `ERR_UNKNOWN_FILE_EXTENSION` 时才接受 Dockkit bundle。其他错误以及意外成功的豁免导入都会失败。限定范围的 resolve/load hook 覆盖预期 CSS 失败、任意失败、其他 stylesheet、其他错误码和过期豁免,不修改共享构建产物。
+
+## 考虑过的替代方案
+
+**生产超时、重试或套件串行化。** 拒绝,因为均不能建立缺少的完成观察。
+
+**从接受或预览推断完成。** HTTP 202 和乐观图片可能早于被断言的操作。
+
+**用受控样本替换实测 worker 覆盖。** 拒绝,因为会遗漏对 Node 实际 ELU 与传输行为的验证。
+
+## 影响
+
+每个 fixture 拥有自己的时钟、屏障、回调、进程和临时路径。受控观察补充真实 worker、子进程、浏览器和持久化路径。产品行为、生产时序、基准预算、CI 调度和录制预期均保持不变。

+ 0 - 59
.github/review-ownership/CODEOWNERS

@@ -1,59 +0,0 @@
-# Custom static-scanner input. Its nested path keeps GitHub from loading it as
-# the repository's native CODEOWNERS file.
-/apps/cli/ @turtle1999
-/apps/web/ @imccyu
-/docs/ @turtle1999
-/native/ @mektpoy
-/patches/ @mektpoy
-/python/ @LegGasai
-/vendor/ @turtle1999
-/website/ @LegGasai
-/packages/acp/ @mektpoy
-/packages/api/ @imccyu
-/packages/attachment/ @CreatixChu
-/packages/boot/ @turtle1999
-/packages/bundle/ @turtle1999
-/packages/client/ @imccyu
-/packages/code-runtime/ @Chinesezjc
-/packages/compaction/ @imccyu
-/packages/context/ @turtle1999
-/packages/core/ @turtle1999 @mektpoy
-/packages/credentials/ @mektpoy
-/packages/e2b/ @mektpoy
-/packages/experimental/ @mektpoy
-/packages/extensions/ @mektpoy
-/packages/feedback/ @mektpoy
-/packages/fs/ @mektpoy
-/packages/goal/ @mektpoy
-/packages/guard/ @turtle1999
-/packages/hooks/ @mektpoy
-/packages/host/ @turtle1999
-/packages/identity/ @imccyu
-/packages/interaction/ @imccyu
-/packages/jobs/ @imccyu
-/packages/llm/ @LegGasai
-/packages/lsp/ @mektpoy
-/packages/mcp/ @mektpoy
-/packages/plan/ @mektpoy
-/packages/preset/ @LegGasai @turtle1999
-/packages/runtime-diagnostics/ @mektpoy
-/packages/sandbox/ @mektpoy
-/packages/schedule/ @imccyu
-/packages/sdk/ @mektpoy
-/packages/session/ @turtle1999 @mektpoy
-/packages/session-query/ @mektpoy
-/packages/settings/ @mektpoy
-/packages/shell/ @mektpoy
-/packages/skill/ @mektpoy
-/packages/spill/ @mektpoy
-/packages/storage/ @imccyu
-/packages/subagent/ @Dudu-0223
-/packages/subprocess/ @mektpoy
-/packages/terminal/ @imccyu
-/packages/todo/ @mektpoy
-/packages/typert/ @imccyu
-/packages/util/ @mektpoy
-/packages/web/ @imccyu
-/packages/webhook/ @mektpoy
-/packages/workflow/ @mektpoy
-/packages/workspace/ @imccyu

+ 6 - 36
.github/review-ownership/README.md

@@ -1,34 +1,16 @@
-# Automated pull-request reviews
+# Pull-request approval policy
 
 ## Summary
 
-The [`request-review` workflow](../workflows/request-review.yml) requests owners for reviewable code. The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Both write-capable workflows execute policy from the trusted default branch.
+The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Reviewer selection and review requests remain manual.
 
 ## Table of Contents
 
-- [Routing](#routing)
 - [Approval scoring](#approval-scoring)
-- [Review exclusions](#review-exclusions)
 - [Security](#security)
 - [Verification](#verification)
 - [Dev Note](#dev-note)
 
-<a id="routing"></a>
-
-## Routing
-
-Pull requests run the workflow when opened, synchronized, reopened, marked ready for review, or converted to a draft. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API.
-
-For a non-draft pull request, the workflow keeps at most one current individual review request other than `@turtle1999`; an existing request for `@turtle1999` does not consume that slot. Each run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when made by people outside the ownership map. When more candidates remain than the available counted slot can cover, the workflow ranks them by the total GitHub-reported additions plus deletions in reviewable changed-file records that match each owner. A rename contributes its changed LOC once to an owner even when both paths match that owner. Higher changed LOC ranks first, and login order resolves ties.
-
-Before selecting a new reviewer, a non-draft run fetches the pull request's complete chronological review list. An owner's latest undismissed decisive review is `APPROVED` or `CHANGES_REQUESTED`; comments and pending reviews do not replace that decision. An approved owner remains omitted after later synchronize events, while a later changes-requested review makes the owner eligible again. The workflow fails before mutation when the list reaches the supported 3,000-review limit or contains an invalid record.
-
-On every run with current review requests, the workflow reads the pull-request timeline. A current reviewer is workflow-authored only when the latest matching `review_requested` event names `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. On a non-draft pull request, the workflow cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit. Current relevance order decides which matching workflow reviewer remains when the limit shrinks. It then fills any slot left by the planned cancellations. On a draft, it cancels every current workflow-authored request. Requests made by people remain unchanged in both states. An attributable event with invalid provenance fails before mutation, and the workflow also fails without cancellation when the timeline exceeds 3,000 events.
-
-The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches and LOC; the aggregate owner relevance ranking; approved owners omitted from new requests; current individual requests and the available counted slot after planned cancellations; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author, approved owners, and users who remain requested are omitted from new requests.
-
-The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase.
-
 <a id="approval-scoring"></a>
 
 ## Approval scoring
@@ -41,34 +23,22 @@ Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` dec
 
 The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
 
-<a id="review-exclusions"></a>
-
-## Review exclusions
-
-Review routing excludes the repository's unit, end-to-end, expected-output, snapshot, benchmark, performance, stress, corpus, native, and Python test conventions. This includes `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, and `stress-tests` directories; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; recognized test filename suffixes; and Python `test_*.py` or `*_test.py` files.
-
-Test infrastructure that can alter how evidence is produced remains reviewable, including `vitest*.config.ts` and gate implementations under `scripts`. A production file named `test.ts`, `spec.ts`, or `snapshot.ts` is not excluded solely by that name.
-
-Files ending in `.md` or `.yaml`, with case-insensitive extension matching, are documentation and never contribute owners. A `.yml` file remains reviewable unless another exclusion applies.
-
-For a modified file with a supported source extension, the scanner compares the pre-change and post-change text after removing parsed comments. It excludes the file only when GitHub supplies a patch whose counted additions and deletions prove that the patch is complete and the remaining code is identical. The parser recognizes C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for their declared extensions. Renames, unsupported languages, missing or partial patches, and uncertain comment forms remain reviewable.
-
 <a id="security"></a>
 
 ## Security
 
-The write-capable jobs check out only the repository default branch. They do not check out or execute pull-request code and do not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request filenames and reviews are treated as API data and escaped in logs.
+The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
 
-Ownership and approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing either program or policy for its own run.
+Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
 
 <a id="verification"></a>
 
 ## Verification
 
-Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs both policy checks and the workflow tests in CI.
+Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI.
 
 <a id="dev-note"></a>
 
 ## Dev Note
 
-The [review-routing decision](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) records the security model, test exclusions, and alternatives.
+None.

+ 2 - 5
.github/review-ownership/check-approval.mjs

@@ -8,7 +8,6 @@ const API_VERSION = '2026-03-10'
 const MAX_PULL_REQUEST_REVIEWS = 3_000
 const PAGE_SIZE = 100
 const STATUS_CONTEXT = 'weighted approval'
-const STATUS_PREFIX = 'This is by automated Angry Turtle Cyborg, not a human'
 const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
 const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
 const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u
@@ -192,12 +191,11 @@ export async function evaluateApproval({ event, policySource, api }) {
  */
 export async function runApprovalCheck({ event, policySource, api, runUrl, write = line => process.stdout.write(`${line}\n`) }) {
   const pull = pullRequestFromEvent(event)
-  write(STATUS_PREFIX)
   let result
   try {
     result = await evaluateApproval({ event, policySource, api })
   } catch (error) {
-    await publishStatus(api, pull, 'error', `${STATUS_PREFIX}: approval evaluation failed.`, runUrl)
+    await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
     throw error
   }
   write(`Approval score: ${result.points}/${result.requiredPoints}.`)
@@ -239,7 +237,7 @@ function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReview
   return {
     pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha },
     state,
-    description: `${STATUS_PREFIX}: ${detail}.`,
+    description: `${detail}.`,
     points: approvals.reduce((total, approval) => total + approval.points, 0),
     requiredPoints,
     approvals,
@@ -355,7 +353,6 @@ async function main() {
       api,
     })
     if (resolved === null) {
-      process.stdout.write(`${STATUS_PREFIX}\n`)
       process.stdout.write('Skipped a review event for a superseded pull-request head.\n')
       return
     }

+ 3 - 2
.github/review-ownership/check-approval.test.mjs

@@ -266,12 +266,12 @@ test('publishes the required status and replaces stale success with error on eva
       body: {
         state: 'success',
         context: 'weighted approval',
-        description: 'This is by automated Angry Turtle Cyborg, not a human: 2/2 approval points.',
+        description: '2/2 approval points.',
         target_url: 'https://github.example/actions/runs/1',
       },
     },
   })
-  assert.equal(output[0], 'This is by automated Angry Turtle Cyborg, not a human')
+  assert.equal(output[0], 'Approval score: 2/2.')
 
   const failures = []
   await assert.rejects(runApprovalCheck({
@@ -289,6 +289,7 @@ test('publishes the required status and replaces stale success with error on eva
     write: () => {},
   }), /reviews unavailable/u)
   assert.equal(failures[0].options.body.state, 'error')
+  assert.equal(failures[0].options.body.description, 'Approval evaluation failed.')
 })
 
 test('sends authenticated JSON and escapes an API error body', async () => {

+ 0 - 660
.github/review-ownership/request-review.mjs

@@ -1,660 +0,0 @@
-#!/usr/bin/env node
-
-import { readFileSync } from 'node:fs'
-import process from 'node:process'
-import { pathToFileURL } from 'node:url'
-
-const API_VERSION = '2026-03-10'
-const MAX_OWNERS_PER_RULE = 2
-const MAX_PULL_REQUEST_FILES = 3_000
-const MAX_PULL_REQUEST_REVIEWS = 3_000
-const MAX_COUNTED_REQUESTED_REVIEWERS = 1
-const MAX_TIMELINE_EVENTS = 3_000
-const PAGE_SIZE = 100
-const PULL_REQUEST_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
-const UNCOUNTED_REVIEWER = 'turtle1999'
-const WORKFLOW_REVIEW_REQUESTER = 'github-actions[bot]'
-const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests'])
-const TEST_FILE_MARKER = /\.(?:bench|corpus|e2e|perf|snapshot|spec|stress|test)\.[^./]+$/u
-const PYTHON_TEST_FILE = /^(?:test_.+|.+_tests?)\.py$/u
-const DOCUMENTATION_FILE = /\.(?:md|yaml)$/iu
-const C_STYLE_EXTENSIONS = new Set([
-  'c', 'cc', 'cjs', 'cpp', 'cts', 'cxx', 'go', 'h', 'hpp', 'java', 'js', 'jsx',
-  'kt', 'kts', 'less', 'mjs', 'mts', 'rs', 'scss', 'swift', 'ts', 'tsx',
-])
-const BLOCK_COMMENT_EXTENSIONS = new Set(['css'])
-const HASH_COMMENT_EXTENSIONS = new Set(['bash', 'ps1', 'py', 'pyi', 'r', 'rb', 'sh', 'toml', 'yml', 'zsh'])
-const HTML_COMMENT_EXTENSIONS = new Set(['htm', 'html'])
-
-/**
- * Parse the explicit directory subset accepted from the review ownership file.
- * @param {string} source CODEOWNERS-compatible source text.
- * @returns {Array<{pattern: string, prefix: string, owners: string[]}>} Ordered ownership rules.
- */
-export function parseOwnership(source) {
-  const rules = []
-  const patterns = new Set()
-  for (const [index, rawLine] of source.split('\n').entries()) {
-    const line = rawLine.trim()
-    if (!line || line.startsWith('#')) continue
-    const [pattern, ...owners] = line.split(/\s+/u)
-    const location = `ownership line ${index + 1}`
-    if (!/^\/[^*?[\]#!\\]+\/$/u.test(pattern)) {
-      throw new Error(`${location}: expected one explicit absolute directory pattern`)
-    }
-    if (pattern.startsWith('/.')) throw new Error(`${location}: hidden-directory patterns are not allowed`)
-    if (patterns.has(pattern)) throw new Error(`${location}: duplicate pattern ${JSON.stringify(pattern)}`)
-    if (owners.length === 0) throw new Error(`${location}: expected at least one owner`)
-    if (owners.length > MAX_OWNERS_PER_RULE) {
-      throw new Error(`${location}: expected at most ${MAX_OWNERS_PER_RULE} owners`)
-    }
-    const normalizedOwners = []
-    const seenOwners = new Set()
-    for (const owner of owners) {
-      if (!/^@[A-Za-z0-9-]+$/u.test(owner)) {
-        throw new Error(`${location}: only individual GitHub users are supported`)
-      }
-      const key = owner.toLowerCase()
-      if (seenOwners.has(key)) throw new Error(`${location}: duplicate owner ${owner}`)
-      seenOwners.add(key)
-      normalizedOwners.push(owner)
-    }
-    patterns.add(pattern)
-    rules.push({ pattern, prefix: pattern.slice(1), owners: normalizedOwners })
-  }
-  if (rules.length === 0) throw new Error('ownership file contains no rules')
-  return rules
-}
-
-/**
- * Normalize a repository-relative path received from GitHub.
- * @param {unknown} value GitHub file path.
- * @returns {string} Slash-normalized repository path.
- */
-export function normalizeRepositoryPath(value) {
-  if (typeof value !== 'string' || value.length === 0) throw new Error('changed file has no path')
-  const normalized = value.replaceAll('\\', '/').replace(/^\.\/+/, '')
-  if (
-    normalized.startsWith('/')
-    || normalized.includes('\0')
-    || normalized.split('/').some(segment => !segment || segment === '.' || segment === '..')
-  ) {
-    throw new Error(`invalid repository path ${JSON.stringify(value)}`)
-  }
-  return normalized
-}
-
-/**
- * Decide whether a repository path belongs only to test evidence or test support.
- * @param {string} value Repository-relative path.
- * @returns {boolean} Whether reviewer routing must ignore the path.
- */
-export function isTestPath(value) {
-  const file = normalizeRepositoryPath(value)
-  const segments = file.split('/')
-  if (segments[0] === 'benchmarks' || segments[0] === 'snapshots') return true
-  if (segments[0] === 'packages' && segments[1] === 'test-support') return true
-  if (segments[0] === 'scripts' && (segments[1] === 'fixtures' || segments[1] === 'snapshots')) return true
-  if (segments.some(segment => TEST_DIRECTORY_NAMES.has(segment))) return true
-  const basename = segments.at(-1) ?? ''
-  return TEST_FILE_MARKER.test(basename) || PYTHON_TEST_FILE.test(basename)
-}
-
-/**
- * Decide whether a repository path is documentation excluded from review routing.
- * @param {string} value Repository-relative path.
- * @returns {boolean} Whether the path has an excluded documentation extension.
- */
-export function isDocumentationPath(value) {
-  return DOCUMENTATION_FILE.test(normalizeRepositoryPath(value))
-}
-
-/**
- * Decide whether a complete modified-file patch changes comments only.
- * @param {unknown} value GitHub changed-file record.
- * @returns {boolean} Whether supported comment parsing removes every changed token.
- */
-export function isCommentOnlyChange(value) {
-  if (!isRecord(value) || value.status !== 'modified' || typeof value.filename !== 'string'
-    || typeof value.patch !== 'string' || !Number.isSafeInteger(value.additions)
-    || value.additions < 0 || !Number.isSafeInteger(value.deletions) || value.deletions < 0) return false
-  const syntax = commentSyntax(value.filename)
-  if (syntax === undefined) return false
-  if (value.filename.toLowerCase().endsWith('.rs') && /\b(?:br|r)#{0,255}"/u.test(value.patch)) return false
-  const hunks = parsePatchHunks(value.patch)
-  if (hunks === undefined || hunks.additions !== value.additions || hunks.deletions !== value.deletions) {
-    return false
-  }
-  return hunks.values.every(({ before, after }) =>
-    normalizedCode(before, syntax) === normalizedCode(after, syntax))
-}
-
-function commentSyntax(filename) {
-  const normalized = normalizeRepositoryPath(filename)
-  const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase()
-  const extension = basename.includes('.') ? basename.slice(basename.lastIndexOf('.') + 1) : ''
-  const line = []
-  const block = []
-  if (C_STYLE_EXTENSIONS.has(extension)) {
-    line.push('//')
-    block.push(['/*', '*/'])
-  }
-  if (BLOCK_COMMENT_EXTENSIONS.has(extension)) block.push(['/*', '*/'])
-  if (HASH_COMMENT_EXTENSIONS.has(extension) || basename === 'dockerfile' || basename.startsWith('dockerfile.')
-    || basename === 'makefile' || basename.startsWith('makefile.')) line.push('#')
-  if (extension === 'sql') {
-    line.push('--')
-    block.push(['/*', '*/'])
-  }
-  if (HTML_COMMENT_EXTENSIONS.has(extension)) block.push(['<!--', '-->'])
-  return line.length === 0 && block.length === 0 ? undefined : { line, block }
-}
-
-function parsePatchHunks(patch) {
-  const values = []
-  let current
-  let additions = 0
-  let deletions = 0
-  for (const line of patch.split('\n')) {
-    if (line.startsWith('@@')) {
-      current = { before: [], after: [] }
-      values.push(current)
-      continue
-    }
-    if (current === undefined || line.startsWith('\\ No newline at end of file')) continue
-    const prefix = line[0]
-    const content = line.slice(1)
-    if (prefix === ' ') {
-      current.before.push(content)
-      current.after.push(content)
-    } else if (prefix === '-') {
-      current.before.push(content)
-      deletions++
-    } else if (prefix === '+') {
-      current.after.push(content)
-      additions++
-    }
-  }
-  return values.length === 0 ? undefined : { values, additions, deletions }
-}
-
-function normalizedCode(lines, syntax) {
-  return stripComments(lines.join('\n'), syntax)
-    .split('\n')
-    .map(line => line.trimEnd())
-    .filter(line => line.trim().length > 0)
-    .join('\n')
-}
-
-function stripComments(source, syntax) {
-  let result = ''
-  let quote
-  let blockEnd
-  for (let index = 0; index < source.length;) {
-    if (blockEnd !== undefined) {
-      if (source.startsWith(blockEnd, index)) {
-        index += blockEnd.length
-        blockEnd = undefined
-      } else {
-        index++
-      }
-      continue
-    }
-    const character = source[index]
-    if (quote !== undefined) {
-      result += character
-      index++
-      if (character === '\\' && index < source.length) {
-        result += source[index]
-        index++
-      } else if (character === quote) {
-        quote = undefined
-      }
-      continue
-    }
-    if (character === '\'' || character === '"' || character === '`') {
-      quote = character
-      result += character
-      index++
-      continue
-    }
-    const block = syntax.block.find(([start]) => source.startsWith(start, index))
-    if (block !== undefined) {
-      index += block[0].length
-      blockEnd = block[1]
-      continue
-    }
-    const line = syntax.line.find(marker => source.startsWith(marker, index))
-    const lineStart = index === 0 || source[index - 1] === '\n'
-    const hashStartsComment = line !== '#' || lineStart || /\s/u.test(source[index - 1] ?? '')
-    if (line !== undefined && hashStartsComment && !(line === '#' && lineStart && source[index + 1] === '!')) {
-      const newline = source.indexOf('\n', index + line.length)
-      if (newline === -1) break
-      result += '\n'
-      index = newline + 1
-      continue
-    }
-    result += character
-    index++
-  }
-  return result
-}
-
-/**
- * Expand changed-file records into reviewable, test, documentation, and comment-only paths.
- * @param {unknown[]} files Pull-request file records from GitHub.
- * @returns {{changedCodeFiles: string[], reviewableChanges: Array<{paths: string[], changedLines: number}>, excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths and their GitHub-reported changed-line counts.
- */
-export function classifyChangedFiles(files) {
-  const changedCodeFiles = new Set()
-  const reviewableChanges = []
-  const excludedTestFiles = new Set()
-  const excludedDocumentationFiles = new Set()
-  const excludedCommentOnlyFiles = new Set()
-  for (const entry of files) {
-    if (!isRecord(entry)) throw new Error('changed-file response contains a non-object entry')
-    const changedLines = changedLineCount(entry)
-    const paths = [normalizeRepositoryPath(entry.filename)]
-    const commentOnly = isCommentOnlyChange(entry)
-    if (entry.previous_filename !== undefined) {
-      paths.unshift(normalizeRepositoryPath(entry.previous_filename))
-    }
-    const reviewablePaths = []
-    for (const file of new Set(paths)) {
-      if (isTestPath(file)) excludedTestFiles.add(file)
-      else if (isDocumentationPath(file)) excludedDocumentationFiles.add(file)
-      else if (commentOnly) excludedCommentOnlyFiles.add(file)
-      else {
-        changedCodeFiles.add(file)
-        reviewablePaths.push(file)
-      }
-    }
-    if (reviewablePaths.length > 0) {
-      reviewableChanges.push({ paths: reviewablePaths.sort(), changedLines })
-    }
-  }
-  return {
-    changedCodeFiles: [...changedCodeFiles].sort(),
-    reviewableChanges,
-    excludedTestFiles: [...excludedTestFiles].sort(),
-    excludedDocumentationFiles: [...excludedDocumentationFiles].sort(),
-    excludedCommentOnlyFiles: [...excludedCommentOnlyFiles].sort(),
-  }
-}
-
-function changedLineCount(entry) {
-  for (const field of ['additions', 'deletions']) {
-    if (!Number.isSafeInteger(entry[field]) || entry[field] < 0) {
-      throw new Error(`changed-file ${field} must be a non-negative integer`)
-    }
-  }
-  const changedLines = entry.additions + entry.deletions
-  if (!Number.isSafeInteger(changedLines)) throw new Error('changed-file LOC exceeds the safe integer range')
-  return changedLines
-}
-
-/**
- * Match changed paths and rank owners by their reviewable changed LOC.
- * @param {Array<{prefix: string, owners: string[]}>} rules Ordered ownership rules.
- * @param {Array<{paths: string[], changedLines: number}>} reviewableChanges Reviewable GitHub file records.
- * @returns {{matches: Array<{file: string, changedLines: number, owners: string[]}>, reviewers: Array<{login: string, changedLines: number}>}} Routing plan.
- */
-export function planReviewers(rules, reviewableChanges) {
-  const matches = []
-  const reviewers = new Map()
-  for (const change of reviewableChanges) {
-    const changeOwners = new Map()
-    for (const file of change.paths) {
-      let owners = []
-      for (const rule of rules) {
-        if (file.startsWith(rule.prefix)) owners = rule.owners
-      }
-      matches.push({ file, changedLines: change.changedLines, owners })
-      for (const owner of owners) changeOwners.set(owner.toLowerCase(), owner.slice(1))
-    }
-    for (const [key, login] of changeOwners) {
-      const changedLines = (reviewers.get(key)?.changedLines ?? 0) + change.changedLines
-      if (!Number.isSafeInteger(changedLines)) throw new Error(`changed LOC for @${login} exceeds the safe integer range`)
-      reviewers.set(key, { login, changedLines })
-    }
-  }
-  return {
-    matches: matches.sort((left, right) => left.file.localeCompare(right.file, 'en')),
-    reviewers: [...reviewers.values()].sort((left, right) => {
-      if (left.changedLines !== right.changedLines) return left.changedLines < right.changedLines ? 1 : -1
-      return left.login.localeCompare(right.login, 'en')
-    }),
-  }
-}
-
-/**
- * Create a repository-scoped GitHub JSON API caller.
- * @param {{token: string, apiUrl?: string, fetchImpl?: typeof fetch}} options API dependencies.
- * @returns {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} API caller.
- */
-export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = globalThis.fetch }) {
-  if (!token) throw new Error('GITHUB_TOKEN is not set')
-  if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable')
-  const root = apiUrl.replace(/\/+$/u, '')
-  return async (path, { method = 'GET', body } = {}) => {
-    const response = await fetchImpl(`${root}${path}`, {
-      method,
-      headers: {
-        Accept: 'application/vnd.github+json',
-        Authorization: `Bearer ${token}`,
-        'Content-Type': 'application/json',
-        'User-Agent': 'deepseek-harness-request-review',
-        'X-GitHub-Api-Version': API_VERSION,
-      },
-      ...(body === undefined ? {} : { body: JSON.stringify(body) }),
-    })
-    if (!response.ok) {
-      const responseBody = await response.text()
-      throw new Error(`GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`)
-    }
-    if (response.status === 204) return undefined
-    return response.json()
-  }
-}
-
-/**
- * Fetch the complete pull-request file list or fail before routing a partial list.
- * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
- * @param {string} repository Owner/name repository identifier.
- * @param {number} pullNumber Pull-request number.
- * @param {number} expectedCount Pull-request changed-file count.
- * @returns {Promise<unknown[]>} Complete changed-file records.
- */
-export async function listPullRequestFiles(api, repository, pullNumber, expectedCount) {
-  if (!Number.isSafeInteger(expectedCount) || expectedCount < 0) {
-    throw new Error('pull request changed_files must be a non-negative integer')
-  }
-  if (expectedCount > MAX_PULL_REQUEST_FILES) {
-    throw new Error(`pull request has ${expectedCount} files; GitHub exposes at most ${MAX_PULL_REQUEST_FILES}`)
-  }
-  const files = []
-  for (let page = 1; files.length < expectedCount; page++) {
-    const response = await api(`/repos/${repository}/pulls/${pullNumber}/files?per_page=${PAGE_SIZE}&page=${page}`)
-    if (!Array.isArray(response) || response.length === 0) {
-      throw new Error(`GitHub returned ${files.length} of ${expectedCount} changed files`)
-    }
-    files.push(...response)
-    if (files.length > expectedCount) {
-      throw new Error(`GitHub returned ${files.length} files but the pull request reports ${expectedCount}`)
-    }
-  }
-  return files
-}
-
-/**
- * Fetch the complete chronological pull-request review list.
- * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
- * @param {string} repository Owner/name repository identifier.
- * @param {number} pullNumber Pull-request number.
- * @returns {Promise<unknown[]>} Complete review list within the supported limit.
- */
-export async function listPullRequestReviews(api, repository, pullNumber) {
-  const reviews = []
-  for (let page = 1; ; page++) {
-    const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`)
-    if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array')
-    reviews.push(...response)
-    if (response.length < PAGE_SIZE) return reviews
-    if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) {
-      throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} entries`)
-    }
-  }
-}
-
-/**
- * Return users whose latest undismissed decisive review approves the pull request.
- * @param {unknown[]} reviews Chronological GitHub pull-request review records.
- * @returns {string[]} Approved reviewer logins in stable order.
- */
-export function approvedReviewerLogins(reviews) {
-  const approved = new Map()
-  for (const review of reviews) {
-    if (!isRecord(review) || !isRecord(review.user) || typeof review.user.login !== 'string') {
-      throw new Error('pull-request reviews response contains an invalid reviewer')
-    }
-    if (typeof review.state !== 'string' || !PULL_REQUEST_REVIEW_STATES.has(review.state)) {
-      throw new Error('pull-request reviews response contains an invalid state')
-    }
-    const key = review.user.login.toLowerCase()
-    if (review.state === 'APPROVED') approved.set(key, review.user.login)
-    else if (review.state === 'CHANGES_REQUESTED') approved.delete(key)
-  }
-  return [...approved.values()].sort((left, right) => left.localeCompare(right, 'en'))
-}
-
-/**
- * Fetch the pull request timeline used to identify workflow-authored review requests.
- * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
- * @param {string} repository Owner/name repository identifier.
- * @param {number} pullNumber Pull-request number.
- * @returns {Promise<unknown[]>} Complete timeline event list within the supported limit.
- */
-export async function listPullRequestTimeline(api, repository, pullNumber) {
-  const events = []
-  for (let page = 1; ; page++) {
-    const response = await api(`/repos/${repository}/issues/${pullNumber}/timeline?per_page=${PAGE_SIZE}&page=${page}`)
-    if (!Array.isArray(response)) throw new Error('pull-request timeline response is not an array')
-    events.push(...response)
-    if (response.length < PAGE_SIZE) return events
-    if (events.length >= MAX_TIMELINE_EVENTS) {
-      throw new Error(`pull-request timeline exceeds ${MAX_TIMELINE_EVENTS} events`)
-    }
-  }
-}
-
-/** Return current requested reviewers whose latest request came from this workflow identity. */
-function workflowRequestedReviewers(events, requestedReviewers) {
-  const requested = new Map(requestedReviewers.map(login => [login.toLowerCase(), login]))
-  const latestRequester = new Map()
-  for (const event of events) {
-    if (!isRecord(event) || event.event !== 'review_requested') continue
-    if (!isRecord(event.requested_reviewer) || typeof event.requested_reviewer.login !== 'string') continue
-    const key = event.requested_reviewer.login.toLowerCase()
-    if (!requested.has(key)) continue
-    if (!isRecord(event.review_requester) || typeof event.review_requester.login !== 'string') {
-      throw new Error('review-request timeline event has no requester login')
-    }
-    latestRequester.set(key, event.review_requester.login.toLowerCase())
-  }
-  return [...requested]
-    .filter(([key]) => latestRequester.get(key) === WORKFLOW_REVIEW_REQUESTER)
-    .map(([, login]) => login)
-}
-
-/** Extract and validate individual logins from GitHub's requested-reviewer response. */
-function requestedReviewerLogins(response) {
-  if (!isRecord(response) || !Array.isArray(response.users)) {
-    throw new Error('requested-reviewers response has no users array')
-  }
-  return response.users.map((user) => {
-    if (!isRecord(user) || typeof user.login !== 'string') {
-      throw new Error('requested-reviewers response contains an invalid user')
-    }
-    return user.login
-  })
-}
-
-/**
- * Print changed paths, reconcile workflow-authored requests with current
- * ownership, and cancel workflow-authored requests on drafts.
- * @param {{event: unknown, ownershipSource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, write?: (line: string) => void}} options Runtime inputs.
- * @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[], requestedReviewers: string[], cancelledReviewers: string[]}>} Applied routing result.
- */
-export async function requestReviews({ event, ownershipSource, api, write = line => process.stdout.write(`${line}\n`) }) {
-  const pull = pullRequestFromEvent(event)
-  write('This is by automated Angry Turtle Cyborg, not a human')
-  const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount)
-  const { reviewableChanges, ...classified } = classifyChangedFiles(files)
-  const plan = planReviewers(parseOwnership(ownershipSource), reviewableChanges)
-  writeList(write, 'Changed code files', classified.changedCodeFiles.map(file => JSON.stringify(file)))
-  writeList(write, 'Excluded test files', classified.excludedTestFiles.map(file => JSON.stringify(file)))
-  writeList(
-    write,
-    'Excluded documentation files',
-    classified.excludedDocumentationFiles.map(file => JSON.stringify(file)),
-  )
-  writeList(
-    write,
-    'Excluded comment-only files',
-    classified.excludedCommentOnlyFiles.map(file => JSON.stringify(file)),
-  )
-  writeList(
-    write,
-    'Owners by changed file',
-    plan.matches.map(({ file, changedLines, owners }) =>
-      `${JSON.stringify(file)} (${changedLines} LOC): ${owners.length ? owners.join(' ') : '(none)'}`),
-  )
-  writeList(
-    write,
-    'Owner relevance by changed LOC',
-    plan.reviewers.map(({ login, changedLines }) => `@${login}: ${changedLines}`),
-  )
-
-  const ownerCandidates = plan.reviewers.filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
-  if (pull.draft) {
-    const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
-    const requestedReviewers = requestedReviewerLogins(existing)
-    const reviewers = requestedReviewers.length === 0
-      ? []
-      : workflowRequestedReviewers(
-          await listPullRequestTimeline(api, pull.repository, pull.number),
-          requestedReviewers,
-        )
-    writeList(write, 'Review requests to cancel', reviewers.map(login => `@${login}`))
-    if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
-
-    await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
-      method: 'DELETE',
-      body: { reviewers },
-    })
-    const requestLabel = reviewers.length === 1 ? 'request' : 'requests'
-    write(`Cancelled review ${requestLabel} for ${reviewers.map(login => `@${login}`).join(' ')}.`)
-    return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers }
-  }
-
-  const approvedReviewerKeys = new Set(
-    (ownerCandidates.length === 0
-      ? []
-      : approvedReviewerLogins(await listPullRequestReviews(api, pull.repository, pull.number)))
-      .map(login => login.toLowerCase()),
-  )
-  const approvedOwners = ownerCandidates.filter(({ login }) => approvedReviewerKeys.has(login.toLowerCase()))
-  const candidates = ownerCandidates.filter(({ login }) => !approvedReviewerKeys.has(login.toLowerCase()))
-  writeList(write, 'Approved owners omitted from review requests', approvedOwners.map(({ login }) => `@${login}`))
-
-  const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
-  const currentReviewers = requestedReviewerLogins(existing).sort((left, right) => left.localeCompare(right, 'en'))
-  const workflowReviewers = currentReviewers.length === 0
-    ? []
-    : workflowRequestedReviewers(
-        await listPullRequestTimeline(api, pull.repository, pull.number),
-        currentReviewers,
-      )
-  const workflowReviewerKeys = new Set(workflowReviewers.map(login => login.toLowerCase()))
-  const manualReviewers = currentReviewers.filter(login => !workflowReviewerKeys.has(login.toLowerCase()))
-  let retainedCountedSlots = Math.max(
-    0,
-    MAX_COUNTED_REQUESTED_REVIEWERS
-      - manualReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length,
-  )
-  const retainedWorkflowReviewerKeys = new Set()
-  for (const { login } of candidates) {
-    const key = login.toLowerCase()
-    if (!workflowReviewerKeys.has(key)) continue
-    if (key === UNCOUNTED_REVIEWER) retainedWorkflowReviewerKeys.add(key)
-    else if (retainedCountedSlots > 0) {
-      retainedWorkflowReviewerKeys.add(key)
-      retainedCountedSlots--
-    }
-  }
-  const reviewersToCancel = workflowReviewers.filter(
-    login => !retainedWorkflowReviewerKeys.has(login.toLowerCase()),
-  )
-  const cancelledReviewerKeys = new Set(reviewersToCancel.map(login => login.toLowerCase()))
-  const remainingReviewers = currentReviewers.filter(login => !cancelledReviewerKeys.has(login.toLowerCase()))
-  const alreadyRequested = new Set(remainingReviewers.map(login => login.toLowerCase()))
-  const availableSlots = Math.max(
-    0,
-    MAX_COUNTED_REQUESTED_REVIEWERS
-      - remainingReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length,
-  )
-  writeList(write, 'Current individual review requests', currentReviewers.map(login => `@${login}`))
-  write(`Available counted review request slots: ${availableSlots}.`)
-  const reviewers = candidates
-    .filter(({ login }) => !alreadyRequested.has(login.toLowerCase()))
-    .slice(0, availableSlots)
-    .map(({ login }) => login)
-  writeList(write, 'Review requests to cancel', reviewersToCancel.map(login => `@${login}`))
-  writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`))
-  if (reviewersToCancel.length > 0) {
-    await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
-      method: 'DELETE',
-      body: { reviewers: reviewersToCancel },
-    })
-    const requestLabel = reviewersToCancel.length === 1 ? 'request' : 'requests'
-    write(`Cancelled review ${requestLabel} for ${reviewersToCancel.map(login => `@${login}`).join(' ')}.`)
-  }
-
-  if (reviewers.length > 0) {
-    await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
-      method: 'POST',
-      body: { reviewers },
-    })
-    write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`)
-  }
-  return { ...classified, requestedReviewers: reviewers, cancelledReviewers: reviewersToCancel }
-}
-
-function pullRequestFromEvent(event) {
-  if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string') {
-    throw new Error('event has no repository.full_name')
-  }
-  if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)) {
-    throw new Error('event has no pull_request')
-  }
-  const { pull_request: pull } = event
-  if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number')
-  if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag')
-  if (typeof pull.user.login !== 'string' || !pull.user.login) throw new Error('pull request has no author login')
-  return {
-    repository: event.repository.full_name,
-    number: pull.number,
-    draft: pull.draft,
-    author: pull.user.login,
-    changedFileCount: pull.changed_files,
-  }
-}
-
-function writeList(write, title, entries) {
-  write(`${title}:`)
-  if (entries.length === 0) write('- (none)')
-  else for (const entry of entries) write(`- ${entry}`)
-}
-
-function isRecord(value) {
-  return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
-async function main() {
-  const eventPath = process.env.GITHUB_EVENT_PATH
-  if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set')
-  const event = JSON.parse(readFileSync(eventPath, 'utf8'))
-  const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8')
-  const api = createGitHubApi({
-    token: process.env.GITHUB_TOKEN ?? '',
-    apiUrl: process.env.GITHUB_API_URL,
-  })
-  await requestReviews({ event, ownershipSource, api })
-}
-
-if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
-  main().catch((error) => {
-    process.stderr.write(`request-review failed: ${error instanceof Error ? error.message : String(error)}\n`)
-    process.exitCode = 1
-  })
-}

+ 0 - 869
.github/review-ownership/request-review.test.mjs

@@ -1,869 +0,0 @@
-import assert from 'node:assert/strict'
-import { execFileSync } from 'node:child_process'
-import { existsSync, readFileSync } from 'node:fs'
-import test from 'node:test'
-
-import {
-  approvedReviewerLogins,
-  classifyChangedFiles,
-  createGitHubApi,
-  isCommentOnlyChange,
-  isDocumentationPath,
-  isTestPath,
-  listPullRequestFiles,
-  listPullRequestReviews,
-  listPullRequestTimeline,
-  normalizeRepositoryPath,
-  parseOwnership,
-  planReviewers,
-  requestReviews,
-} from './request-review.mjs'
-
-const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8')
-
-const pullRequestEvent = ({ author = 'author', changedFiles = 1, draft = false } = {}) => ({
-  repository: { full_name: 'deepseek-harness/deepseek-harness' },
-  pull_request: {
-    number: 42,
-    draft,
-    changed_files: changedFiles,
-    user: { login: author },
-  },
-})
-
-test('loads the repository ownership policy without test-only directory rules', () => {
-  const rules = parseOwnership(ownershipSource)
-  const ownersByPattern = new Map(rules.map(rule => [rule.pattern, rule.owners]))
-  assert.equal(rules.length, 57)
-  assert.equal(rules.some(rule => rule.pattern === '/benchmarks/'), false)
-  assert.equal(rules.some(rule => rule.pattern === '/scripts/'), false)
-  assert.equal(rules.some(rule => rule.pattern === '/snapshots/'), false)
-  assert.equal(rules.some(rule => rule.pattern === '/packages/test-support/'), false)
-  assert.deepEqual(ownersByPattern.get('/apps/cli/'), ['@turtle1999'])
-  assert.deepEqual(ownersByPattern.get('/docs/'), ['@turtle1999'])
-  assert.deepEqual(ownersByPattern.get('/packages/core/'), ['@turtle1999', '@mektpoy'])
-  assert.deepEqual(ownersByPattern.get('/packages/llm/'), ['@LegGasai'])
-  assert.deepEqual(ownersByPattern.get('/packages/preset/'), ['@LegGasai', '@turtle1999'])
-  assert.deepEqual(ownersByPattern.get('/packages/session/'), ['@turtle1999', '@mektpoy'])
-  assert.deepEqual(ownersByPattern.get('/packages/subagent/'), ['@Dudu-0223'])
-  assert.deepEqual(ownersByPattern.get('/packages/web/'), ['@imccyu'])
-  assert.deepEqual(ownersByPattern.get('/python/'), ['@LegGasai'])
-  assert.deepEqual(ownersByPattern.get('/website/'), ['@LegGasai'])
-  assert.equal(rules.every(rule => rule.owners.length <= 2), true)
-  for (const excludedOwner of ['@tianyicui', '@kermeanx', '@pkh-xht']) {
-    assert.equal(rules.some(rule => rule.owners.some(owner => owner.toLowerCase() === excludedOwner)), false)
-  }
-})
-
-test('keeps turtle below one third of the eligible owned codebase', () => {
-  const rules = parseOwnership(ownershipSource)
-  const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' })
-    .split('\0')
-    .filter(file => file && existsSync(file))
-  let ownedLines = 0
-  let turtleLines = 0
-  for (const file of trackedFiles) {
-    if (isTestPath(file) || isDocumentationPath(file)) continue
-    const owners = planReviewers(rules, [{ paths: [file], changedLines: 0 }]).matches[0]?.owners ?? []
-    if (owners.length === 0) continue
-    const content = readFileSync(file)
-    const lines = content.length === 0
-      ? 0
-      : content.reduce((count, byte) => count + (byte === 10 ? 1 : 0), 0) + (content.at(-1) === 10 ? 0 : 1)
-    ownedLines += lines
-    if (owners.includes('@turtle1999')) turtleLines += lines
-  }
-  assert.ok(
-    turtleLines * 3 <= ownedLines,
-    `@turtle1999 owns ${turtleLines} of ${ownedLines} eligible owned lines`,
-  )
-})
-
-test('rejects ownership forms the requester cannot apply safely', () => {
-  for (const [source, message] of [
-    ['', /contains no rules/u],
-    ['* @owner\n', /explicit absolute directory/u],
-    ['/.github/ @owner\n', /hidden-directory/u],
-    ['/packages/*/ @owner\n', /explicit absolute directory/u],
-    ['/packages/core/\n', /at least one owner/u],
-    ['/packages/core/ @org/team\n', /individual GitHub users/u],
-    ['/packages/core/ @one @two @three\n', /at most 2 owners/u],
-    ['/packages/core/ @owner @OWNER\n', /duplicate owner/u],
-    ['/packages/core/ @owner\n/packages/core/ @other\n', /duplicate pattern/u],
-  ]) {
-    assert.throws(() => parseOwnership(source), message)
-  }
-})
-
-test('recognizes every repository test location and filename convention', () => {
-  for (const file of [
-    'apps/cli/tests/args.spec.ts',
-    'apps/cli/tests/harness.ts',
-    'apps/web/stress-tests/reasoning-chunks.stress.ts',
-    'benchmarks/session-open/workload.ts',
-    'native/landlock-run/test/entry.test.js',
-    'packages/core/agent/__tests__/agent.ts',
-    'packages/core/agent/benches/agent.rs',
-    'packages/core/agent/src/agent.compat.spec.ts',
-    'packages/core/agent/src/__snapshots__/agent.ts.snap',
-    'packages/session-query/session-query/tests/test-service.ts',
-    'packages/test-support/session-snapshot/src/index.ts',
-    'python/sdk/src/test_client.py',
-    'python/sdk/src/client_test.py',
-    'scripts/fixtures/translation-prompt/response.txt',
-    'scripts/session-snapshot-corpus.corpus.ts',
-    'scripts/snapshots/translation-prompt-v4/request-response.expected.json',
-    'snapshots/session/headless.snapshot.ts',
-  ]) {
-    assert.equal(isTestPath(file), true, file)
-  }
-})
-
-test('does not confuse production names with tests', () => {
-  for (const file of [
-    'apps/cli/src/testing.ts',
-    'packages/core/agent/src/contest.ts',
-    'packages/session/session-format/src/snapshot.ts',
-    'packages/session/session-format/src/spec.ts',
-    'packages/session/session-format/src/test.ts',
-    'scripts/run-gates.ts',
-    'vitest.config.ts',
-    'vitest.bench.config.ts',
-    'vitest.e2e.config.ts',
-    'vitest.snapshot.config.ts',
-    'vitest.web.perf.config.ts',
-    'website/docs.ts',
-  ]) {
-    assert.equal(isTestPath(file), false, file)
-  }
-})
-
-test('excludes Markdown and YAML documentation extensions', () => {
-  for (const file of [
-    'README.md',
-    'docs/architecture.MD',
-    'packages/subagent/subagent/guide.yaml',
-    'profiles/example.YAML',
-  ]) {
-    assert.equal(isDocumentationPath(file), true, file)
-  }
-  for (const file of [
-    '.github/workflows/request-review.yml',
-    'packages/subagent/subagent/src/index.ts',
-    'website/docs.ts',
-  ]) {
-    assert.equal(isDocumentationPath(file), false, file)
-  }
-})
-
-test('detects comment-only changes only from complete supported patches', () => {
-  for (const file of [
-    {
-      filename: 'packages/core/agent/src/index.ts',
-      status: 'modified', additions: 1, deletions: 1,
-      patch: '@@ -1,2 +1,2 @@\n-// old note\n+// new note\n const value = "https://example.com"',
-    },
-    {
-      filename: 'python/sdk/src/client.py',
-      status: 'modified', additions: 1, deletions: 1,
-      patch: '@@ -1 +1 @@\n-value = 1  # old note\n+value = 1  # new note',
-    },
-    {
-      filename: 'native/landlock-run/src/main.rs',
-      status: 'modified', additions: 1, deletions: 1,
-      patch: '@@ -1 +1 @@\n-let value = 1; /* old note */\n+let value = 1; /* new note */',
-    },
-  ]) {
-    assert.equal(isCommentOnlyChange(file), true, file.filename)
-  }
-
-  for (const file of [
-    {
-      filename: 'packages/core/agent/src/index.ts',
-      status: 'modified', additions: 1, deletions: 1,
-      patch: '@@ -1 +1 @@\n-const value = 1 // note\n+const value = 2 // note',
-    },
-    {
-      filename: 'packages/core/agent/src/index.ts',
-      status: 'modified', additions: 2, deletions: 1,
-      patch: '@@ -1 +1 @@\n-// old note\n+// new note',
-    },
-    {
-      filename: 'packages/core/agent/src/data.json',
-      status: 'modified', additions: 1, deletions: 1,
-      patch: '@@ -1 +1 @@\n-{"value":1}\n+{"value":2}',
-    },
-    {
-      filename: 'native/landlock-run/src/main.rs',
-      status: 'modified', additions: 1, deletions: 1,
-      patch: '@@ -1 +1 @@\n-let value = r#"https://old.example"#;\n+let value = r#"https://new.example"#;',
-    },
-    {
-      filename: 'packages/core/agent/src/index.ts',
-      status: 'renamed', additions: 1, deletions: 1,
-      patch: '@@ -1 +1 @@\n-// old note\n+// new note',
-    },
-  ]) {
-    assert.equal(isCommentOnlyChange(file), false, file.filename)
-  }
-})
-
-test('normalizes separators and rejects paths that are not repository-relative', () => {
-  assert.equal(normalizeRepositoryPath('./packages\\core\\agent\\src\\index.ts'), 'packages/core/agent/src/index.ts')
-  for (const file of ['', '/absolute.ts', '../escape.ts', 'packages//empty.ts', 'packages/./same.ts']) {
-    assert.throws(() => normalizeRepositoryPath(file), /path/u, file)
-  }
-})
-
-test('classifies both sides of a rename independently', () => {
-  assert.deepEqual(
-    classifyChangedFiles([
-      {
-        filename: 'packages/core/agent/tests/moved.spec.ts',
-        previous_filename: 'packages/core/agent/src/moved.ts',
-        additions: 3,
-        deletions: 2,
-      },
-      {
-        filename: 'packages/client/store/src/restored.ts',
-        previous_filename: 'packages/client/store/tests/restored.spec.ts',
-        additions: 2,
-        deletions: 1,
-      },
-      { filename: 'packages/core/agent/README.md', additions: 1, deletions: 0 },
-      {
-        filename: 'packages/core/agent/src/commented.ts',
-        status: 'modified', additions: 1, deletions: 1,
-        patch: '@@ -1 +1 @@\n-// old note\n+// new note',
-      },
-    ]),
-    {
-      changedCodeFiles: [
-        'packages/client/store/src/restored.ts',
-        'packages/core/agent/src/moved.ts',
-      ],
-      reviewableChanges: [
-        { paths: ['packages/core/agent/src/moved.ts'], changedLines: 5 },
-        { paths: ['packages/client/store/src/restored.ts'], changedLines: 3 },
-      ],
-      excludedTestFiles: [
-        'packages/client/store/tests/restored.spec.ts',
-        'packages/core/agent/tests/moved.spec.ts',
-      ],
-      excludedDocumentationFiles: ['packages/core/agent/README.md'],
-      excludedCommentOnlyFiles: ['packages/core/agent/src/commented.ts'],
-    },
-  )
-})
-
-test('uses the last matching ownership rule and ranks owners by changed LOC', () => {
-  const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n')
-  assert.deepEqual(
-    planReviewers(rules, [
-      { paths: ['AGENTS.md'], changedLines: 1 },
-      { paths: ['packages/core/agent/src/index.ts'], changedLines: 8 },
-      { paths: ['packages/fs/fs/src/index.ts'], changedLines: 3 },
-    ]),
-    {
-      matches: [
-        { file: 'AGENTS.md', changedLines: 1, owners: [] },
-        { file: 'packages/core/agent/src/index.ts', changedLines: 8, owners: ['@core', '@second'] },
-        { file: 'packages/fs/fs/src/index.ts', changedLines: 3, owners: ['@broad'] },
-      ],
-      reviewers: [
-        { login: 'core', changedLines: 8 },
-        { login: 'second', changedLines: 8 },
-        { login: 'broad', changedLines: 3 },
-      ],
-    },
-  )
-})
-
-test('counts each changed-file record once per owner across rename paths', () => {
-  const rules = parseOwnership('/packages/a/ @same @a\n/packages/b/ @same @b\n/packages/c/ @c\n')
-  const plan = planReviewers(rules, [
-    { paths: ['packages/a/old.ts', 'packages/b/new.ts'], changedLines: 10 },
-    { paths: ['packages/a/other.ts'], changedLines: 5 },
-    { paths: ['packages/c/tiny.ts'], changedLines: 1 },
-  ])
-  assert.deepEqual(plan.reviewers, [
-    { login: 'a', changedLines: 15 },
-    { login: 'same', changedLines: 15 },
-    { login: 'b', changedLines: 10 },
-    { login: 'c', changedLines: 1 },
-  ])
-})
-
-test('rejects invalid changed-file LOC', () => {
-  for (const file of [
-    { filename: 'packages/core/index.ts', deletions: 0 },
-    { filename: 'packages/core/index.ts', additions: -1, deletions: 0 },
-    { filename: 'packages/core/index.ts', additions: Number.MAX_SAFE_INTEGER, deletions: 1 },
-  ]) {
-    assert.throws(() => classifyChangedFiles([file]), /changed-file|LOC/u)
-  }
-})
-
-test('fetches every declared changed file across pages', async () => {
-  const calls = []
-  const pageOne = Array.from({ length: 100 }, (_, index) => ({ filename: `packages/core/file-${index}.ts` }))
-  const pageTwo = [{ filename: 'packages/core/file-100.ts' }]
-  const api = async (path) => {
-    calls.push(path)
-    return calls.length === 1 ? pageOne : pageTwo
-  }
-  const files = await listPullRequestFiles(api, 'owner/repo', 42, 101)
-  assert.equal(files.length, 101)
-  assert.deepEqual(calls, [
-    '/repos/owner/repo/pulls/42/files?per_page=100&page=1',
-    '/repos/owner/repo/pulls/42/files?per_page=100&page=2',
-  ])
-})
-
-test('fails closed when GitHub cannot provide the complete file list', async () => {
-  let calls = 0
-  await assert.rejects(
-    listPullRequestFiles(async () => {
-      calls++
-      return calls === 1 ? [{ filename: 'one.ts' }] : []
-    }, 'owner/repo', 42, 2),
-    /returned 1 of 2/u,
-  )
-  await assert.rejects(
-    listPullRequestFiles(async () => [], 'owner/repo', 42, 3_001),
-    /at most 3000/u,
-  )
-})
-
-test('fetches pull-request reviews across pages', async () => {
-  const calls = []
-  const pageOne = Array.from({ length: 100 }, (_, index) => ({
-    user: { login: `reviewer-${index}` },
-    state: 'COMMENTED',
-  }))
-  const pageTwo = [{ user: { login: 'approver' }, state: 'APPROVED' }]
-  const reviews = await listPullRequestReviews(async (path) => {
-    calls.push(path)
-    return calls.length === 1 ? pageOne : pageTwo
-  }, 'owner/repo', 42)
-
-  assert.equal(reviews.length, 101)
-  assert.deepEqual(calls, [
-    '/repos/owner/repo/pulls/42/reviews?per_page=100&page=1',
-    '/repos/owner/repo/pulls/42/reviews?per_page=100&page=2',
-  ])
-})
-
-test('tracks each reviewer\'s latest undismissed approval decision', () => {
-  assert.deepEqual(approvedReviewerLogins([
-    { user: { login: 'commented-after' }, state: 'APPROVED' },
-    { user: { login: 'commented-after' }, state: 'COMMENTED' },
-    { user: { login: 'changes-after' }, state: 'APPROVED' },
-    { user: { login: 'changes-after' }, state: 'CHANGES_REQUESTED' },
-    { user: { login: 'dismissed' }, state: 'DISMISSED' },
-    { user: { login: 'approved-after' }, state: 'CHANGES_REQUESTED' },
-    { user: { login: 'approved-after' }, state: 'APPROVED' },
-    { user: { login: 'pending-after' }, state: 'APPROVED' },
-    { user: { login: 'pending-after' }, state: 'PENDING' },
-  ]), ['approved-after', 'commented-after', 'pending-after'])
-
-  assert.throws(
-    () => approvedReviewerLogins([{ user: { login: 'reviewer' }, state: 'UNKNOWN' }]),
-    /invalid state/u,
-  )
-  assert.throws(() => approvedReviewerLogins([{ state: 'APPROVED' }]), /invalid reviewer/u)
-})
-
-test('fails closed when the pull-request review list exceeds its limit', async () => {
-  let calls = 0
-  await assert.rejects(
-    listPullRequestReviews(async () => {
-      calls++
-      return Array.from({ length: 100 }, () => ({ user: { login: 'reviewer' }, state: 'COMMENTED' }))
-    }, 'owner/repo', 42),
-    /exceed 3000 entries/u,
-  )
-  assert.equal(calls, 30)
-})
-
-test('fails closed when the review-request timeline exceeds its limit', async () => {
-  let calls = 0
-  await assert.rejects(
-    listPullRequestTimeline(async () => {
-      calls++
-      return Array.from({ length: 100 }, () => ({ event: 'commented' }))
-    }, 'owner/repo', 42),
-    /exceeds 3000 events/u,
-  )
-  assert.equal(calls, 30)
-})
-
-test('prints changed code files and requests the highest-ranked counted owner', async () => {
-  const trace = []
-  const files = [
-    { filename: 'packages/core/agent/src/index.ts', additions: 70, deletions: 10 },
-    { filename: 'packages/preset/agent-presets/src/index.ts', additions: 5, deletions: 5 },
-    { filename: 'packages/client/store/src/index.ts', additions: 2, deletions: 0 },
-    { filename: 'packages/subagent/subagent/src/index.ts', additions: 40, deletions: 0 },
-    { filename: 'packages/core/agent/tests/index.spec.ts', additions: 100, deletions: 0 },
-    { filename: 'AGENTS.md', additions: 200, deletions: 0 },
-  ]
-  const api = async (path, options = {}) => {
-    trace.push({ type: 'api', path, options })
-    if (path.endsWith('/files?per_page=100&page=1')) return files
-    if (path.endsWith('/reviews?per_page=100&page=1')) return []
-    if (path.endsWith('/requested_reviewers') && options.method !== 'POST') {
-      return { users: [], teams: [] }
-    }
-    if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
-    throw new Error(`unexpected API path ${path}`)
-  }
-
-  const result = await requestReviews({
-    event: pullRequestEvent({ author: 'turtle1999', changedFiles: files.length }),
-    ownershipSource,
-    api,
-    write: line => trace.push({ type: 'log', line }),
-  })
-
-  assert.deepEqual(result, {
-    changedCodeFiles: [
-      'packages/client/store/src/index.ts',
-      'packages/core/agent/src/index.ts',
-      'packages/preset/agent-presets/src/index.ts',
-      'packages/subagent/subagent/src/index.ts',
-    ],
-    excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'],
-    excludedDocumentationFiles: ['AGENTS.md'],
-    excludedCommentOnlyFiles: [],
-    requestedReviewers: ['mektpoy'],
-    cancelledReviewers: [],
-  })
-  assert.equal(trace[0].type, 'log')
-  assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human')
-  const changedHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Changed code files:')
-  const relevanceHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Owner relevance by changed LOC:')
-  const post = trace.findIndex(item => item.type === 'api' && item.options.method === 'POST')
-  assert.ok(changedHeading >= 0 && changedHeading < relevanceHeading && relevanceHeading < post)
-  assert.deepEqual(trace.slice(relevanceHeading, relevanceHeading + 6).map(item => item.line), [
-    'Owner relevance by changed LOC:',
-    '- @turtle1999: 90',
-    '- @mektpoy: 80',
-    '- @Dudu-0223: 40',
-    '- @LegGasai: 10',
-    '- @imccyu: 2',
-  ])
-  assert.deepEqual(trace[post], {
-    type: 'api',
-    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
-    options: {
-      method: 'POST',
-      body: { reviewers: ['mektpoy'] },
-    },
-  })
-})
-
-test('does not request an owner again after that owner approves', async () => {
-  const calls = []
-  const output = []
-  const result = await requestReviews({
-    event: pullRequestEvent(),
-    ownershipSource: '/packages/typert/ @imccyu\n',
-    api: async (path, options = {}) => {
-      calls.push({ path, options })
-      if (path.endsWith('/files?per_page=100&page=1')) {
-        return [{ filename: 'packages/typert/generator/src/analyzer.ts', additions: 150, deletions: 47 }]
-      }
-      if (path.endsWith('/reviews?per_page=100&page=1')) {
-        return [
-          { user: { login: 'imccyu' }, state: 'APPROVED' },
-          { user: { login: 'imccyu' }, state: 'COMMENTED' },
-        ]
-      }
-      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [], teams: [] }
-      }
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: line => output.push(line),
-  })
-
-  assert.deepEqual(result.requestedReviewers, [])
-  assert.equal(calls.some(call => call.options.method === 'POST'), false)
-  const approvedHeading = output.indexOf('Approved owners omitted from review requests:')
-  assert.ok(approvedHeading >= 0)
-  assert.equal(output[approvedHeading + 1], '- @imccyu')
-})
-
-test('fills the counted slot with the next owner after omitting an approved owner', async () => {
-  const calls = []
-  const result = await requestReviews({
-    event: pullRequestEvent(),
-    ownershipSource: '/packages/core/ @imccyu @mektpoy\n',
-    api: async (path, options = {}) => {
-      calls.push({ path, options })
-      if (path.endsWith('/files?per_page=100&page=1')) {
-        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
-      }
-      if (path.endsWith('/reviews?per_page=100&page=1')) {
-        return [{ user: { login: 'imccyu' }, state: 'APPROVED' }]
-      }
-      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [], teams: [] }
-      }
-      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: () => {},
-  })
-
-  assert.deepEqual(result.requestedReviewers, ['mektpoy'])
-  assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
-    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
-    options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
-  })
-})
-
-test('does not add another counted owner when one is already requested', async () => {
-  const calls = []
-  const output = []
-  const result = await requestReviews({
-    event: pullRequestEvent(),
-    ownershipSource: '/packages/core/ @mektpoy\n',
-    api: async (path, options = {}) => {
-      calls.push({ path, options })
-      if (path.endsWith('/files?per_page=100&page=1')) {
-        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
-      }
-      if (path.endsWith('/reviews?per_page=100&page=1')) return []
-      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [{ login: 'first' }], teams: [] }
-      }
-      if (path.endsWith('/timeline?per_page=100&page=1')) return []
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: line => output.push(line),
-  })
-
-  assert.deepEqual(result.requestedReviewers, [])
-  assert.equal(calls.some(call => call.options.method === 'POST'), false)
-  assert.deepEqual(output.slice(-7), [
-    'Current individual review requests:',
-    '- @first',
-    'Available counted review request slots: 0.',
-    'Review requests to cancel:',
-    '- (none)',
-    'Reviewers to request:',
-    '- (none)',
-  ])
-})
-
-test('requests at most one owner per run when turtle ranks first', async () => {
-  const calls = []
-  const result = await requestReviews({
-    event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }),
-    ownershipSource: '/packages/core/ @turtle1999\n/packages/client/ @mektpoy\n',
-    api: async (path, options = {}) => {
-      calls.push({ path, options })
-      if (path.endsWith('/files?per_page=100&page=1')) {
-        return [
-          { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 },
-          { filename: 'packages/client/store/src/index.ts', additions: 8, deletions: 2 },
-        ]
-      }
-      if (path.endsWith('/reviews?per_page=100&page=1')) return []
-      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [], teams: [] }
-      }
-      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: () => {},
-  })
-
-  assert.deepEqual(result.requestedReviewers, ['turtle1999'])
-  assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
-    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
-    options: { method: 'POST', body: { reviewers: ['turtle1999'] } },
-  })
-})
-
-test('does not add turtle when one counted reviewer is already requested', async () => {
-  const calls = []
-  const result = await requestReviews({
-    event: pullRequestEvent({ author: 'contributor' }),
-    ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n',
-    api: async (path, options = {}) => {
-      calls.push({ path, options })
-      if (path.endsWith('/files?per_page=100&page=1')) {
-        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
-      }
-      if (path.endsWith('/reviews?per_page=100&page=1')) return []
-      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [{ login: 'first' }], teams: [] }
-      }
-      if (path.endsWith('/timeline?per_page=100&page=1')) return []
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: () => {},
-  })
-
-  assert.deepEqual(result.requestedReviewers, [])
-  assert.equal(calls.some(call => call.options.method === 'POST'), false)
-})
-
-test('keeps the counted slot available when turtle is already requested', async () => {
-  const calls = []
-  const result = await requestReviews({
-    event: pullRequestEvent({ author: 'contributor' }),
-    ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n',
-    api: async (path, options = {}) => {
-      calls.push({ path, options })
-      if (path.endsWith('/files?per_page=100&page=1')) {
-        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
-      }
-      if (path.endsWith('/reviews?per_page=100&page=1')) return []
-      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [{ login: 'turtle1999' }], teams: [] }
-      }
-      if (path.endsWith('/timeline?per_page=100&page=1')) return []
-      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: () => {},
-  })
-
-  assert.deepEqual(result.requestedReviewers, ['mektpoy'])
-  assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
-    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
-    options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
-  })
-})
-
-test('replaces a workflow reviewer that no longer matches current ownership', async () => {
-  const trace = []
-  const result = await requestReviews({
-    event: pullRequestEvent({ author: 'contributor' }),
-    ownershipSource: '/packages/core/ @mektpoy\n',
-    api: async (path, options = {}) => {
-      trace.push({ type: 'api', path, options })
-      if (path.endsWith('/files?per_page=100&page=1')) {
-        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
-      }
-      if (path.endsWith('/reviews?per_page=100&page=1')) return []
-      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [{ login: 'Dudu-0223' }], teams: [] }
-      }
-      if (path.endsWith('/timeline?per_page=100&page=1')) {
-        return [{
-          event: 'review_requested',
-          requested_reviewer: { login: 'Dudu-0223' },
-          review_requester: { login: 'github-actions[bot]' },
-        }]
-      }
-      if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
-      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: line => trace.push({ type: 'log', line }),
-  })
-
-  assert.deepEqual(result.requestedReviewers, ['mektpoy'])
-  assert.deepEqual(result.cancelledReviewers, ['Dudu-0223'])
-  const cancelLog = trace.findIndex(item => item.type === 'log' && item.line === 'Review requests to cancel:')
-  const requestLog = trace.findIndex(item => item.type === 'log' && item.line === 'Reviewers to request:')
-  const firstMutation = trace.findIndex(item => item.type === 'api' && item.options.method !== undefined)
-  assert.ok(cancelLog >= 0 && requestLog >= 0 && cancelLog < firstMutation && requestLog < firstMutation)
-  assert.equal(trace[cancelLog + 1].line, '- @Dudu-0223')
-  assert.equal(trace[requestLog + 1].line, '- @mektpoy')
-  assert.deepEqual(trace.filter(item => item.type === 'api' && item.options.method !== undefined), [
-    {
-      type: 'api',
-      path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
-      options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
-    },
-    {
-      type: 'api',
-      path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
-      options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
-    },
-  ])
-})
-
-test('removes excess workflow reviewers using current relevance order', async () => {
-  const calls = []
-  const result = await requestReviews({
-    event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }),
-    ownershipSource: '/packages/core/ @mektpoy\n/packages/subagent/ @Dudu-0223\n',
-    api: async (path, options = {}) => {
-      calls.push({ path, options })
-      if (path.endsWith('/files?per_page=100&page=1')) {
-        return [
-          { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 },
-          { filename: 'packages/subagent/subagent/src/index.ts', additions: 8, deletions: 2 },
-        ]
-      }
-      if (path.endsWith('/reviews?per_page=100&page=1')) return []
-      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [{ login: 'Dudu-0223' }, { login: 'mektpoy' }], teams: [] }
-      }
-      if (path.endsWith('/timeline?per_page=100&page=1')) {
-        return ['Dudu-0223', 'mektpoy'].map(login => ({
-          event: 'review_requested',
-          requested_reviewer: { login },
-          review_requester: { login: 'github-actions[bot]' },
-        }))
-      }
-      if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: () => {},
-  })
-
-  assert.deepEqual(result, {
-    changedCodeFiles: [
-      'packages/core/agent/src/index.ts',
-      'packages/subagent/subagent/src/index.ts',
-    ],
-    excludedTestFiles: [],
-    excludedDocumentationFiles: [],
-    excludedCommentOnlyFiles: [],
-    requestedReviewers: [],
-    cancelledReviewers: ['Dudu-0223'],
-  })
-  assert.deepEqual(calls.find(call => call.options.method === 'DELETE'), {
-    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
-    options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
-  })
-})
-
-test('does not request reviewers for test, documentation, or comment-only changes', async () => {
-  const calls = []
-  const output = []
-  const files = [
-    { filename: 'apps/web/tests/chat.e2e.ts', additions: 10, deletions: 0 },
-    { filename: 'packages/core/agent/tests/agent.spec.ts', additions: 10, deletions: 0 },
-    { filename: 'packages/core/agent/README.md', additions: 10, deletions: 0 },
-    { filename: 'packages/core/agent/examples.yaml', additions: 10, deletions: 0 },
-    {
-      filename: 'packages/core/agent/src/index.ts',
-      status: 'modified', additions: 1, deletions: 1,
-      patch: '@@ -1 +1 @@\n-// old note\n+// new note',
-    },
-  ]
-  const result = await requestReviews({
-    event: pullRequestEvent({ changedFiles: files.length }),
-    ownershipSource,
-    api: async (path) => {
-      calls.push(path)
-      if (path.endsWith('/files?per_page=100&page=1')) return files
-      if (path.endsWith('/requested_reviewers')) return { users: [], teams: [] }
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: line => output.push(line),
-  })
-  assert.deepEqual(result, {
-    changedCodeFiles: [],
-    excludedTestFiles: files.slice(0, 2).map(file => file.filename),
-    excludedDocumentationFiles: files.slice(2, 4).map(file => file.filename),
-    excludedCommentOnlyFiles: ['packages/core/agent/src/index.ts'],
-    requestedReviewers: [],
-    cancelledReviewers: [],
-  })
-  assert.equal(calls.length, 2)
-  assert.deepEqual(output.slice(0, 4), [
-    'This is by automated Angry Turtle Cyborg, not a human',
-    'Changed code files:',
-    '- (none)',
-    'Excluded test files:',
-  ])
-})
-
-test('cancels workflow-authored review requests on draft pull requests', async () => {
-  const trace = []
-  const files = [
-    { filename: 'packages/subagent/subagent/src/index.ts', additions: 10, deletions: 2 },
-    { filename: 'packages/subagent/subagent/tests/index.spec.ts', additions: 10, deletions: 0 },
-    { filename: 'packages/subagent/subagent/README.md', additions: 10, deletions: 0 },
-  ]
-  const result = await requestReviews({
-    event: pullRequestEvent({ draft: true, changedFiles: files.length }),
-    ownershipSource,
-    api: async (path, options = {}) => {
-      trace.push({ type: 'api', path, options })
-      if (path.endsWith('/files?per_page=100&page=1')) return files
-      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [{ login: 'Dudu-0223' }, { login: 'manual-reviewer' }], teams: [] }
-      }
-      if (path.endsWith('/timeline?per_page=100&page=1')) {
-        return [
-          {
-            event: 'review_requested',
-            requested_reviewer: { login: 'Dudu-0223' },
-            review_requester: { login: 'maintainer' },
-          },
-          {
-            event: 'review_requested',
-            requested_reviewer: { login: 'Dudu-0223' },
-            review_requester: { login: 'github-actions[bot]' },
-          },
-          {
-            event: 'review_requested',
-            requested_reviewer: { login: 'manual-reviewer' },
-            review_requester: { login: 'github-actions[bot]' },
-          },
-          {
-            event: 'review_requested',
-            requested_reviewer: { login: 'manual-reviewer' },
-            review_requester: { login: 'maintainer' },
-          },
-        ]
-      }
-      if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
-      throw new Error(`unexpected API path ${path}`)
-    },
-    write: line => trace.push({ type: 'log', line }),
-  })
-  assert.deepEqual(result, {
-    changedCodeFiles: ['packages/subagent/subagent/src/index.ts'],
-    excludedTestFiles: ['packages/subagent/subagent/tests/index.spec.ts'],
-    excludedDocumentationFiles: ['packages/subagent/subagent/README.md'],
-    excludedCommentOnlyFiles: [],
-    requestedReviewers: [],
-    cancelledReviewers: ['Dudu-0223'],
-  })
-  const remove = trace.find(item => item.type === 'api' && item.options.method === 'DELETE')
-  assert.deepEqual(remove, {
-    type: 'api',
-    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
-    options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
-  })
-  assert.equal(trace.some(item => item.type === 'log' && item.line === '- @manual-reviewer'), false)
-  assert.equal(trace.at(-1).line, 'Cancelled review request for @Dudu-0223.')
-})
-
-test('sends authenticated JSON and escapes an API error body', async () => {
-  const requests = []
-  const api = createGitHubApi({
-    token: 'secret',
-    apiUrl: 'https://github.example/api/v3/',
-    fetchImpl: async (url, options) => {
-      requests.push({ url, options })
-      return new Response(JSON.stringify({ ok: true }), {
-        status: 200,
-        headers: { 'Content-Type': 'application/json' },
-      })
-    },
-  })
-  assert.deepEqual(await api('/repos/owner/repo', { method: 'POST', body: { value: 1 } }), { ok: true })
-  assert.equal(requests[0].url, 'https://github.example/api/v3/repos/owner/repo')
-  assert.equal(requests[0].options.headers.Authorization, 'Bearer secret')
-  assert.equal(requests[0].options.headers['X-GitHub-Api-Version'], '2026-03-10')
-  assert.equal(requests[0].options.body, '{"value":1}')
-
-  const failing = createGitHubApi({
-    token: 'secret',
-    fetchImpl: async () => new Response('::error::untrusted\nbody', { status: 422 }),
-  })
-  await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u)
-})

+ 0 - 31
.github/workflows/request-review.yml

@@ -1,31 +0,0 @@
-name: request-review
-
-on:
-  pull_request_target:
-    types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
-
-permissions:
-  contents: read
-  pull-requests: write
-
-concurrency:
-  group: request-review-${{ github.event.pull_request.number }}
-  cancel-in-progress: true
-
-jobs:
-  request-review:
-    name: request-review
-    runs-on: ubuntu-latest
-    timeout-minutes: 5
-    steps:
-      # SECURITY: the write-capable job executes policy from the trusted default
-      # branch and reads pull-request filenames only as API data.
-      - name: Check out trusted review policy
-        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
-        with:
-          ref: ${{ github.event.repository.default_branch }}
-          persist-credentials: false
-      - name: Request reviewers
-        env:
-          GITHUB_TOKEN: ${{ github.token }}
-        run: node .github/review-ownership/request-review.mjs

+ 1 - 3
.github/workflows/weighted-approval-review-event.yml

@@ -14,6 +14,4 @@ jobs:
     timeout-minutes: 2
     steps:
       - name: Record review event
-        run: |
-          echo 'This is by automated Angry Turtle Cyborg, not a human'
-          echo 'Recorded a weighted approval review event.'
+        run: echo 'Recorded a weighted approval review event.'

+ 2 - 2
apps/web/tests/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/web/tests/README.md
-README.md: e5b97f0b7527da01ce7c926360145fee49e168f0
-README.zh.md: f360c2b487bbf5b1a1c81492e5b4c2d4eaa8d749
+README.md: a3503e66d780e62562348b02830517381d21df92
+README.zh.md: a9039bbd0444a0786cd419c1e6aba0c3504e26d3

+ 4 - 0
apps/web/tests/README.md

@@ -8,6 +8,10 @@ the deliberate composition divergences from `dsh web` — are documented in
 [`scaffold.ts`](scaffold.ts) and the
 [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
 
+## Completion observations
+
+State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
+
 ## These are Host-face tests
 
 They type-check in the root `tsconfig.host.json`, not in the Client aggregate,

+ 4 - 0
apps/web/tests/README.zh.md

@@ -7,6 +7,10 @@
 [`scaffold.ts`](scaffold.ts) 和
 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)中。
 
+## 完成状态观察
+
+依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
+
 ## 这些是 Host 面的测试
 
 它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取

+ 1 - 1
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -348,7 +348,7 @@ describe('web e2e: clickable links gallery', () => {
     const mentions = markdown.locator('code button')
     expect(await mentions.count()).toBe(1)
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
-    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
     expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5)
     expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0)
 

+ 72 - 0
apps/web/tests/composer-placeholder.e2e.ts

@@ -0,0 +1,72 @@
+// The built shared Web/Electron composer hides guidance as soon as a draft contains whitespace.
+import { fileURLToPath } from 'node:url'
+import { chromium, type Page } from 'playwright'
+import { expect, it } from 'vitest'
+import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+it('hides the placeholder for typed and pasted spaces and restores it after deletion', async () => {
+  const scaffold = await launchWebScaffold({})
+  try {
+    const browser = await chromium.launch()
+    let failurePage: Page | undefined
+    try {
+      const page = await newEnglishPage(browser)
+      failurePage = page
+      const tripwire = watchConsole(page)
+      await page.goto(scaffold.authenticatedUrl)
+      await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-placeholder')
+      const input = page.locator('[data-composer-input][contenteditable="true"]').first()
+      const placeholder = page.locator('[data-composer-placeholder]').first()
+      const observations: string[] = []
+      const observe = async (label: string, visible: boolean) => {
+        await expect.poll(() => placeholder.isVisible()).toBe(visible)
+        observations.push(`- ${label}: placeholder ${visible ? 'visible' : 'hidden'}`)
+      }
+      const clear = async () => {
+        await input.click()
+        await page.keyboard.press('ControlOrMeta+KeyA')
+        await page.keyboard.press('Backspace')
+      }
+      await observe('Empty draft', true)
+      await input.click()
+      await page.keyboard.press('Space')
+      await observe('Single space', false)
+      await page.keyboard.press('Space')
+      await page.keyboard.press('Space')
+      await observe('Consecutive spaces', false)
+      await page.keyboard.press('Tab')
+      await input.click()
+      await observe('Focus restored', false)
+      const draftMarkup = await input.innerHTML()
+      await page.keyboard.press('Enter')
+      expect(await input.innerHTML()).toBe(draftMarkup)
+      await observe('Whitespace submission rejected', false)
+      await clear()
+      await observe('All content deleted', true)
+      await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+      await page.evaluate(() => navigator.clipboard.writeText('   '))
+      await page.keyboard.press('ControlOrMeta+KeyV')
+      await expect.poll(() => input.textContent()).toBe('   ')
+      await observe('Pasted spaces', false)
+      await clear()
+      await observe('Pasted content deleted', true)
+      await assertFixtureInventory(
+        fileURLToPath(new URL('./expected/composer-placeholder', import.meta.url)), ['visibility.expected.md'],
+      )
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+      await compareOrRefreshGolden(
+        fileURLToPath(new URL('./expected/composer-placeholder/visibility.expected.md', import.meta.url)),
+        observations.join('\n'), webSnapshotMode(),
+      )
+    } catch (error) {
+      if (failurePage !== undefined) await saveFailureShot(failurePage, 'web-e2e-composer-placeholder')
+      throw error
+    } finally {
+      await browser.close()
+    }
+  } finally {
+    await scaffold.close()
+  }
+})

+ 6 - 1
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -233,6 +233,10 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     const close = async (): Promise<void> => {
       await column.locator('[data-sidebar-right-toggle]').click()
       await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      // Closing publishes state before the frame's grid transition finishes.
+      await appFrame(page).evaluate(async (frame) => {
+        await Promise.allSettled(frame.getAnimations().map(animation => animation.finished))
+      })
       await expect.poll(() => detailsTrack(page)).toBe(0)
       await panel.waitFor({ state: 'hidden' })
     }
@@ -279,7 +283,8 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await workspaceDirectory.waitFor({ timeout: 15_000 })
     await workspaceDirectory.click()
     await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
-    await expect.poll(() => column.locator('[data-files-row="loading"]').count()).toBe(0)
+    // The child listing crosses the same Remote as the root listing above.
+    await column.locator('[data-files-row="loading"]').waitFor({ state: 'hidden', timeout: 15_000 })
     expect(await column.locator('[data-files-row="failed"]').count()).toBe(0)
     const retainedB = await paneSnapshot(page)
     expect(retainedB.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files']])

+ 1 - 1
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -169,7 +169,7 @@
 - list:
   - listitem:
     - paragraph: Footnote references stay inert superscripts. ↩
-- text: Produced
+- text: Files changed
 - button "Open site/report.html": report.html
 - button "Open a/style.css": style.css
 - button "Open b/style.css": style.css

+ 8 - 0
apps/web/tests/expected/composer-placeholder/visibility.expected.md

@@ -0,0 +1,8 @@
+- Empty draft: placeholder visible
+- Single space: placeholder hidden
+- Consecutive spaces: placeholder hidden
+- Focus restored: placeholder hidden
+- Whitespace submission rejected: placeholder hidden
+- All content deleted: placeholder visible
+- Pasted spaces: placeholder hidden
+- Pasted content deleted: placeholder visible

+ 2 - 2
apps/web/tests/expected/settings-chrome/dialog.expected.md

@@ -41,8 +41,8 @@
   - button "减小字号":
     - img
   - text: px 对话显示 控制已完成轮次的过程内容
-  - button "Compact":
-    - text: Compact
+  - button "紧凑":
+    - text: 紧凑
     - img
   - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
   - button "排队发送":

+ 20 - 1
apps/web/tests/github-ready-review.e2e.ts

@@ -6,7 +6,7 @@ import type { AddressInfo } from 'node:net'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
 import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-webhook'
@@ -147,10 +147,29 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => {
         && event.data.source.deliveryId === 'ready' && event.data.source.ruleId === 'review-pr-when-ready') reviewSession = session.id
       if (event.type === 'turn/end' && session.id === reviewSession) completed.resolve(undefined)
     })
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    const createWorkspace = scaffold.ctx.workspaceRegistry.create.bind(scaffold.ctx.workspaceRegistry)
+    const create = vi.spyOn(scaffold.ctx.workspaceRegistry, 'create').mockImplementationOnce(async (...args) => {
+      entered.resolve(undefined)
+      await release.promise
+      return await createWorkspace(...args)
+    })
+    onTestFinished(() => {
+      off()
+      release.resolve(undefined)
+      create.mockRestore()
+    })
     try {
       expect((await send(webhookOrigin, 'ready', payload)).status).toBe(202)
+      await entered.promise
+      expect(scaffold.ctx.agents.list()).toHaveLength(before)
+      expect(adapter.requests).toHaveLength(0)
+      release.resolve(undefined)
       await completed.promise
     } finally {
+      release.resolve(undefined)
+      create.mockRestore()
       off()
     }
     expect(scaffold.ctx.agents.list()).toHaveLength(before + 1)

+ 20 - 16
apps/web/tests/message-feedback-layout.e2e.ts

@@ -231,28 +231,32 @@ describe('web e2e: the feedback note editor floats above the column', () => {
   }
 
   /**
-   * Resize to a viewport and read the row once its width stops moving. The
-   * frame eases its column tracks, so reading straight after a resize can
-   * report the previous viewport's relation.
+   * Resize and wait for fonts, frame transitions, and column-width publication
+   * before reading the row and the portaled editor.
    * @param width - viewport width to settle at.
    * @param editorOpen - whether the note editor is currently open; reads the popover relations when so.
    * @returns the row's (and popover's) readings at that width.
    */
   const settleAt = async (width: number, editorOpen: boolean): Promise<PopoverMetrics> => {
     await page.setViewportSize({ width, height: 900 })
-    let previous = -1
-    await expect.poll(async () => {
-      const current = await page.evaluate(() =>
-        document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1)
-      const settled = current === previous
-      previous = current
-      return settled
-    }, { timeout: 10_000 }).toBe(true)
-    // The popover is JS-positioned from the trigger rect and re-places on
-    // resize/scroll, so once the column width stops moving we nudge it to the
-    // final layout; otherwise the panel can sit at a transient position from
-    // mid-resize and the anchor reading would be off.
-    await page.evaluate(() => window.dispatchEvent(new Event('resize')))
+    await page.evaluate(async () => { await document.fonts.ready })
+    await page.waitForFunction(() => {
+      const frame = document.querySelector('[style*="grid-template-columns"]')
+      const root = frame?.querySelector<HTMLElement>('div[data-phase]')
+      if (frame === null) return false
+      const tracks = getComputedStyle(frame).gridTemplateColumns.split(' ').map(Number.parseFloat)
+      // This sweep keeps the default sidebar preference and the right column closed.
+      return tracks[0] === (window.innerWidth < 1024 ? 56 : 280) && tracks.at(-1) === 0
+        && frame.getAnimations().every(animation =>
+          animation.playState === 'finished' || animation.playState === 'idle')
+        && root !== undefined && root !== null
+        && root.style.getPropertyValue('--dsh-conversation-column-width') === `${String(root.offsetWidth)}px`
+    }, undefined, { timeout: 10_000 })
+    // The resize listener schedules a React update; its return is not a commit barrier.
+    await page.evaluate(async () => {
+      window.dispatchEvent(new Event('resize'))
+      await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() })))
+    })
     return measurePopover(page, width, editorOpen)
   }
 

+ 121 - 0
apps/web/tests/present-svg.e2e.ts

@@ -0,0 +1,121 @@
+/** A file request elicits explicit SVG delivery without naming the present tool. */
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type {} from '@deepseek-ai/dsh-tool-present/types'
+import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import {
+  assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden,
+  fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
+  webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
+
+const DIR = fileURLToPath(new URL('../../../snapshots/web/present-svg', import.meta.url))
+const FIXTURE = join(DIR, 'session.v3.jsonl')
+const MODE = webSnapshotMode()
+const PROMPT = '简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg'
+const FILE = 'von-neumann.svg'
+
+describe('web e2e: requested SVG is explicitly delivered', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let cwd: string
+  let replayRoot: string | undefined
+
+  beforeAll(async () => {
+    let replayOverride: string | undefined
+    if (MODE !== 'record') {
+      replayRoot = await mkdtemp(join(tmpdir(), 'dsh-present-svg-replay-'))
+      replayOverride = join(replayRoot, 'replay.override.json')
+      const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
+      // Recorded absolute paths must follow each isolated Session's working directory.
+      const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}'
+      await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1)))
+    }
+    scaffold = await launchWebScaffold({
+      compareReplaySession: true,
+      ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }),
+    })
+    browser = await chromium.launch()
+    page = await browser.newPage({
+      viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
+    })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]')
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+  })
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      try {
+        await scaffold?.close()
+      } finally {
+        if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
+      }
+    }
+  })
+
+  it('writes valid SVG and calls present before the final reply', async () => {
+    if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('[data-composer-input]').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    const sessionId = await settled
+    const session = scaffold.ctx.agents.get(sessionId)?.session
+    if (session?.header.cwd === undefined) throw new Error('SVG Session has no workspace')
+    cwd = session.header.cwd
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+
+    const svg = await readFile(join(cwd, FILE), 'utf8')
+    const document = await page.evaluate((source) => {
+      const parsed = new DOMParser().parseFromString(source, 'image/svg+xml')
+      return {
+        root: parsed.documentElement.localName,
+        namespace: parsed.documentElement.namespaceURI,
+        errors: parsed.querySelectorAll('parsererror').length,
+      }
+    }, svg)
+    expect(document).toEqual({ root: 'svg', namespace: 'http://www.w3.org/2000/svg', errors: 0 })
+
+    const events = session.snapshotEvents()
+    const declarations = events.filter(event => event.type === 'deliverables/presented')
+    const delivery = declarations.find(event => event.data.files.some(file => resolve(cwd, file.path) === join(cwd, FILE)))
+    expect(delivery, 'the file request must produce a successful present declaration').toBeDefined()
+    if (delivery === undefined) throw new Error('SVG was written but not delivered')
+    expect(events.some(event => (
+      event.type === 'tool/call' && event.data.name === 'present' && event.data.callId === delivery.data.callId
+    ) || (
+      event.type === 'tool/ptc-dispatch' && event.data.name === 'present'
+      && event.data.subCallId === delivery.data.callId && !event.data.isError
+    ))).toBe(true)
+    expect(events.some(event => event.type === 'assistant/message' && event.seq > delivery.seq
+      && event.data.message.content.some(block => block.type === 'text'))).toBe(true)
+
+    const card = page.locator('[data-presented-files-row]').getByRole('button').filter({ hasText: FILE })
+    await card.waitFor({ state: 'visible' })
+    expect(await card.count()).toBe(1)
+    expect(await page.getByText('产物', { exact: true }).count()).toBe(0)
+    if (await page.locator('[data-produced-files-row]').count() > 0) {
+      expect(await page.getByText('本轮文件改动', { exact: true }).count()).toBe(1)
+    }
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
+  it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => {
+    await assertFinalWorkspaceSnapshot(DIR, cwd)
+    // Delivery owns the transcript; navigation and composer chrome have separate scenarios.
+    const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
+  })
+})

+ 1 - 1
apps/web/tests/produced-file-mentions.e2e.ts

@@ -159,7 +159,7 @@ describe('web e2e: inline-code mentions of produced files', () => {
     expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
     // The turn still ends with its produced-files row (all three writes).
-    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
 
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 19 - 5
apps/web/tests/produced-files.e2e.ts

@@ -146,16 +146,30 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     await expect.poll(() => chips.count()).toBe(6)
     await expect.poll(() => row.getByText('+ 4 files', { exact: true }).isVisible()).toBe(true)
 
-    await page.setViewportSize({ width: 780, height: 900 })
-    await expect.poll(() => chips.count()).toBe(5)
+    await page.setViewportSize({ width: 750, height: 900 })
+    await page.evaluate(async () => { await document.fonts.ready })
+    await page.waitForFunction(() => {
+      const frame = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]')
+      if (frame === null) return false
+      const tracks = getComputedStyle(frame).gridTemplateColumns.split(' ').map(Number.parseFloat)
+      // The responsive sidebar's settled collapsed track is 56px.
+      return tracks[0] === 56 && tracks.at(-1) === 0
+        && frame.getAnimations().every(animation =>
+          animation.playState === 'finished' || animation.playState === 'idle')
+    }, undefined, { timeout: 10_000 })
+    await expect.poll(() => chips.count()).toBe(4)
+    const laneWidth = await row.evaluate(element => element.clientWidth)
+    // Keep font-metric differences away from the 479px and 583px container-query edges.
+    expect(laneWidth).toBeGreaterThan(503)
+    expect(laneWidth).toBeLessThan(559)
     expect(await chips.nth(0).innerText()).toBe('关于我.md')
     expect(await chips.nth(1).innerText()).toBe('index.html')
-    expect(await chips.nth(4).innerText()).toBe('app.ts')
-    await expect.poll(() => row.getByText('+ 5 files', { exact: true }).isVisible()).toBe(true)
+    expect(await chips.nth(3).innerText()).toBe('styles.css')
+    await expect.poll(() => row.getByText('+ 6 files', { exact: true }).isVisible()).toBe(true)
     // Chips open in the right Sidebar's text preview, and a directory is not
     // something that preview can show, so the row offers no folder action.
     expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
-    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
 
     const tops = await row.locator(':scope > *:visible').evaluateAll(elements =>
       elements.map(element => element.getBoundingClientRect().top))

+ 46 - 12
apps/web/tests/queue-image.e2e.ts

@@ -46,9 +46,12 @@ describe('web e2e: queued image submission', () => {
   let browser: Browser | undefined
   let page: Page
   let overrideDir: string | undefined
+  let cleanupRoutes: (() => Promise<void>) | undefined
 
   afterEach(async () => {
     const failures: unknown[] = []
+    await cleanupRoutes?.().catch((error: unknown) => failures.push(error))
+    cleanupRoutes = undefined
     await browser?.close().catch((error: unknown) => failures.push(error))
     browser = undefined
     const closing = scaffold
@@ -97,18 +100,49 @@ describe('web e2e: queued image submission', () => {
     await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
     await pasteImage(page, await readFile(PNG))
     await page.getByRole('img', { name: 'queued.png' }).waitFor({ timeout: 10_000 })
-    await input.fill(QUEUED_TEXT)
-    await input.press('Enter')
-
-    // Admission replaces the local preview; the durable row loads its own thumbnail.
-    await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 })
-    const dockThumb = page.locator('[data-queue-dock] li:not([data-submission-echo]) img[alt="Queued message image"]')
-    await dockThumb.waitFor({ timeout: 15_000 })
-    await expect.poll(() => dockThumb.getAttribute('src')).toMatch(/^blob:/)
-    await expect.poll(() => dockThumb.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
-    await page.getByText(QUEUED_TEXT, { exact: true }).waitFor()
-    const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE)
+    const releasePrompt = Promise.withResolvers<undefined>()
+    const releaseImage = Promise.withResolvers<undefined>()
+    let cleanupPromise: Promise<void> | undefined
+    const cleanup = (): Promise<void> => cleanupPromise ??= (async () => {
+      releasePrompt.resolve(undefined)
+      releaseImage.resolve(undefined)
+      await page.unrouteAll({ behavior: 'wait' })
+    })()
+    cleanupRoutes = cleanup
+    let imageRequested = false
+    await page.route('**/api/session/prompt', async (route) => {
+      await releasePrompt.promise
+      await route.continue()
+    })
+    await page.route('**/api/session/attachment', async (route) => {
+      imageRequested = true
+      await releaseImage.promise
+      await route.continue()
+    })
+    const dockThumb = page.locator('[data-queue-dock] img[alt="Queued message image"]')
+    try {
+      await input.fill(QUEUED_TEXT)
+      await input.press('Enter')
+      await dockThumb.waitFor({ timeout: 15_000 })
+      await expect.poll(() => dockThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/)
+      expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(1)
+      releasePrompt.resolve(undefined)
+      await expect.poll(() => imageRequested, { timeout: 15_000 }).toBe(true)
+      await page.getByText(QUEUED_TEXT, { exact: true }).waitFor()
+      await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 })
+      expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
+      expect(await dockThumb.count()).toBe(0)
+      releaseImage.resolve(undefined)
+      // Admission replaces the optimistic image; wait for the durable row's own thumbnail.
+      const durableThumb = page.locator('[data-queue-dock] li:not([data-submission-echo]) img[alt="Queued message image"]')
+      await durableThumb.waitFor({ timeout: 15_000 })
+      await expect.poll(() => durableThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/)
+      await expect.poll(() => durableThumb.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
+      const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE)
+    } finally {
+      await cleanup()
+    }
 
     // Stop parks the accepted queue; the next waking send delivers the image
     // message first (FIFO), then its own text as the following turn.

+ 7 - 7
apps/web/tests/settings-chrome.e2e.ts

@@ -458,9 +458,9 @@ describe('web e2e: settings modal and General preferences', () => {
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.waitFor({ timeout: 10_000 })
     await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 })
-    await dialog.getByRole('button', { name: 'Compact', exact: true }).click()
-    await page.getByRole('menuitem', { name: 'Normal', exact: true }).click()
-    await dialog.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '紧凑', exact: true }).click()
+    await page.getByRole('menuitem', { name: '标准', exact: true }).click()
+    await dialog.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
     await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
       .toMatch(/ui-chat:\n\s+transcriptView: normal/)
     await page.keyboard.press('Escape')
@@ -471,11 +471,11 @@ describe('web e2e: settings modal and General preferences', () => {
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const reloaded = page.getByRole('dialog', { name: '设置' })
-    await reloaded.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 })
+    await reloaded.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
 
-    await reloaded.getByRole('button', { name: 'Normal', exact: true }).click()
-    await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
-    await reloaded.getByRole('button', { name: 'Compact', exact: true }).waitFor({ timeout: 10_000 })
+    await reloaded.getByRole('button', { name: '标准', exact: true }).click()
+    await page.getByRole('menuitem', { name: '紧凑', exact: true }).click()
+    await reloaded.getByRole('button', { name: '紧凑', exact: true }).waitFor({ timeout: 10_000 })
     await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
       .toMatch(/ui-chat:\n\s+transcriptView: compact/)
     await page.keyboard.press('Escape')

+ 19 - 2
apps/web/tests/steering.e2e.ts

@@ -312,6 +312,8 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
 })
 
 describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
+  const releaseReplay = Promise.withResolvers<undefined>()
+  let disposeReplayBarrier: (() => void) | undefined
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
@@ -327,6 +329,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
       replayOverride: STEER_ALL_OVERRIDE,
       paceMs: REPLAY_PACE_MS,
     })
+    disposeReplayBarrier = scaffold.ctx.on('llm/stream', async function* (_options, next) {
+      await releaseReplay.promise
+      yield* next()
+    }, { prepend: true })
     scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
@@ -338,6 +344,8 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
   }, 120_000)
 
   afterAll(async () => {
+    releaseReplay.resolve(undefined)
+    disposeReplayBarrier?.()
     await browser?.close()
     await scaffold?.close()
   })
@@ -348,8 +356,8 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled(30_000)
 
-    // Call 0 streams a question-tool call; the fills must land inside the
-    // first replay window, before the question composer replaces the textarea.
+    // Hold the question-tool stream until both rows have been steered, so
+    // question-composer takeover cannot race queue publication or the shortcut.
     await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
     await input.fill(PROMPT)
     await input.press('Enter')
@@ -369,6 +377,14 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
     await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 })
     expect(await page.locator('[data-pending-steering]').count()).toBe(0)
 
+    // Submission echoes carry the same text before the Host queue publishes.
+    await expect.poll(
+      () => dock.getByRole('button', { name: 'Steer queued message', disabled: false }).count(),
+      { timeout: 10_000 },
+    ).toBe(2)
+    await page.getByRole('textbox', { name: 'Cmd/Ctrl+Enter steers all queued messages', exact: true })
+      .waitFor({ timeout: 10_000 })
+
     // Empty draft + Cmd+Enter: both queued rows steer in FIFO order, the dock
     // empties, and the pending steering renders at the conversation tail.
     await input.press('Meta+Enter')
@@ -376,6 +392,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
       () => page.locator('[data-pending-steering]').filter({ hasText: /BANANA|ORANGE/ }).count(),
       { timeout: 10_000 },
     ).toBe(2)
+    releaseReplay.resolve(undefined)
     expect(await page.locator('[data-queue-dock]').count()).toBe(0)
     // The reasoning row streams independently of the steering handoff. Wait
     // for the block to settle so the mid snapshot does not race its transient

+ 25 - 6
apps/web/tests/workspace-management.e2e.ts

@@ -210,6 +210,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+    // Session restoration focuses the composer; the Workspace list can arrive
+    // first. Do not let that focus cancel the next directory dialog's path draft.
+    const composer = page.locator('[data-composer-input][contenteditable="true"]')
+    await expect.poll(() => composer.evaluate(element => document.activeElement === element), { timeout: 10_000 }).toBe(true)
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 
@@ -589,21 +593,28 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
 
   it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
-    const sessionRow = await seededSessionRow()
+    const initialRow = await seededSessionRow()
     // Selecting the seed hides any blank stray left by Workspace deletion,
     // so archiving this last visible Ungrouped Session must remove the bucket.
-    await sessionRow.click()
+    await initialRow.click()
+    const { title } = await scaffold.ctx.sessionController.rename({
+      sessionId: SessionId(SEED_ID), title: `Archive target ${SEED_ID}`,
+    })
+    // A user-owned title binds the locator to this seed across restoration.
+    const sessionRow = page.getByRole('treeitem').filter({
+      has: page.getByText(title, { exact: true }),
+    })
+    await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(1)
     await expect.poll(() => sessionRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
     const ungroupedSection = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..').locator('..')
     await expect.poll(() => ungroupedSection.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(2)
-    const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
     // Row menu: hover reveals the actions button; Archive session commits
     // without a confirmation dialog (non-destructive: log + accounting stay).
-    await clickHoverAction(sessionRow, `Session actions for ${rowTitle}`)
+    await clickHoverAction(sessionRow, `Session actions for ${title}`)
     await page.getByRole('menuitem', { name: 'Archive session' }).click()
     // The row disappears on the archive-set echo; with no other visible
     // stray, the whole Ungrouped bucket withdraws.
-    await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+    await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(0)
     await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
     // Durable on the host: the registry-global set carries the id while the
     // session log itself stays in persistence untouched.
@@ -615,10 +626,18 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    // Initial Workspace reconnection can focus the composer after the tree renders.
+    // Finish that navigation before the next test opens a path editor.
+    await page.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 15_000 })
+    await expect.poll(
+      () => page.locator('[data-composer-input][contenteditable="true"]')
+        .evaluate(element => element === document.activeElement),
+      { timeout: 15_000 },
+    ).toBe(true)
     // The archived row must not resurface (the Ungrouped bucket itself may
     // reappear if selection restore lands on another stray — not this test's
     // concern).
-    expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0)
+    expect(await sessionRow.count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 

+ 2 - 0
apps/web/tsconfig.json

@@ -64,7 +64,9 @@
     "tests/conversation-column-overflow.e2e.ts",
     "tests/ptc-round.e2e.ts",
     "tests/present.e2e.ts",
+    "tests/present-svg.e2e.ts",
     "tests/composer-draft-scroll.e2e.ts",
+    "tests/composer-placeholder.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
     "tests/web-search-round.e2e.ts",
     "tests/file-upload-round.e2e.ts",

+ 2 - 2
benchmarks/agent-continuation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write benchmarks/agent-continuation/README.md
-README.md: 3d38f008c4ee95c794e4e7fbd3d874d1d668b1ce
-README.zh.md: 189dcae8eea8716dbcb24b1fe2b08f1113caf36d
+README.md: 489939499af8d98922df2cbbdd6be793bf6e3796
+README.zh.md: e99760cfd1aba0ca41e78243f333fe18446474fe

+ 1 - 1
benchmarks/agent-continuation/README.md

@@ -18,7 +18,7 @@ Measure long-history request processing, cold tool-heavy continuation, and repea
 
 From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`. Do not overlap timing runs with builds or other benchmarks.
 
-The test reports all five fresh-process samples and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([calibration](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md)), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically.
+The test reports all five fresh-process samples, CPU models, available parallelism, platform/architecture, and Node/V8 versions, and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([calibration](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md)), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically.
 
 <a id="measurements"></a>
 

+ 1 - 1
benchmarks/agent-continuation/README.zh.md

@@ -18,7 +18,7 @@
 
 在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`。不要让计时运行与构建或其他基准重叠。
 
-测试报告全部五个新进程样本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([校准依据](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md)),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。
+测试报告全部五个新进程样本、CPU 型号、可用并行度、平台/架构和 Node/V8 版本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([校准依据](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md)),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。
 
 <a id="measurements"></a>
 

+ 7 - 1
benchmarks/agent-continuation/agent-continuation.bench.ts

@@ -1,7 +1,7 @@
 /** Baseline budgets for long-history requests, tool continuation, and fork-child discovery. */
 
 import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
+import { availableParallelism, cpus, tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
@@ -166,6 +166,12 @@ describe('continuing tool-heavy Sessions with large histories', () => {
       const retainedHeapBudgetMb = EXPECTED_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM
       console.log(JSON.stringify({
         benchmark: 'agent-continuation/' + scenario, workload: WORKLOAD,
+        runtime: {
+          cpuModels: [...new Set(cpus().map(cpu => cpu.model))],
+          availableParallelism: availableParallelism(),
+          platform: process.platform, arch: process.arch,
+          node: process.version, v8: process.versions.v8,
+        },
         samples, totalMs: { min: Math.min(...totalMs), median: median(totalMs), max: Math.max(...totalMs) },
         budgetMs, ...(scenario === 'tool-continuation' ? { retainedHeapBudgetMb } : {}),
       }))

+ 2 - 2
docs/i18n/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/i18n/README.md
-README.md: 55ae07c18e09fde141ecf5344f715dfa25658325
-README.zh.md: 674edeb9da4bf0083c607216a3c992a98f61897a
+README.md: 2ff8fc62f21d58a4d31b8aadd80c7a0c14556e6d
+README.zh.md: 73da4445bc35e779a990d4cd4aef605cc9078a06

+ 1 - 1
docs/i18n/README.md

@@ -51,7 +51,7 @@ Generated English references and graphs participate in pairing when a reviewed C
 - `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.
 - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.
 - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.
-- [review-ownership/README.md](../../.github/review-ownership/README.md) and its [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) — repository-internal automation policy maintained in English only.
+- [review-ownership/README.md](../../.github/review-ownership/README.md) — repository-internal approval policy maintained in English only.
 - `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.
 
 **Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.

+ 1 - 1
docs/i18n/README.zh.md

@@ -53,7 +53,7 @@
 - `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。
 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。
 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。
-- [review-ownership/README.md](../../.github/review-ownership/README.md) 及其 [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md):仓库内部自动化政策,只以英文维护。
+- [review-ownership/README.md](../../.github/review-ownership/README.md):仓库内部审批策略,只以英文维护。
 - `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。
 
 **统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。

+ 2 - 2
docs/tool-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/tool-catalog.md
-tool-catalog.md: c2ea95ff460b65fbba789738b16b0c67a7c91948
-tool-catalog.zh.md: 41fc1eaa3c6a0acf17bde4b69c97413b64872930
+tool-catalog.md: 928f7537ab212edf6af1dec93077caed2bb003b7
+tool-catalog.zh.md: 5ac34f6188f2b0689e02b72595375f00b02d5f6f

+ 1 - 1
docs/tool-catalog.md

@@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_
 
 ### `present`
 
-Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.
+Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
 
 ```json
 {

+ 1 - 1
docs/tool-catalog.zh.md

@@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
 
 ### `present`
 
-声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。
+声明交付已有的工作区文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
 
 ```json
 {

+ 0 - 1
package.json

@@ -59,7 +59,6 @@
     "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
     "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs",
     "test:issue-management": "node .github/issue-management/policy.test.mjs",
-    "test:request-review": "node --test .github/review-ownership/request-review.test.mjs",
     "test:snapshot": "vitest run --config vitest.snapshot.config.ts",
     "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
     "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 7c9b38a1abc6289c9c0f7b8335d2c4a796e32840
-README.zh.md: 80198a89fea2586d350dfc8bbeaf8a122739122c
+README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8
+README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
packages/client/ui-chat/README.md


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
packages/client/ui-chat/README.zh.md


+ 2 - 2
packages/client/ui-chat/src/client/locale.ts

@@ -29,8 +29,8 @@ export const zh = {
   'chat.turnNavigation.turn': '第 {turn} 轮',
   'settings.transcript.title': '对话显示',
   'settings.transcript.description': '控制已完成轮次的过程内容',
-  'settings.transcript.normal': 'Normal',
-  'settings.transcript.compact': 'Compact',
+  'settings.transcript.normal': '标准',
+  'settings.transcript.compact': '紧凑',
   'fileOpen.title': '无法打开文件',
   'fileOpen.unknown': '无法打开此文件',
   'message.extraBlock': '附加内容块',

+ 10 - 3
packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx

@@ -8,7 +8,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx'
-import { en } from '../src/client/locale.ts'
+import { en, zh } from '../src/client/locale.ts'
 
 afterEach(cleanup)
 
@@ -31,7 +31,7 @@ function noPendingInteraction() {
 // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
-function mount(mode: 'normal' | 'compact' = 'compact') {
+function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | typeof zh = en) {
   const source = createSnapshotStore(mode)
   const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) })
   const props: TranscriptViewRowProps = {
@@ -42,7 +42,7 @@ function mount(mode: 'normal' | 'compact' = 'compact') {
     useResource,
     useTranscriptView: bindSnapshotSelector(source),
     setTranscriptView,
-    t: makeTranslate(en),
+    t: makeTranslate(dictionary),
   }
   render(<TranscriptViewRow {...props} />)
   return { setTranscriptView }
@@ -67,4 +67,11 @@ describe('TranscriptViewRow', () => {
     fireEvent.pointerDown(document.body)
     expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull()
   })
+
+  it('shows the conversation-display values in Chinese', () => {
+    mount('compact', zh)
+    fireEvent.click(screen.getByRole('button', { name: '紧凑' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '标准' }))
+    expect(screen.getByRole('button', { name: '标准' })).toBeDefined()
+  })
 })

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 6a953a0f099d5205da683d81a1390a05b5159209
-README.zh.md: 612a7ca72787a4199207ad5fd0ac6846024e1d6e
+README.md: 6f2297cfa87d2bbf47735a371e4a41a85bb3c851
+README.zh.md: 9ca52a5079c42186d7d991f21d0c5823a42d78cc

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -46,7 +46,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis
 
 The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
 
-The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
+The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
 
 Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -46,7 +46,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是
 
 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
 
-常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
+常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
 
 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
 

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -477,7 +477,7 @@ export const InputBar = memo(function InputBar({
               onKeyDown={workspaceTrigger ? onWorkspaceKeyDown : undefined}
               style={hint === null ? undefined : { '--dsh-composer-hint': JSON.stringify(hint) } as CSSProperties}
             />
-            {empty && !claimActive && (
+            {draft === '' && attachments.length === 0 && !claimActive && (
               <div aria-hidden className={css.placeholder} data-composer-placeholder>
                 {placeholderText}
               </div>

+ 43 - 0
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -270,6 +270,49 @@ function writeDraft(shell: SessionInputShell, text: string): void {
   act(() => { shell.setDraft(text) })
 }
 
+describe('composer placeholder visibility', () => {
+  it.each([' ', '   ', '\t', '\n'])('hides for whitespace %j and returns after deletion', async (draft) => {
+    const { view, shell, textarea, button, sink, props } = bench()
+    const placeholder = () => view.container.querySelector('[data-composer-placeholder]')
+    expect(placeholder()).not.toBeNull()
+    writeDraft(shell, draft)
+    expect(placeholder()).toBeNull()
+    expect(button.disabled).toBe(true)
+    fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13 })
+    await act(async () => {})
+    expect(sink).not.toHaveBeenCalled()
+    fireEvent.blur(textarea)
+    view.rerender(<InputBar {...props} />)
+    fireEvent.focus(textarea)
+    expect(placeholder()).toBeNull()
+    writeDraft(shell, '')
+    expect(placeholder()).not.toBeNull()
+  })
+
+  it('hides for pasted spaces and restores after clearing', async () => {
+    const { view, shell, textarea } = bench()
+    fireEvent.paste(textarea, {
+      clipboardData: { items: [], getData: () => '   ' },
+    })
+    await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('   ') })
+    expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
+    writeDraft(shell, '')
+    expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
+  })
+
+  it('keeps whitespace hidden through composition and rerender', () => {
+    const { view, shell, textarea, props } = bench()
+    fireEvent.compositionStart(textarea)
+    writeDraft(shell, ' ')
+    expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
+    view.rerender(<InputBar {...props} />)
+    fireEvent.compositionEnd(textarea, { data: ' ' })
+    expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
+    writeDraft(shell, '')
+    expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
+  })
+})
+
 describe('image draft rail', () => {
   it('collects clipboard files while preserving text from a mixed paste', async () => {
     const addFiles = vi.fn(() => null)

+ 2 - 2
packages/client/ui-deliverables/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: ae42abb5a69d29332bd7cfd6d2d2a1191381e79a
-README.zh.md: 664dfa3cd2e0d526b43464a80290604a8ecf958c
+README.md: 4886a5817e6118ef76d5e7b49ed7fe3ed319650f
+README.zh.md: 43c68d4563f9ec32bb15fdd3df9a2e21225ebc13

+ 1 - 1
packages/client/ui-deliverables/README.md

@@ -36,7 +36,7 @@ The `present` tool row shows running, delivered, failed, or interrupted status;
 
 ### The row
 
-The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
+The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. It uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
 
 ### Inline-code links
 

+ 1 - 1
packages/client/ui-deliverables/README.zh.md

@@ -36,7 +36,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 ### 该行
 
-该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
+“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
 
 ### 行内代码链接
 

+ 2 - 2
packages/client/ui-deliverables/src/client/locales.ts

@@ -18,7 +18,7 @@ export const zh = {
   'row.stopped': '已中断',
   'row.inspect': '查看调用',
   'presented.open': '在默认程序中打开 {name}',
-  'produced.label': '产物',
+  'produced.label': '本轮文件改动',
   'produced.moreOne': '+ 1 个文件',
   'produced.more': '+ {count} 个文件',
   'produced.open': '打开 {name}',
@@ -39,7 +39,7 @@ export const en: Record<DeliverablesKey, string> = {
   'row.stopped': 'Interrupted',
   'row.inspect': 'Inspect call',
   'presented.open': 'Open {name} in default app',
-  'produced.label': 'Produced',
+  'produced.label': 'Files changed',
   'produced.moreOne': '+ 1 file',
   'produced.more': '+ {count} files',
   'produced.open': 'Open {name}',

+ 3 - 3
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -426,7 +426,7 @@ describe('ProducedFiles row', () => {
     const openFile = vi.fn<(path: string) => void>()
 
     const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
-    expect(view.getByText('产物')).toBeTruthy()
+    expect(view.getByText('本轮文件改动')).toBeTruthy()
     const row = view.container.querySelector('[data-produced-files-row]')
     if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
     expect(within(row).getAllByRole('button')).toHaveLength(6)
@@ -595,7 +595,7 @@ describe('presented files', () => {
     expect(view.queryByRole('link')).toBeNull()
     fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' }))
     expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0)
-    expect(view.queryByText('Produced')).toBeNull()
+    expect(view.queryByText('Files changed')).toBeNull()
   })
 })
 
@@ -617,7 +617,7 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] },
   const owner = tailOwner(deliverablesOf(value), 5)
   const matched = selectDeliverables(owner)!
   const view = render(<Deliverables {...openProps()} matched={matched} openFile={owner.openFile} sessionId={SessionId('session')} t={makeTranslate(en)} />)
-  expect(view.getByText('Produced')).toBeTruthy()
+  expect(view.getByText('Files changed')).toBeTruthy()
   expect(view.queryByText('Deliverables')).toBeNull()
 })
 

+ 2 - 2
packages/client/ui-model-selection/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md
-README.md: b050f1fa7e680b7669e208d88851754aa72f09e2
-README.zh.md: 85b4d44a1beb45a5a11fa946d2f36d83f9201e29
+README.md: ae939ce678bcc50aa69c6c260f1782830eea103f
+README.zh.md: d9826f55a48f141f8baa65f230e24ea8d0254e30

+ 1 - 1
packages/client/ui-model-selection/README.md

@@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp
 
 ### Model and effort
 
-Models stay grouped by provider. The menu shows model and effort names only; catalog descriptions remain available to other consumers. The `/model` popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input.
+Models stay grouped by provider. The composer menu shows model and effort names only. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input.
 
 ### Unroutable sessions
 

+ 1 - 1
packages/client/ui-model-selection/README.zh.md

@@ -29,7 +29,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
 
 ### 模型与推理强度
 
-模型按提供方分组。菜单只显示模型与推理强度名称;目录中的说明仍可供其他消费方使用。`/model` 弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
+模型按提供方分组。composer 菜单只显示模型与推理强度名称。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
 
 ### 不可路由的会话
 

+ 16 - 1
packages/client/ui-model-selection/src/client/index.ts

@@ -47,15 +47,30 @@ function rowId(providerId: string, modelId: string): string {
   return `${providerId}/${modelId}`
 }
 
+const BUILTIN_DESCRIPTION_KEYS: Readonly<Record<string, ModelKey>> = {
+  'deepseek-official/deepseek-v4-flash': 'option.deepseekV4Flash.description',
+  'deepseek-official/deepseek-v4-pro': 'option.deepseekV4Pro.description',
+}
+
+function descriptionOf(
+  providerId: string,
+  model: ModelDirectoryState['groups'][number]['models'][number],
+  t: TranslateNS<'model'>,
+): string | undefined {
+  const key = BUILTIN_DESCRIPTION_KEYS[rowId(providerId, model.id)]
+  return key !== undefined && model.description === en[key] ? t(key) : model.description
+}
+
 /** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
 function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] {
   const rows: SelectOption[] = []
   for (const group of directory.groups) {
     for (const model of group.models) {
+      const description = descriptionOf(group.id, model, t)
       rows.push({
         id: rowId(group.id, model.id),
         label: model.name,
-        detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
+        detail: description !== undefined ? `${group.name} · ${description}` : group.name,
         ...(directory.current !== null
           && directory.current.provider === group.id
           && directory.current.model === model.id

+ 4 - 0
packages/client/ui-model-selection/src/client/locales.ts

@@ -13,6 +13,8 @@ export const zh = {
   'command.label': '模型',
   'command.description': '选择本会话使用的模型',
   'option.loadError': '目录加载失败:{message}',
+  'option.deepseekV4Flash.description': '快速、高效且经济;适合目标明确、常规或并行任务。',
+  'option.deepseekV4Pro.description': '更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。',
   'trigger.fallback': '选择模型',
   'trigger.loading': '正在加载模型…',
   'trigger.selectAria': '选择模型',
@@ -39,6 +41,8 @@ export const en = {
   'command.label': 'Model',
   'command.description': 'Select the model for this conversation',
   'option.loadError': 'Catalog failed to load: {message}',
+  'option.deepseekV4Flash.description': 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
+  'option.deepseekV4Pro.description': 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
   'trigger.fallback': 'Select model',
   'trigger.loading': 'Loading models…',
   'trigger.selectAria': 'Select model',

+ 35 - 8
packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts

@@ -30,6 +30,7 @@ const GROUPS = [{
     {
       id: 'deepseek-v4-flash',
       name: 'DeepSeek-V4-Flash',
+      description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
       reasoning: {
         efforts: [
           { id: 'off', name: 'Off' },
@@ -42,6 +43,7 @@ const GROUPS = [{
     {
       id: 'deepseek-v4-pro',
       name: 'DeepSeek-V4-Pro',
+      description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
       reasoning: {
         efforts: [
           { id: 'off', name: 'Off' },
@@ -52,10 +54,18 @@ const GROUPS = [{
       },
     },
   ],
+}, {
+  id: 'external',
+  name: 'External Provider',
+  models: [{
+    id: 'deepseek-v4-flash',
+    name: 'External Flash',
+    description: 'Provider-authored description.',
+  }],
 }]
 
 /** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */
-async function bench() {
+async function bench(locale: 'zh' | 'en' = 'zh') {
   const ctx = new Context()
   let defaultSelection: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
   let selected = defaultSelection
@@ -117,10 +127,9 @@ async function bench() {
     },
   })
   const localeRuntime = new LocaleRuntime(ctx)
-  // This spec asserts the shipped Chinese copy. There is no jsdom `window` in
-  // this lane, so browser-language detection never runs and the locale comes
-  // from FALLBACK_LOCALE (en): state the asserted locale explicitly.
-  localeRuntime.setLocale('zh')
+  // There is no jsdom `window` in this lane, so browser-language detection
+  // never runs. Each bench states the locale its assertions require.
+  localeRuntime.setLocale(locale)
   ctx.provide('locale', localeRuntime)
   const scopes = new Map<SessionId, Context>()
   const addressed = new Set<SessionId>()
@@ -184,15 +193,33 @@ describe('ui-model-selection dual entry', () => {
     expect(b.seat().locale).toBe('model')
   })
 
-  it('popup options mark the host current active with the provider group in the detail', async () => {
+  it('localizes built-in descriptions and preserves external provider descriptions', async () => {
     const b = await bench()
     b.mint('s1')
     const options = await b.popup().options(projection('s1'), new AbortController().signal)
-    expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro'])
-    expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
+    expect(options.map((o: SelectOption) => o.label)).toEqual([
+      'DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'External Flash',
+    ])
+    expect(options[0]).toMatchObject({
+      active: true,
+      detail: 'DeepSeek · 快速、高效且经济;适合目标明确、常规或并行任务。',
+    })
+    expect(options[1]?.detail)
+      .toBe('DeepSeek · 更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。')
+    expect(options[2]?.detail).toBe('External Provider · Provider-authored description.')
     expect(options[1]?.active).toBeUndefined()
   })
 
+  it('keeps built-in descriptions unchanged in English', async () => {
+    const b = await bench('en')
+    b.mint('s1')
+    const options = await b.popup().options(projection('s1'), new AbortController().signal)
+    expect(options[0]?.detail)
+      .toBe('DeepSeek · Fast, efficient, and economical; suited to focused, routine, or parallel tasks.')
+    expect(options[1]?.detail)
+      .toBe('DeepSeek · Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.')
+  })
+
   it('a seat selection is the current the popup marks active next — one shared state', async () => {
     const b = await bench()
     b.mint('s1')

+ 2 - 2
packages/client/ui-settings-models/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
-README.md: 8c852023f6c7c90a4aad67beef8c4a3081e5968e
-README.zh.md: 6b5fd016dbc17bced816a16192c80640f56b9ab2
+README.md: 283761d17f857c9142a59ee3b333081df84ecd9c
+README.zh.md: 70a5f035cb4ceb03f5d9d593d4ca69a702cbe9b1

+ 1 - 1
packages/client/ui-settings-models/README.md

@@ -39,7 +39,7 @@ The collapsed 自定义设置 fold carries the curated extras: `baseURL` for bot
 
 ### Adding and deleting providers
 
-The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
+The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
 
 ### First-run dialogs
 

+ 1 - 1
packages/client/ui-settings-models/README.zh.md

@@ -39,7 +39,7 @@ kind: "package-reference"
 
 ### 新增与删除提供方
 
-「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
+「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
 
 ### 首次运行弹窗
 

+ 21 - 6
packages/client/ui-settings-models/src/client/CustomProviderCard.tsx

@@ -47,6 +47,15 @@ const NS = 'llm-pi-ai'
  */
 const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
 
+function isHttpUrl(value: string): boolean {
+  try {
+    const protocol = new URL(value).protocol
+    return protocol === 'http:' || protocol === 'https:'
+  } catch {
+    return false
+  }
+}
+
 /** Props of {@link CustomProviderCard}. */
 export interface CustomProviderCardProps {
   /** Route ids already declared, so the card refuses to shadow one. */
@@ -98,6 +107,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
 
   const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
   const routeTaken = taken.includes(route)
+  const normalizedBaseURL = baseURL.trim()
+  const baseUrlInvalid = baseURL.length > 0 && !isHttpUrl(normalizedBaseURL)
   // Rows are checked by the same per-row validator the editor cards use, so a
   // bad row is named by its position here too. Capacities have route-level
   // fallbacks; what a route cannot default is at least one model.
@@ -108,7 +119,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
   // legitimately authenticate through the provider's own ambient discovery.
   const keyValue = keyDraft.trim()
   const ready = route.length > 0 && !routeInvalid && !routeTaken
-    && baseURL.length > 0 && models.length > 0 && modelFailure === undefined
+    && normalizedBaseURL.length > 0 && !baseUrlInvalid && models.length > 0 && modelFailure === undefined
     && keyFailure === undefined
   // The one blocked gate worth a line under the form. A satisfied card says
   // nothing at all rather than printing an empty paragraph.
@@ -120,9 +131,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
     // Same for the route id, and it must be tested rather than assumed: the
     // fallback arm below reads "no models yet", so an unmet route gate would
     // fall through to it and contradict the filled-in list right above.
-    || route.length === 0 || routeInvalid || routeTaken
+    || route.length === 0 || routeInvalid || routeTaken || baseUrlInvalid
     ? undefined
-    : baseURL.length === 0
+    : normalizedBaseURL.length === 0
       ? t('customNeedsBaseUrl')
       : modelFailure !== undefined
         ? `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
@@ -141,7 +152,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
         // chain, ADC) instead of resolving a reference nothing ever sets.
         ...storesKey ? { apiKeyEnv: keyRef } : {},
         api: protocol,
-        baseURL,
+        baseURL: normalizedBaseURL,
         models: models.map(model => ({ ...model })),
       }
       // `taken` is a snapshot too, so the id check alone cannot see a route
@@ -227,10 +238,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           value={baseURL}
           placeholder={t('customBaseUrlPlaceholder')}
           aria-label={t('baseUrl')}
+          aria-invalid={baseUrlInvalid}
           disabled={profileDisabled}
           onChange={(event) => { setBaseURL(event.target.value) }}
         />
       </div>
+      {baseUrlInvalid ? <p className={styles['error']}>{t('customBaseUrlInvalid')}</p> : null}
       <div className={styles['field']}>
         <span className={styles['fieldLabel']}>{t('customApi')}</span>
         <select
@@ -267,11 +280,13 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
         onChange={setModels}
         probe={{
           settingsNs: NS,
-          baseURL,
+          baseURL: normalizedBaseURL,
           api: protocol,
           ...keyValue.length === 0 ? {} : { apiKey: keyValue },
         }}
-        probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
+        probeBlocked={baseUrlInvalid
+          ? 'customBaseUrlInvalid'
+          : keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
         operations={operations}
         t={t}
         disabled={profileDisabled}

+ 2 - 0
packages/client/ui-settings-models/src/client/locales.ts

@@ -86,6 +86,7 @@ export const en = {
   customApi: 'API protocol',
   customApiUnset: 'Not selected',
   customNeedsBaseUrl: 'A custom provider needs a base URL.',
+  customBaseUrlInvalid: 'Enter a valid HTTP or HTTPS URL.',
   customNeedsModels: 'A custom provider needs at least one model.',
   customBaseUrlPlaceholder: 'https://gateway.example/v1',
   settingsPathUnresolvable: 'unresolvable settings path',
@@ -192,6 +193,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
   customApi: 'API 协议',
   customApiUnset: '未选择',
   customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
+  customBaseUrlInvalid: '请输入有效的 HTTP 或 HTTPS 地址。',
   customNeedsModels: '自定义提供方至少需要一个模型。',
   customBaseUrlPlaceholder: 'https://gateway.example/v1',
   settingsPathUnresolvable: '无法解析设置路径',

+ 67 - 0
packages/client/ui-settings-models/tests/provider-form.client.spec.tsx

@@ -1079,6 +1079,73 @@ describe('hand-declared providers', () => {
     expect(screen.getByText(en.customRouteTaken).className).toMatch(/error/)
   })
 
+  it.each(['not-a-url', 'localhost:11434', 'ftp://gateway.acme.example/v1'])(
+    'rejects the non-HTTP base URL %j before discovery or creation', (baseURL) => {
+      const { discover, mutate } = mountCard()
+      fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
+      fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+      fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
+      fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: baseURL } })
+
+      expect(screen.getByText(en.customBaseUrlInvalid)).toBeTruthy()
+      expect(screen.getByLabelText(en.baseUrl).getAttribute('aria-invalid')).toBe('true')
+      expect(buttonNamed(en.fetchModels).disabled).toBe(true)
+      expect(buttonNamed(en.fetchModels).title).toBe(en.customBaseUrlInvalid)
+      expect(buttonNamed(en.create).disabled).toBe(true)
+      expect(discover).not.toHaveBeenCalled()
+      expect(mutate).not.toHaveBeenCalled()
+    },
+  )
+
+  it.each([
+    'http://localhost:11434/v1',
+    'http://127.0.0.1:8080/v1',
+    'http://[::1]:8080/v1',
+    'https://gateway.acme.example:8443/v1',
+  ])('allows the HTTP base URL %j to be interrogated', (baseURL) => {
+    const { discover } = mountCard()
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: baseURL } })
+
+    expect(screen.queryByText(en.customBaseUrlInvalid)).toBeNull()
+    expect(buttonNamed(en.fetchModels).disabled).toBe(false)
+    expect(buttonNamed(en.create).disabled).toBe(false)
+    fireEvent.click(screen.getByText(en.fetchModels))
+    expect(firstProbe(discover)).toMatchObject({ baseURL })
+  })
+
+  it('normalizes surrounding whitespace before interrogating and storing a base URL', async () => {
+    const discover = vi.fn(() => Promise.resolve(ok([{ id: 'm' }])))
+    const { mutate, onClose } = mountCard({}, { discover })
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), {
+      target: { value: '  https://gateway.acme.example/v1  ' },
+    })
+
+    expect(screen.queryByText(en.customBaseUrlInvalid)).toBeNull()
+    fireEvent.click(screen.getByText(en.fetchModels))
+    await waitFor(() => { expect(discover).toHaveBeenCalledTimes(1) })
+    expect(firstProbe(discover)).toMatchObject({ baseURL: 'https://gateway.acme.example/v1' })
+
+    fireEvent.click(await screen.findByText(en.fetchAdopt))
+    await waitFor(() => { expect(buttonNamed(en.create).disabled).toBe(false) })
+    fireEvent.click(screen.getByText(en.create))
+    await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
+    expect(firstMutate(mutate).ops[0]?.value).toMatchObject({ baseURL: 'https://gateway.acme.example/v1' })
+  })
+
+  it('keeps a network failure distinct from base URL syntax', async () => {
+    const discover = vi.fn(() => Promise.resolve(fail('connection refused', 'gateway/internal')))
+    mountCard({}, { discover })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'http://localhost:11434/v1' } })
+    fireEvent.click(screen.getByText(en.fetchModels))
+
+    await screen.findByText('connection refused')
+    expect(screen.queryByText(en.customBaseUrlInvalid)).toBeNull()
+  })
+
   it('derives a reference the credential seam accepts for every id it admits', () => {
     // The two rules have to stay in step; this is the relation, checked
     // directly rather than through the DOM.

+ 95 - 0
packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts

@@ -0,0 +1,95 @@
+/** Host budget decisions use controlled clocks and ELU samples; worker execution and binding transport stay real. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { WorkerThreadCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker-thread'
+import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
+
+const meter = vi.hoisted(() => ({ sample: vi.fn() }))
+
+vi.mock('node:worker_threads', async (importOriginal) => {
+  const original = await importOriginal<typeof import('node:worker_threads')>()
+  return {
+    ...original,
+    Worker: class extends original.Worker {
+      constructor(...args: ConstructorParameters<typeof original.Worker>) {
+        super(...args)
+        this.performance.eventLoopUtilization = meter.sample
+      }
+    },
+  }
+})
+
+describe('worker budgets with controlled ELU samples and real binding transport', () => {
+  let ctx: Context
+  let controller: AbortController
+  let run: Promise<CodeRunResult> | undefined
+  let release: (() => void) | undefined
+
+  beforeEach(() => {
+    ctx = new Context()
+    controller = new AbortController()
+    run = undefined
+    release = undefined
+    meter.sample.mockReset().mockReturnValue({ active: 10, idle: 0, utilization: 1 })
+    // Worker bootstrap and scheduling contribute to ELU active time; only the
+    // measured input and host deadlines are controlled, not worker execution.
+    vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
+  })
+
+  afterEach(async () => {
+    const owned = { ctx, controller, run, release }
+    try {
+      owned.controller.abort('test cleanup')
+      owned.release?.()
+    } finally {
+      vi.useRealTimers()
+    }
+    try {
+      await owned.run
+    } finally {
+      await owned.ctx.fiber.dispose()
+    }
+  })
+
+  async function pendingBinding(): Promise<void> {
+    await ctx.plugin(WorkerThreadCodeRuntime, { computeMs: 1_000, maxWallMs: 30_000 })
+    let entered!: () => void
+    const ready = new Promise<void>((resolve) => { entered = resolve })
+    const binding = new Promise<string>((resolve) => { release = () => { resolve('slow-done') } })
+    run = ctx.codeRuntime.run({
+      program: 'return await tools.slow({})',
+      bindings: [{ global: 'tools', functions: { slow: () => { entered(); return binding } } }],
+      signal: controller.signal,
+    })
+    await Promise.race([
+      ready,
+      run.then((result) => { throw new Error('Worker settled before binding entry: ' + JSON.stringify(result)) }),
+    ])
+  }
+
+  it('does not charge a binding wait longer than the compute budget', async () => {
+    await pendingBinding()
+    const settled = vi.fn()
+    void run!.then(settled, settled)
+    meter.sample.mockReturnValue({ active: 10, idle: 1_500, utilization: 10 / 1_510 })
+    await vi.advanceTimersByTimeAsync(1_500)
+    expect(meter.sample).toHaveBeenCalled()
+    expect(settled).not.toHaveBeenCalled()
+    release!()
+    expect(await run).toEqual({ logs: [], value: 'slow-done' })
+  })
+
+  it('expires active time even while a binding is pending', async () => {
+    await pendingBinding()
+    meter.sample.mockReturnValue({ active: 1_001, idle: 1_500, utilization: 1_001 / 2_501 })
+    await vi.advanceTimersByTimeAsync(25)
+    expect(await run).toEqual({ logs: [], error: { kind: 'timeout', message: 'compute budget exhausted (1000ms busy)' } })
+  })
+
+  it('expires the wall ceiling while active time remains below the compute budget', async () => {
+    await pendingBinding()
+    meter.sample.mockReturnValue({ active: 10, idle: 30_000, utilization: 10 / 30_010 })
+    await vi.advanceTimersByTimeAsync(30_000)
+    expect(await run).toEqual({ logs: [], error: { kind: 'timeout', message: 'wall-clock ceiling reached (30000ms)' } })
+  })
+})

+ 2 - 2
packages/experimental/webworker-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md
-README.md: b9454e308e92ba78e3d11aa72dfd3723e8c07568
-README.zh.md: 4d39f145d2ce2c826906ea1e60a26488eab9467e
+README.md: 2327851be62bc4e09e8678cd8d0c7b07663d1bdd
+README.zh.md: afd1bef17c8e921b4a6e97bd224deaa0f94e9486

+ 2 - 0
packages/experimental/webworker-runtime/README.md

@@ -32,6 +32,8 @@ Three artifacts from one tsdown pipeline:
 
 Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The fixture generator owns current-generation logs and the projection cache; committed predecessor logs remain byte-identical alongside them. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
 
+The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts only Node’s unknown-extension error for the expected stylesheet; other failures and unexpectedly successful exempt imports remain errors. See the [CI observation decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
+
 -----
 
 <a id="model-experience"></a>

+ 2 - 0
packages/experimental/webworker-runtime/README.zh.md

@@ -32,6 +32,8 @@ kind: "package-library"
 
 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。fixture 生成器负责当前代日志与投影缓存;已提交的前代日志逐字节保持不变,与它们并存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
 
+[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外仅接受 Node 针对预期样式表报告的未知扩展名错误;其他失败以及意外成功的豁免导入仍然报错。参见 [CI 观察决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
+
 -----
 
 <a id="model-experience"></a>

+ 7 - 2
packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts

@@ -23,6 +23,8 @@ import { join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 
 const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
+const DOCKKIT_BUNDLE = 'packages/client/ui-dockkit/lib/index.js'
+const DOCKKIT_CSS = join(repositoryRoot, 'packages/client/ui-dockkit/lib/components/dockkit.module.css')
 
 /**
  * Files Node's ESM loader cannot import in this repository. None is a finding:
@@ -34,7 +36,7 @@ const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)
  * import re-registers koffi's type names and fails as the second load.
  */
 const BASELINE_EXEMPT: ReadonlyMap<string, string> = new Map([
-  ['packages/client/ui-dockkit/lib/index.js', 'imports .css, which bare Node cannot load'],
+  [DOCKKIT_BUNDLE, 'imports .css, which bare Node cannot load'],
   ['packages/client/ui-primitives/lib/index.js', 'imports .css, which bare Node cannot load'],
   ['packages/client/web/lib/index.js', 'imports .css, which bare Node cannot load'],
   ['packages/subprocess/win32-process/lib/index.js', 'koffi type-name collision on a second load'],
@@ -109,7 +111,10 @@ if (files.length === 0) {
     try {
       await import(pathToFileURL(file).href)
     } catch (reason) {
-      if (exemption === undefined) {
+      const expectedDockkitCss = reason instanceof Error
+        && 'code' in reason && reason.code === 'ERR_UNKNOWN_FILE_EXTENSION'
+        && reason.message === `Unknown file extension ".css" for ${DOCKKIT_CSS}`
+      if (exemption === undefined || (key === DOCKKIT_BUNDLE && !expectedDockkitCss)) {
         // A bundle that stopped being importable is a real finding, so it
         // fails rather than joining a tolerated total.
         fail(`- UNEXPECTED BASELINE FAILURE ${key}: ${(reason as Error).message.split('\n')[0]}`)

+ 50 - 2
packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts

@@ -13,8 +13,8 @@
  * The corpus is the build output, so this skips on a tree that has none.
  */
 import { spawnSync } from 'node:child_process'
-import { fileURLToPath } from 'node:url'
-import { expect, test } from 'vitest'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { expect, test, TestRunner } from 'vitest'
 
 const runner = fileURLToPath(new URL('./transform-corpus-check.ts', import.meta.url))
 
@@ -30,3 +30,51 @@ test('every built bundle imports under Node', (context) => {
   expect(output.split('\n').filter(line => line.startsWith('- ')).join('\n')).toBe('')
   expect(finished.status, output).toBe(0)
 }, 900_000)
+
+// The hook replaces only the chosen bundle; shared build artifacts stay intact.
+test.each([
+  ['expected-css', 0, 'baselineExempt=1 unexpectedBaselineFailure=0'],
+  ['error', 1, '- UNEXPECTED BASELINE FAILURE'],
+  ['other-css', 1, '- UNEXPECTED BASELINE FAILURE'],
+  ['other-code', 1, '- UNEXPECTED BASELINE FAILURE'],
+  ['clean', 1, '- STALE EXEMPTION'],
+] as const)('classifies dockkit import: %s', (mode, status, finding) => {
+  const root = new URL('../../../../../', import.meta.url)
+  const bundle = 'packages/client/ui-dockkit/lib/index.js'
+  const css = fileURLToPath(new URL('packages/client/ui-dockkit/lib/components/dockkit.module.css', root))
+  const message = mode === 'error'
+    ? 'dockkit-negative-control'
+    : `Unknown file extension ".css" for ${mode === 'other-css' ? `${css}.other.css` : css}`
+  const source = mode === 'clean'
+    ? 'export {}'
+    : `throw Object.assign(new Error(${JSON.stringify(message)}), { code: ${JSON.stringify(mode === 'other-code' ? 'ERR_OTHER' : 'ERR_UNKNOWN_FILE_EXTENSION')} })`
+  const script = `
+    import { registerHooks } from 'node:module'
+    const target = ${JSON.stringify(new URL(bundle, root).href)}
+    registerHooks({
+      resolve(specifier, context, nextResolve) {
+        if (specifier === target) return { url: target, shortCircuit: true }
+        return nextResolve(specifier, context)
+      },
+      load(url, context, nextLoad) {
+        if (url === target) {
+          return { format: 'module', shortCircuit: true, source: ${JSON.stringify(source)} }
+        }
+        return nextLoad(url, context)
+      },
+    })
+    process.argv = [process.execPath, 'corpus-classification', ${JSON.stringify(bundle)}]
+    await import(${JSON.stringify(pathToFileURL(runner).href)})
+  `
+  const finished = spawnSync(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '-e', script], {
+    cwd: fileURLToPath(root), encoding: 'utf8', timeout: TestRunner.getCurrentTest()!.timeout,
+  })
+  const output = `${finished.stdout}${finished.stderr}`
+  expect(finished.error).toBeUndefined()
+  expect(finished.signal).toBeNull()
+  expect(finished.status, output).toBe(status)
+  expect(output).toContain(finding)
+  if (mode === 'error' || mode === 'other-css' || mode === 'other-code') {
+    expect(output).toContain(`- UNEXPECTED BASELINE FAILURE ${bundle}: ${message}\n`)
+  }
+})

+ 2 - 2
packages/fs/tool-present/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/tool-present/README.md
-README.md: 6767bb3b8d8839ae776fdf441ef853192c5117a2
-README.zh.md: 8255e7be42de273d58d01ca9b4e108d4c65a585d
+README.md: 77c1a83a30bdbe58b3be35d8bc0d879f1669b5a5
+README.zh.md: cec2d64b4e6f95a4e8210e7e24a9c796ed95d749

+ 1 - 1
packages/fs/tool-present/README.md

@@ -73,7 +73,7 @@ The pure `./types` entry declares `PresentedFile` and the Session event without
 
 #### What the model sees
 
-The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
+The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
 
 #### Token effect
 

Някои файлове не бяха показани, защото твърде много файлове са промени