diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index 7254478110..4377e34e19 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: 09baf5876029295f7a80b6a0fe6a6395d98f406c -2026-07-30-client-locale-full-rollout.zh.md: a2f9b619be9928a713d5dbf527d7c2278e84bd1f +2026-07-30-client-locale-full-rollout.md: 0faf4e0424e037b59b24d32f7fa987ac36497691 +2026-07-30-client-locale-full-rollout.zh.md: 5c26c2d5e7b75b89675b0b0d9ca3f147d2152bc8 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index 09baf58760..0faf4e0424 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -25,7 +25,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. -**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the initial locale follows `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). +**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (an `en-US` browser) and the built-boot snapshot pins the same navigator language—goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the provisional locale follows `navigator` before an explicit Host preference arrives ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index a2f9b619be..5c26c2d5e7 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -25,7 +25,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。 -**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage` 打开(boot 前钉 `dsh.locale=en`),built-boot 快照同样钉 en——预期输出不受本地化迁移影响;settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为初始 locale 跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。 +**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage`(`en-US` 浏览器)打开,built-boot 快照 同样固定 navigator 语言:golden 因而不受语言迁移影响。settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为在显式 Host 偏好到达前,暂定 locale 会跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。 [settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中「apply 层订阅 `locale/change` 重注册刷新 label」的机制已被本决定取代(thunk + revision 生命周期)。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml new file mode 100644 index 0000000000..00884185ca --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.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-08-06-host-backed-web-preferences.md +2026-08-06-host-backed-web-preferences.md: d56a8d2e330b214a1922997e3cc7165fd0fb31e4 +2026-08-06-host-backed-web-preferences.zh.md: 593646fe0845c20fb09cb7d115e6fa558226506e diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md new file mode 100644 index 0000000000..d56a8d2e33 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -0,0 +1,43 @@ +# Agent Note: Persist Web user preferences through Host settings + +Status: implemented + +English | [中文](2026-08-06-host-backed-web-preferences.zh.md) + +## Problem + +The Web Appearance, Language, and busy-Enter preferences lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different partition and lost choices even though both processes used the same DSH home. These are user-level product preferences; session selection, drafts, disclosure state, and other transient browser state remain page-local. + +The first theme implementation moved only Appearance to Host settings but awaited its initial RPC before providing `ThemeService`. A slow or unavailable settings request therefore suspended the assembled page. It also subscribed after the read, could miss an invalidation in that window, did not carry namespace revisions on writes, and allowed queued writes from a disposed plugin to reach the Host. + +## Decision + +The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary. + +The client runtime provides one `bindSettingsScope` lifecycle per namespace — the browser mirror of the Host-side settings owner seam. It installs `settings/changed` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through dsh-client-schema-form, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. + +User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. + +Remote browsers cannot call the loopback-only configuration API, so their preferences remain process-local. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference. + +## Alternatives considered + +**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host relay would recreate the settings service around a browser-specific format. + +**Mirror Host settings into `localStorage`.** A second authority requires boot and invalidation conflict rules while retaining the partition that caused the defect. The Host document is the sole durable source. + +**Await the initial read to avoid a provisional render.** Configuration availability is not a prerequisite for drawing the page. A background read may cause one live convergence, but it keeps failure isolated and preserves the existing browser/system/default fallbacks. + +**Give every domain its own settings controller.** The concurrency, revision, failure, invalidation, and disposal rules are identical; copying them already produced lifecycle drift in the theme implementation. Domain-owned schemas keep product policy out of the shared runtime. + +**A per-field preference controller with paired sync/persist callbacks.** The first shared lifecycle synchronized one scalar field through a domain `sync` callback while the service wrote back through an injected `persist` callback. The mutual callbacks forced two-phase construction — a defaulted no-op writer later replaced via `bindPersistence` — every additional field of a namespace would have carried its own controller and whole-document read, and each domain re-declared a hand-written guard the registered wire schema already expresses. The namespace scope publishes a snapshot the service subscribes to and accepts writes directly, so the callback pair and the second construction phase do not exist. + +**Move every `localStorage` entry into settings.** Current session, drafts, panel disclosure, trajectory display state, and similar entries are browser-instance state rather than user configuration. Promoting them would synchronize transient navigation state across tabs and ports without a product contract. + +## Consequences + +Appearance, Language, and busy-Enter choices follow the DSH user home across reloads, ports, and loopback origins. Direct edits to `settings.yaml` converge through the existing invalidation stream, while legacy `dsh.theme`, `dsh.locale`, and `dsh.conversation.busyEnter` entries are neither read nor written. + +Boot may briefly show the domain default before the background read settles. A transient read failure keeps that default or the last good in-process value; reconnect retries. A write rejection can visibly restore the durable preference after the immediate local change. + +Focused unit coverage pins schema registration, listener-before-read ordering, nonblocking activation, schema-validated section acceptance, revisioned ordered writes, stale-response containment, failure recovery, disposal quiescence, and remote memory mode. The namespace-granular scope also carries multi-field sections, so later configuration surfaces can ride the same lifecycle instead of hand-rolling describe/mutate synchronization. The keyless Web settings scenario writes all three preferences through the UI, verifies the YAML document and empty legacy storage, reloads, and boots another Host on a distinct port against the same DSH home. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md new file mode 100644 index 0000000000..593646fe08 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 通过 Host settings 持久化 Web 用户偏好 + +Status: implemented + +[English](2026-08-06-host-backed-web-preferences.md) | 中文 + +## 问题 + +Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区并丢失选择,即使两个进程使用同一个 DSH home。这些是用户级产品偏好;会话选择、草稿、折叠展开状态和其他瞬态浏览器状态仍保留在页面内。 + +第一版主题实现只把 Appearance 移入 Host settings,但会在提供 `ThemeService` 之前等待初始 RPC。缓慢或不可用的 settings 请求因而会挂起组装后的页面。该实现还在读取后才建立订阅,可能错过此窗口内的失效通知;它写入时不携带 namespace revision,并且允许已释放插件所排队的写入到达 Host。 + +## 决策 + +各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。 + +客户端运行时为每个 namespace 提供一份 `bindSettingsScope` 生命周期——即 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/changed` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。 + +用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 + +远程浏览器无法调用仅限回环请求的配置 API,因此其偏好仅保留在进程内。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。 + +## 曾考虑的替代方案 + +**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 中继会围绕浏览器特有格式重新实现一套 settings 服务。 + +**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会要求另外定义启动与失效时的冲突规则,同时依然保留造成该缺陷的分区。Host settings 文档是唯一的持久化真源。 + +**等待初始读取,以避免暂定渲染。** 绘制页面不以配置可用为前置条件。后台读取可能引发一次实时收敛,但它会隔离失败,并保留既有的浏览器/系统/默认回落路径。 + +**让每个领域拥有自己的 settings 控制器。** 并发、revision、失败、失效与释放规则完全一致;此前的主题实现已因复制这些规则产生生命周期漂移。由领域持有 schema,可以避免把产品政策放入共享运行时。 + +**带成对 sync/persist 回调的逐字段偏好控制器。** 第一版共享生命周期经领域提供的 `sync` 回调同步单个标量字段,服务则经注入的 `persist` 回调写回。这对相互依赖的回调迫使构造分两阶段完成——写入器先默认为无操作,稍后经 `bindPersistence` 替换——namespace 每新增一个字段,本都得再携带一个自己的控制器和一次全文档读取,且每个领域都重新声明了一个已注册 wire schema 本已表达的手写校验器。namespace scope 发布一份供服务订阅的快照并直接接受写入,因此这对回调与第二个构造阶段都不存在。 + +**把每个 `localStorage` 条目都移入 settings。** 当前会话、草稿、面板展开状态、trajectory 显示状态和类似条目属于浏览器实例状态,而非用户配置。将它们提升为设置,会在没有产品契约的情况下,跨标签页和端口同步短暂导航状态。 + +## 后果 + +Appearance、Language 和繁忙态 Enter 选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin。直接编辑 `settings.yaml` 所产生的变更会通过现有失效流收敛,而旧的 `dsh.theme`、`dsh.locale` 和 `dsh.conversation.busyEnter` 条目既不会被读取,也不会被写入。 + +启动时可能会在后台读取结算前短暂显示领域默认值。短暂的读取失败会保留该默认值或上一个正确的进程内值;重连时会重试。写入被拒时,界面可能会在本地值立即变化后明显恢复为持久化偏好。 + +聚焦的单元测试覆盖 schema 注册、先监听后读取的顺序、非阻塞激活、经 schema 校验的分节接受、携带 revision 的有序写入、陈旧响应隔离、故障恢复、释放时完全停稳,以及远程端仅内存模式。以 namespace 为粒度的 scope 也承载多字段分节,因此后续的配置表面可以沿用同一份生命周期,而不必手搭 describe/mutate 同步。无密钥 Web settings 场景通过 UI 写入全部三项偏好,校验 YAML 文档并确认旧 `localStorage` 为空,重新加载,再使用同一个 DSH home 在不同端口上启动另一个 Host。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index d55bdac61c..a391387a29 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.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-07-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: b04095b81f499982c8680a2d3627d8e98a70d8ac -2026-07-30-web-queue-steer-action.zh.md: 1b46ec90a27fbc3c8f3660a7cbdccb78a21a2cfa +2026-07-30-web-queue-steer-action.md: 2718c5b3cc95f1ab02db80230ba158d9b5c3b4e6 +2026-07-30-web-queue-steer-action.zh.md: 377117e2aa9c20b1c39d1fb7f450dbe729b00580 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index b04095b81f..2718c5b3cc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -20,7 +20,7 @@ Activating the action requests strict current-turn steering for that exact `Inbo The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error, after which the original waking occurrence proceeds through Queue. If the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface. -The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The browser persists the preference, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. +The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The Host settings document persists the preference across Web origins sharing one DSH home, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. ### Agent and lifecycle boundary @@ -38,7 +38,7 @@ The Host's existing `queuedMirror` remains the sole transient inbox authority. I When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending the durable `user/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. On the accepted live event, the client Session retires the first matching current steering occurrence before publishing its snapshot; history replay does not consume a later occurrence that reused the same `MessageId`. ChatView therefore renders one authority at a time without scanning durable history, and the durable projection restores the clock, Copy, and Fork against its logged event time and sequence. An append failure still retires the claimed row. -The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence. +The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser submission policy owns the live busy-Enter preference while the Host settings service owns durability; the policy resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions, and the Settings row and InputBar share it without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence. ### Verification diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index 1b46ec90a2..377117e2aa 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -20,7 +20,7 @@ Web composer 原本会在 agent(智能体)运行期间把所有 Enter 提交 running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒单次入队项会经 Queue 继续执行。如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。 -Composer 对新输入采用另一套尽力而为约定。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。浏览器会持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 +Composer 对新输入采用另一套尽力而为约定。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。Host settings 文档会在共享同一 DSH home 的 Web origin 之间持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 ### Agent 与生命周期边界 @@ -38,7 +38,7 @@ Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/q AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久会话事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。 -现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的约定:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该约定。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并且只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。 +现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的约定:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该约定。浏览器提交策略拥有实时繁忙态 Enter 偏好,而 Host settings 服务拥有持久性;该策略只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势,Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。 ### 验证 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index 89af3af66d..d346875c02 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.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-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 0c49a6bbfec0ab33a5aa3ce53dde0cac747f3816 -2026-07-31-browser-derived-initial-locale.zh.md: ca8eeceae58e25836bb42f7c1cec0e61a232d921 +2026-07-31-browser-derived-initial-locale.md: 3fed32ad46f01ef3f88f3182a1cb21f40031ca1b +2026-07-31-browser-derived-initial-locale.zh.md: 6cd11797ebc516f78a36f6c8825c4d074a7dd445 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 0c49a6bbfe..3fed32ad46 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -10,15 +10,15 @@ The Settings Language row opened every first visit in Chinese: `LocaleService` r ## Decision -**The initial locale resolves through three ordered sources: the persisted preference, then the browser, then `FALLBACK_LOCALE`.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and is the only place the order is expressed; `restorePreference()` now returns `LocaleId | undefined` (an absent, unparseable, or unreachable store reads as *no preference*) so the next source can speak. +**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. **`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. -**An explicit choice is permanent.** `setLocale` persistence is untouched, and the persisted value is consulted first, so a user who picked a language keeps it even when travelling between browser profiles or system languages. Nothing writes the detected locale back to storage: detection is re-derived every boot and stays invisible to the "has the user chosen?" question. +**An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question. -**The browser e2e lane now pins the browser language, not just storage.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` keeps pinning `dsh.locale=en`, which still wins over any browser language. `settings-chrome.e2e.ts` gained a scenario opening a second `en-US` page with empty storage and asserting the settings surface comes up English — the assembled-app proof of this feature. +**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature. ## Alternatives considered @@ -33,4 +33,4 @@ The Settings Language row opened every first visit in Chinese: `LocaleService` r - A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. - `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen". - Tests that construct a `LocaleService` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all). -- Detection cost is one array walk per service construction, and no storage write, so boot behavior and the persisted-state surface are unchanged. +- Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index ca8eeceae5..6cd11797eb 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -10,15 +10,15 @@ Status: implemented ## Decision -**初始 locale 依次经三个来源解析:已持久化的偏好、浏览器、`FALLBACK_LOCALE`。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,是这一顺序的唯一表达处;`restorePreference()` 现在返回 `LocaleId | undefined`(存储项缺失、无法解析或不可访问,一律读作*没有偏好*),后一个来源才有开口的机会。 +**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 **判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 -**显式选择是永久的。** `setLocale` 的持久化未作改动,且持久化值最先被查询,因此选过语言的用户即便在不同浏览器配置文件或系统语言之间辗转也保留原选择。没有任何代码把探测到的 locale 写回存储:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 +**显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 -**浏览器 e2e 车道现在钉住浏览器语言,而不只是存储项。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 仍然钉 `dsh.locale=en`,它依旧压过任何浏览器语言。`settings-chrome.e2e.ts` 新增一个场景:另开一个存储项为空的 `en-US` 页面,断言设置界面以英文呈现——这是本功能在组装后应用中的证据。 +**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据。 ## Alternatives considered @@ -33,4 +33,4 @@ Status: implemented - 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 - `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当「用户尚未选择」。 - 在 jsdom 下构造 `LocaleService` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。 -- 探测的代价是每次服务构造遍历一次数组,且不写存储,因此启动行为与持久化状态面均无变化。 +- 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index a869a98b51..a993fdec64 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.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/process/2026-07-02-bilingual-docs-and-pairing-gate.md -2026-07-02-bilingual-docs-and-pairing-gate.md: 9e6611aa8391e1478603bedb7d2b96fdd9a8bad2 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 654d265f3e8b396e23a49fd53e522826a7adb2ff +2026-07-02-bilingual-docs-and-pairing-gate.md: d516c422d09a51cc47440d3ca73d914e96db2320 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 8d478d293b4d6a07e68da5036301816bdeb1bdfd diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 9e6611aa83..d516c422d0 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -15,7 +15,7 @@ This repo's documentation corpus is read by people and agents inside and outside - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch. - **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it. - **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent. +- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth. ## Verification @@ -32,7 +32,7 @@ The verification contract covers each boundary independently. `verify-translatio ## Industry precedent -Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service. +Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 654d265f3e..8d478d293b 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -15,7 +15,7 @@ Status: implemented - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。 - **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 - **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。 +- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。 ## 验证 @@ -32,7 +32,7 @@ Status: implemented ## 业界先例 -带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。 +带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index 7f4bc40463..e98385c805 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.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/process/2026-07-26-briefed-minimal-translation-updates.md -2026-07-26-briefed-minimal-translation-updates.md: afd990b7b63adfd0e66a4726975b678d044e7cad -2026-07-26-briefed-minimal-translation-updates.zh.md: a6559667f0f1140d8a26cd5ebc4bb64b7b95fe45 +2026-07-26-briefed-minimal-translation-updates.md: 1c032fa07167ec2407d0f46707f942ba0c830494 +2026-07-26-briefed-minimal-translation-updates.zh.md: 3e4b235ea6d65e5b8fec60d24208924537577951 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md index afd990b7b6..1c032fa071 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -10,10 +10,10 @@ The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) ## Decision -Pair updates run on a generated briefing instead of the guidance corpus; only new pairs still run the whole-document workflow, which is unchanged. +The extended manual workflow runs pair updates on a generated briefing instead of the guidance corpus; new pairs in that workflow still use the unchanged whole-document path. Routine agent work uses the direct path defined by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). - **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the incremental prompt-pipeline work; its provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. -- **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. +- **When explicitly invoked, the update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. - **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command. Before recording, `--write` stores each side's exact bytes with `git hash-object -w --stdin` and pins the blob under a content-addressed local `refs/dsh/translation-pairing/snapshots/` ref; an uncommitted last-confirmed snapshot is therefore available to the briefing generator's later `git cat-file`, not merely named by a hash that Git cannot resolve or left vulnerable to garbage collection. ## Benchmark @@ -38,7 +38,7 @@ A second head-to-head replay on the same ten examples compared this note's shipp ## Consequences -- A small prose edit's counterpart update now costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds; the cheap path and the correct path point the same way. +- In the explicitly invoked extended workflow, a small prose edit's counterpart update costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds. - The briefing generator is a second consumer of the consistency records: recorded blob hashes now also drive diff recovery and section mapping, strengthening the incentive to keep records honest. - Each distinct confirmed snapshot retains a content-addressed local ref and object. An abandoned re-record may therefore leave an extra durable pin, but it changes no branch or commit history; this local retention is the tradeoff that prevents garbage collection from invalidating an accepted pairing record. - `--write` without arguments no longer works; muscle-memory callers must name pairs or pass `--all`. That is the point — the bulk bless is now a visible, deliberate act. diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index a6559667f0..3e4b235ea6 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -10,10 +10,10 @@ Status: implemented ## 决策 -配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。 +扩展的手动工作流使用生成的简报(briefing)而非指导语料来更新配对;该工作流中的新配对仍采用保持不变的整篇文档路径。常规 agent 工作采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)定义的直接路径。 - **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了增量提示词流水线工作的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 -- **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 +- **显式调用时,[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 - **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。写下记录之前,`--write` 用 `git hash-object -w --stdin` 存入每一侧的精确字节,并在内容寻址的本地 `refs/dsh/translation-pairing/snapshots/` ref 下固定该 blob;未提交的上次确认快照因此能被简报生成器之后的 `git cat-file` 取回,而不只是留下一个 Git 无法解析的 hash 名称或暴露于垃圾回收。 ## 基准测试 @@ -38,7 +38,7 @@ Status: implemented ## 后果 -- 一次小的行文修改,其对侧更新如今只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变;低成本的路径与正确的路径指向同一个方向。 +- 在显式调用的扩展工作流中,一次小的行文修改,其对侧文件更新只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变。 - 简报生成器成为一致性记录的第二个消费方:记录的 blob hash 如今还驱动 diff 还原与章节映射,这进一步强化了如实维护记录的动机。 - 每个不同的已确认快照都会保留一个内容寻址的本地 ref 和对象。因此,中途放弃的重新记录可能留下额外的持久固定项,但它不会改变任何分支或提交历史;这种本地保留正是防止垃圾回收让已接受配对记录失效所付出的代价。 - 不带参数的 `--write` 不再可用;靠肌肉记忆的调用者必须点名配对或传 `--all`。这正是目的所在:批量背书如今是一个可见的、有意为之的动作。 diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml new file mode 100644 index 0000000000..9805a10d2c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.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/process/2026-08-08-lightweight-routine-documentation-translation.md +2026-08-08-lightweight-routine-documentation-translation.md: ff4d6005588b562018bf1ee40d6dabb5569766f0 +2026-08-08-lightweight-routine-documentation-translation.zh.md: fe809d328e739c2c869d5597cf6786198864bd56 diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md new file mode 100644 index 0000000000..ff4d600558 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md @@ -0,0 +1,30 @@ +# Agent Note: Lightweight routine documentation translation + +Status: implemented + +English | [中文](2026-08-08-lightweight-routine-documentation-translation.zh.md) + +## Problem + +Routine bilingual edits automatically selected the full [translation skill](../../../skills/dsh-translate-docs/SKILL.md). Even after the [briefed-update optimization](2026-07-26-briefed-minimal-translation-updates.md), a small documentation change could still load a specialized workflow, generate a briefing, delegate prose to a subagent, and perform a separate verification pass. That orchestration consumed more time, context, and model tokens than translating the changed text itself, and automatic skill discovery exposed the workflow on ordinary documentation turns. + +## Decision + +- **Routine translation is one shot and one pass.** The active agent loads [terminology.md](../../../../docs/i18n/terminology.md), translates only the changed content directly, moves a terminology annotation when the true first occurrence crosses the edit boundary, otherwise preserves reviewed counterpart prose outside the change, and re-records the pair. It does not invoke a translation skill, generate a briefing, start a separate translation-review pass, or delegate translation to a subagent. +- **The extended workflow is manual-only.** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) retains its briefing, delegated prose, whole-document, and scoped-verification paths. The [Claude Code skill contract](https://code.claude.com/docs/en/skills#control-who-invokes-a-skill) reads `disable-model-invocation: true` with `user-invocable: true` in `SKILL.md`; Codex reads `policy.allow_implicit_invocation: false` in `agents/openai.yaml`. The repository's `.claude/skills` symlink projects the same skill directory to Claude Code, so both products share one committed workflow while enforcing their own invocation metadata. The `doc-sync` skill-invocation-metadata gate keeps those independent policies aligned. +- **Automatic workflows do not chain into the manual skill.** Root and documentation instructions own the lightweight default. Documentation, website-sync, prose, and code-review skills link to those instructions or the i18n contracts instead of loading `dsh-translate-docs` from an inferred bilingual change. +- **The pairing and review contracts stay intact.** Both language files still update together, untouched counterpart wording remains stable, terminology stays binding, the consistency record is rewritten only after the active agent confirms the pair, and `doc-sync` retains the corpus-wide mechanical checks. Human review still owns semantic translation quality. + +## Alternatives considered + +- **Delete the extended skill and briefing tools** — rejected: explicit manual use remains valuable for whole-document translations, difficult reconciliation, and callers that deliberately choose the guarded workflow. +- **Replace the extended skill with an automatically invoked lightweight skill** — rejected: another automatic skill would still add discovery context and an invocation boundary around a task the active agent can complete directly from the terminology table and standing instructions. +- **Keep automatic invocation only for new pairs or large changes** — rejected: size-based inference is another hidden policy and can unexpectedly activate the expensive workflow. The user, not the agent, chooses when the extended path is worth its cost. +- **Drop the terminology load as well** — rejected: the glossary is the small, binding input that prevents repository-wide term drift; removing it would trade token savings for inconsistent product language. + +## Consequences + +- Ordinary development pays for the changed source text, its local counterpart context, and the terminology table rather than the extended workflow's briefing and subagent context. +- The active agent owns the final routine translation in the same turn. The lightweight path deliberately gives up the extended workflow's generated alignment, delegated isolation, and separate prose-verification pass. +- Explicit users can still invoke the full workflow through `/dsh-translate-docs` in Claude Code or `$dsh-translate-docs` in Codex. +- The Claude Code frontmatter and Codex policy file are separate product contracts; `doc-sync` rejects a skill that becomes manual-only on only one product or becomes unavailable to the Claude Code user as well as the model. diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md new file mode 100644 index 0000000000..fe809d328e --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 轻量化日常文档翻译 + +Status: implemented + +[English](2026-08-08-lightweight-routine-documentation-translation.md) | 中文 + +## 问题 + +日常双语编辑会自动选用完整的[翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)。即使经过[基于简报的更新优化](2026-07-26-briefed-minimal-translation-updates.md),一次小的文档改动仍可能加载专用工作流、生成简报、把行文翻译委派给 subagent,并另行执行一轮核验。这种编排耗费的时间、上下文和模型 token 比直接翻译改动文本本身还多,而且 skill 的自动发现机制还会在普通文档处理轮次中暴露该工作流。 + +## 决策 + +- **日常翻译一次完成,只处理一遍。** 当前 agent(智能体)加载 [terminology.md](../../../../docs/i18n/terminology.md),直接翻译发生改动的内容;如果术语的实际首现位置跨过了编辑边界,则移动相应括注,否则保留改动之外已经评审的对侧文件行文;最后重新记录配对。它不会调用翻译 skill、生成简报、启动单独的翻译评审轮次,也不会把翻译委派给 subagent。 +- **扩展工作流仅限手动调用。** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 保留简报、行文翻译委派、整篇文档和按范围核验路径。[Claude Code skill 契约](https://code.claude.com/docs/en/skills#control-who-invokes-a-skill)读取 `SKILL.md` 中的 `disable-model-invocation: true` 和 `user-invocable: true`;Codex 读取 `agents/openai.yaml` 中的 `policy.allow_implicit_invocation: false`。仓库的 `.claude/skills` 符号链接把同一个 skill 目录映射给 Claude Code,因此两个产品共享同一份提交到仓库的工作流,同时分别执行各自的调用元数据契约。`doc-sync` 中的 skill 调用元数据门禁会让这两份独立策略保持一致。 +- **自动工作流不会串联调用这项仅限手动调用的 skill。** 轻量默认行为由根级指令和文档指令定义。文档、网站同步、行文和代码评审 skill 会链接这些指令或 i18n 契约,而不会因为推断到双语改动就加载 `dsh-translate-docs`。 +- **配对契约与评审契约保持不变。** 两种语言文件仍会一并更新;未触及的对侧文件措辞保持稳定;术语约束仍然有效;只有当前 agent 确认配对后,才会重写一致性记录;`doc-sync`(文档同步门禁)继续执行全语料机械检查。语义层面的翻译质量仍由人工评审负责。 + +## 曾考虑的替代方案 + +- **删除扩展 skill 和简报工具**:不予采纳。在整篇文档翻译或棘手的两侧内容协调中,以及对有意选择受控工作流的调用方而言,显式手动调用仍有价值。 +- **用自动调用的轻量 skill 取代扩展 skill**:不予采纳。另一项自动 skill 仍会给这项任务增加发现上下文和调用边界,而当前 agent 仅依据术语表与常驻指令即可直接完成该任务。 +- **仅对新配对或大规模改动保留自动调用**:不予采纳。基于规模的推断同样是一项隐藏政策,可能出乎意料地启用高开销工作流。何时值得为扩展路径付出成本,应由用户而非 agent 决定。 +- **同时取消加载术语表**:不予采纳。术语表是体量小但有约束力的输入,可以防止整个仓库发生术语漂移;移除它等于用产品语言不一致换取 token 节省。 + +## 后果 + +- 普通开发的成本来自发生改动的源文本、其局部对侧文件上下文和术语表,不再来自扩展工作流的简报与 subagent 上下文。 +- 当前 agent 在同一轮次内对日常翻译的最终结果负责。轻量路径有意放弃扩展工作流提供的自动生成对齐信息、委派所提供的隔离,以及单独的行文核验轮次。 +- 用户仍可在 Claude Code 中通过 `/dsh-translate-docs`,或在 Codex 中通过 `$dsh-translate-docs` 显式调用完整工作流。 +- Claude Code frontmatter 与 Codex 策略文件是彼此独立的产品契约;如果某项 skill 仅在一个产品中变为手动调用,或者在 Claude Code 中对模型和用户都不可用,`doc-sync` 会拒绝该状态。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 4d710a9c55..cc081c72c4 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.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: 84912895514f9dba4ff61bc8842444a81c5fbefa -2026-07-24-web-gui-browser-e2e-lane.zh.md: 3c94b42d1b85190dfe6b3b8ebb14efb4624fd326 +2026-07-24-web-gui-browser-e2e-lane.md: e572929ae6762da6adc2e77e1dba19361beaf670 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 99f86f40ba006c4024f367b73ce52f8679b8d2fd diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 8491289551..e572929ae6 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side 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 from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `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. -Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; the scenarios asserting Chinese copy leave storage unset and open a `zh-CN` browser instead, because the client derives its initial locale from `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)), and `settings-chrome.e2e.ts` additionally covers both switch directions and the English-browser default. +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home. ### Expected outputs diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 3c94b42d1b..99f86f40ba 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -28,7 +28,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 -每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则不预设该存储项,改为开启 `zh-CN` 浏览器,因为客户端的初始 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md)),而 `settings-chrome.e2e.ts` 还额外覆盖双向切换与英文浏览器默认态。 +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。 ### 预期输出 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 572c36a8a1..e79f11cdc7 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -15,7 +15,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. - [docs/testing.md](../../../docs/testing.md) and the [quality-gates Agent Note](../../notes/implemented/process/2026-06-11-quality-gates.md): required test tiers and gates. - [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto. -- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md), [terminology.md](../../../docs/i18n/terminology.md), and [dsh-translate-docs](../dsh-translate-docs/SKILL.md). +- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation. ## Blocking requirements diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 5d118f2257..5d39470d72 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -12,7 +12,7 @@ Repository translations follow the sibling pairing contract: English `foo.md`, C ## Read the owning contracts - Read [docs/AGENTS.md](../../../docs/AGENTS.md) and use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) when deciding where content belongs or changing product documentation prose. -- Use [dsh-translate-docs](../dsh-translate-docs/SKILL.md) whenever an edited source has a bilingual counterpart. +- For an edited bilingual source, follow the lightweight routine path in [docs/AGENTS.md](../../../docs/AGENTS.md#writing-rules) and the [pairing contract](../../../docs/i18n/README.md); never invoke the extended translation skill automatically. - Read the current `DocsPage` type and entries in [website/docs.ts](../../../website/docs.ts) before changing the manifest; do not rely on a remembered field set. - Read [website/.vitepress/config.ts](../../../website/.vitepress/config.ts) before adding a new section, sidebar collection, locale, or top-level navigation item. diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index a42c27cfda..1ea836b8ee 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -53,4 +53,4 @@ Apply the ordered relocate-condense-raise policy in [docs/AGENTS.md](../../../do ## Validation and PR hygiene -Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow [dsh-translate-docs](../dsh-translate-docs/SKILL.md) and run `pnpm run verify-translation-pairing --write `. The PR body should give word deltas, explain any deliberately long exception, and list checks. +Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow the [lightweight routine path](../../../docs/AGENTS.md#writing-rules) and run `pnpm run verify-translation-pairing --write `. The PR body should give word deltas, explain any deliberately long exception, and list checks. diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index 6ad226baf4..f100f78613 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -23,7 +23,7 @@ Always exclude `vendor/` from discovery, review, and edits, even when the reques Also exclude `.agents/notes/archived/` from prose review and edits. Archived Agent Notes are frozen snapshots; inspect an exact target only to understand a historical inbound citation, never to modernize its prose or outbound links. -Treat generated catalogs, snapshots, and fixtures as derivative. Edit the owning source or scenario first, then regenerate the artifact. When a generator extracts a summary from owner prose, make the extracted sentence complete for that surface. Bilingual pairs have no permanent owner: either language may be the authored side for an update. Update the counterpart minimally and re-record the pair. +Treat generated catalogs, snapshots, and fixtures as derivative. Edit the owning source or scenario first, then regenerate the artifact. When a generator extracts a summary from owner prose, make the extracted sentence complete for that surface. Bilingual pairs have no permanent owner: either language may be the authored side for an update. Follow the [lightweight routine path](../../../docs/AGENTS.md#writing-rules), update the counterpart minimally, and re-record the pair. ## Preserve the complete proposition diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 586eb554c8..332c55920b 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -1,10 +1,16 @@ --- name: dsh-translate-docs -description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — tells the orchestrating agent when to delegate translation to a subagent, and orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result +description: Manually run the extended DeepSeek Harness bilingual-document workflow, including generated briefings, delegated prose translation, whole-document translation, and scoped pairing verification. +disable-model-invocation: true +user-invocable: true --- # Translating DeepSeek-Harness docs +## Invocation boundary + +Run this extended workflow only when the user explicitly invokes `dsh-translate-docs` by name. Never select or load it for ordinary documentation work, from another skill, or from an inferred translation need; routine translation follows the one-shot, one-pass rule in [docs/AGENTS.md](../../../docs/AGENTS.md). + ## What this skill is **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. @@ -43,6 +49,7 @@ When translations need to be written from scratch, the orchestrating agent does - **Pass 1 — write, don't transpose.** Read a semantic unit, then restate it as a native technical author in the nearest [style sample's](../../../docs/i18n/style-samples.md) register. Preserve the required frame without forcing sentence-by-sentence correspondence. - **Pass 2 — verify against the source, clause by clause.** Fidelity is checked here, not written in: confirm nothing was added or dropped, every term follows the table, and each code span survived verbatim. Fix by rewriting the sentence natively, not by patching words into it. +- **Read the completed counterpart alone.** After the source comparison, read the translated file without the source beside it and rewrite phrasing whose awkwardness only becomes visible in isolation. - Write only the final text to the file, never drafts or notes. - Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified. For a Chinese target, use the Chinese and first-occurrence columns; an unlisted term needs a citable Chinese OSS/vendor precedent or stays English under 「待定术语」. For an English target, use the English column and an established English technical term; preserve an ambiguous source term with a short gloss and list it as pending. Never invent a rendering inline. - Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`. diff --git a/.agents/skills/dsh-translate-docs/agents/openai.yaml b/.agents/skills/dsh-translate-docs/agents/openai.yaml new file mode 100644 index 0000000000..8f02948105 --- /dev/null +++ b/.agents/skills/dsh-translate-docs/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "DSH Extended Doc Translation" + short_description: "Run the full bilingual documentation workflow manually" + default_prompt: "Use $dsh-translate-docs to run the extended bilingual-document workflow for the specified pair." + +policy: + allow_implicit_invocation: false diff --git a/AGENTS.md b/AGENTS.md index a8a202147c..9daf45a832 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,7 @@ Everything compiles under `strict: true` with `noImplicitAny`; every remaining ` Comments and docs state complete contracts and context, not reasoning transcripts. Use direct, concrete terms. Do not use metaphors. Before writing `contract`, `boundary`, or `shape`, ask whether a more exact term names the subject: write `response fields`, `JSON validation`, or `ESM exports` instead of `response shape`, `validation boundary`, or `module shape`. Keep `contract` for preconditions, postconditions, invariants, compatibility promises, and other obligations that callers, callees, implementers, providers, producers, or consumers rely on. Keep a literal process, wire, security, transaction, or lifecycle boundary. Do not narrate control flow or tests, preserve review history, or restate code. Keep behavior, failure, timing, ownership, and safe-use facts; link the rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for decisions. Wire mechanically checkable invariants into an executed top-level gate and prove each changed acceptance path rejects an invalid case. Use narrow, justified exceptions instead of disabling a rule globally. -Docs accompany every code change: update affected README and JSDoc contracts together; update both sides of a bilingual pair and re-record it ([i18n contract](docs/i18n/README.md)). Current-state prose, one physical line per paragraph, one home per fact, and word budgets live in [docs/AGENTS.md](docs/AGENTS.md). +Docs accompany every code change: update affected README and JSDoc contracts together. Routine bilingual work follows [docs/AGENTS.md](docs/AGENTS.md); only explicit user invocation may run `dsh-translate-docs`. Current-state prose, one physical line per paragraph, one home per fact, and word budgets live there. ## Editing these instructions diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 631196c652..ac0ec80fcb 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -70,7 +70,12 @@ let unmount: (() => void) | undefined export function installAssembledBootEnv(): void { beforeEach(() => { localStorage.clear() - localStorage.setItem('dsh.locale', 'en') + // The locale service derives its provisional locale from the browser and + // takes an explicit choice only from Host settings, which this lane's + // fixture transport does not serve; pinning the navigator is what selects + // English here. + Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true }) + Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true }) document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => @@ -88,6 +93,11 @@ export function installAssembledBootEnv(): void { document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' history.replaceState(null, '', '/') + // Deleting the own properties uncovers jsdom's own accessors again + // (Navigator declares both readonly, hence the erased receiver). + const ownNavigator = navigator as unknown as Record + delete ownNavigator.languages + delete ownNavigator.language vi.unstubAllGlobals() }) } diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 6546a79f1b..df286ec1c3 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -14,7 +14,7 @@ installAssembledBootEnv() /** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */ async function openFixtureSession(): Promise { - const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 }) + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) const group = (await within(tree).findAllByText('fixture')) .map(el => el.closest('[role="treeitem"]')) .find(el => el?.getAttribute('aria-expanded') !== null) @@ -33,7 +33,6 @@ async function openFixtureSession(): Promise { } it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => { - localStorage.setItem('dsh.locale', 'zh') mountAssembledApp() await openFixtureSession() @@ -73,24 +72,23 @@ it('renders the history image pair through the authorized attachment route and o fireEvent.doubleClick(frame) const lightbox = await screen.findByRole('dialog') expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob') - fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ })) + fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ })) await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() }) }) it('accepts pasted images into the composer rail in order and removes them', async () => { - localStorage.setItem('dsh.locale', 'zh') mountAssembledApp() - const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 }) - const start = tree.querySelector('button[aria-label="在“fixture”中新建会话"]') + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') if (start === null) throw new Error('fixture Workspace new-session action missing') fireEvent.click(start) // Image-only send arming is pinned at package level (input-bar.spec.tsx); // this assembled lane pins the intake chain over the built graph. - const textarea = await screen.findByPlaceholderText('描述你想要构建的内容', {}, { timeout: 10_000 }) + const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { @@ -102,7 +100,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy // The rail is an accessible group holding the draft thumbnail (queried via // DOM: jsdom's a11y-visibility computation hides the composer subtree). const rail = await waitFor(() => { - const el = document.querySelector('[role="group"][aria-label="待发送图片"]') + const el = document.querySelector('[role="group"][aria-label="Pending images"]') if (el === null) throw new Error('attachment rail missing') return el }, { timeout: 5_000 }) @@ -129,10 +127,10 @@ it('accepts pasted images into the composer rail in order and removes them', asy .toEqual(['pasted.png', 'second.png']) }) - const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')] + const remove = [...rail.querySelectorAll('button[aria-label^="Remove image"]')] if (remove.length !== 2) throw new Error('remove buttons missing') for (const button of remove) fireEvent.click(button) await waitFor(() => { - expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() }) }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index eebdd3a8a8..945775678f 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -251,6 +251,8 @@ export interface LaunchOptions { * 127.0.0.1; a non-resolving authority fails before Host trust is exercised. */ remoteAuthority?: string + /** Reuse an existing harness home so a second Host can verify user settings across origins. */ + harnessHome?: string } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -297,16 +299,19 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise localStorage dsh.theme +// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings // -> theme/change -> ui-layout's presenter -> body attribute -> alias token + // browser theme-color metadata) -// the Language row (settings-scoped localization + persisted dsh.locale), -// the busy-state Enter preference, plus Permission as the persisted default -// for subsequently created sessions. +// the Language row and busy-state Enter preference (both Host-backed), plus +// Permission as the persisted default for subsequently created sessions. // Zero model calls: everything is pure client + persistence state on a blank // frame, so there is no fixture and a stray stream would fail loud on the // open llm seam. @@ -153,23 +152,24 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('flips the theme through the Appearance cubes and persists across reload', async () => { + it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) interface ThemeState { attr: boolean background: string - stored: string | null + /** Pre-migration localStorage key; the Host-backed world never writes it. */ + legacy: string | null themeColor: string | null themeColorCount: number token: string } - const readState = async (): Promise => await page.evaluate(() => { + const readState = async (target: Page = page): Promise => await target.evaluate(() => { const metas = document.head.querySelectorAll('meta[name="theme-color"]') const computed = getComputedStyle(document.body) return { attr: document.body.hasAttribute('data-ds-dark-theme'), background: computed.backgroundColor, - stored: localStorage.getItem('dsh.theme'), + legacy: localStorage.getItem('dsh.theme'), themeColor: metas[0]?.content ?? null, themeColorCount: metas.length, token: computed.getPropertyValue('--dsw-alias-bg-base').trim(), @@ -193,27 +193,51 @@ describe('web e2e: settings modal and General preferences', () => { const darkCube = dialog.getByRole('button', { name: '深色' }) expect(await darkCube.getAttribute('aria-pressed')).toBe('false') await darkCube.click() - // The full cascade: pressed state, persisted preference, body attribute, + // The full cascade: pressed state, Host-backed preference, body attribute, // alias token flip — all from one real user gesture. await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') const dark = await readState() expect(dark.attr).toBe(true) - expect(dark.stored).toBe('dark') + expect(dark.legacy).toBeNull() expect(dark.token).not.toBe(light.token) expectThemeColorSynchronized(dark) + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-theme:\n\s+preference: dark/) await page.keyboard.press('Escape') - // Reload: the preference survives boot (restore + presenter initial apply). + // Reload: the preference survives the background Host read + presenter update. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.emulateMedia({ colorScheme: 'light' }) + await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) const reloaded = await readState() - expect(reloaded.attr).toBe(true) - expect(reloaded.stored).toBe('dark') + expect(reloaded.legacy).toBeNull() expectThemeColorSynchronized(reloaded) + // A second live Host binds another ephemeral port but shares the same + // user-settings home. Its fresh origin has no theme localStorage and still + // converges to dark before the settings dialog opens. + const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) + const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const secondTripwire = watchConsole(secondPage) + try { + expect(second.baseUrl).not.toBe(scaffold.baseUrl) + await secondPage.emulateMedia({ colorScheme: 'light' }) + await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true) + const secondState = await readState(secondPage) + expect(secondState.legacy).toBeNull() + expectThemeColorSynchronized(secondState) + expect(secondTripwire.pageErrors).toEqual([]) + expect(secondTripwire.warnings).toEqual([]) + } finally { + await secondPage.close() + await second.close() + } + // `system` follows the emulated OS scheme (dark stays dark, light clears). await page.getByRole('button', { name: '设置', exact: true }).click() const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' }) @@ -233,7 +257,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) - it('persists the busy-state Enter behavior across reload and restores Queue', async () => { + it('persists the busy-state Enter behavior across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior')) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) @@ -241,7 +265,9 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: '排队发送' }).click() await page.getByRole('menuitem', { name: '插话发送' }).click() await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) - expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer') + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-conversation:\n\s+busyEnter: steer/) await page.keyboard.press('Escape') const warningStart = tripwire.warnings.length @@ -251,15 +277,36 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const reloaded = page.getByRole('dialog', { name: '设置' }) await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + + const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) + const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const secondTripwire = watchConsole(secondPage) + try { + expect(second.baseUrl).not.toBe(scaffold.baseUrl) + await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await secondPage.getByRole('button', { name: '设置', exact: true }).click() + await secondPage.getByRole('dialog', { name: '设置' }) + .getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + expect(await secondPage.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull() + expect(secondTripwire.pageErrors).toEqual([]) + expect(secondTripwire.warnings).toEqual([]) + } finally { + await secondPage.close() + await second.close() + } + await reloaded.getByRole('button', { name: '插话发送' }).click() await page.getByRole('menuitem', { name: '排队发送' }).click() await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 }) - expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue') + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-conversation:\n\s+busyEnter: queue/) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) - it('switches the settings surface language and persists dsh.locale', async () => { + it('persists the settings language across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language')) await page.getByRole('button', { name: '设置', exact: true }).click() const zhDialog = page.getByRole('dialog', { name: '设置' }) @@ -276,7 +323,9 @@ describe('web e2e: settings modal and General preferences', () => { await enDialog.waitFor({ timeout: 10_000 }) expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) - expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en') + expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/locale:\n\s+preference: en/) // Reload keeps English; then restore zh so shared page state (and the // other specs' 设置-anchored selectors + goldens) see the default again. const warningStart = tripwire.warnings.length @@ -285,24 +334,47 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) const enTrigger = page.getByRole('button', { name: 'Settings' }) await enTrigger.waitFor({ timeout: 10_000 }) + + // A Chinese browser on another port still receives the explicit English + // preference from the shared Host settings document. + const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) + const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const secondTripwire = watchConsole(secondPage) + try { + expect(second.baseUrl).not.toBe(scaffold.baseUrl) + await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await secondPage.getByRole('button', { name: 'Settings', exact: true }).click() + await secondPage.getByRole('dialog', { name: 'Settings' }) + .getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + expect(await secondPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + expect(secondTripwire.pageErrors).toEqual([]) + expect(secondTripwire.warnings).toEqual([]) + } finally { + await secondPage.close() + await second.close() + } + await enTrigger.click() await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click() await page.getByRole('menuitem', { name: '中文' }).click() await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 }) - expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh') + expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/locale:\n\s+preference: zh/) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) it('opens an English browser in English without any stored preference', async () => { - // A second page under a different browser language: nothing is persisted - // for it, so the settings surface must follow the browser rather than the - // product fallback the shared zh page shows. + // A fresh Host home has no locale preference, so its surface follows the + // browser rather than the product fallback. + const fresh = await launchWebScaffold({}) const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) const enTripwire = watchConsole(enPage) onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language')) try { - await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await enPage.goto(fresh.baseUrl, { waitUntil: 'load' }) await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() await enPage.getByRole('button', { name: 'Settings', exact: true }).click() @@ -315,6 +387,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(enTripwire.warnings).toEqual([]) } finally { await enPage.close() + await fresh.close() } }, 90_000) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 1b7b67aab3..40b9be39ca 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -18,18 +18,17 @@ export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) export const ZH_BROWSER_LOCALE = 'zh-CN' /** - * Open the standard browser-test page with English selected before client - * boot. This keeps role locators and goldens deterministic across localized - * component migrations; the scenarios asserting the Chinese surface bypass - * this helper and advertise {@link ZH_BROWSER_LOCALE} instead. + * Open the standard browser-test page advertising English before client boot. + * This keeps role locators and goldens deterministic while leaving the Host + * settings document free to override the provisional browser-derived locale; + * scenarios asserting the Chinese surface advertise + * {@link ZH_BROWSER_LOCALE} instead. * @param browser - Playwright browser owning the page. * @param height - Viewport height; width is fixed to the lane baseline. * @returns the initialized page. */ export async function newEnglishPage(browser: Browser, height = 1000): Promise { - const page = await browser.newPage({ viewport: { width: 1680, height } }) - await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') }) - return page + return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US' }) } /** Fail loud on a stale checkout instead of testing yesterday's bundle. */ diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 64ea632cb6..96c9b36352 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -40,7 +40,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). -- **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). +- **Pairs update together**: [Terminology-guided](i18n/terminology.md), single-pass active-agent work repositions first-use annotations, preserves untouched prose, and re-records; `dsh-translate-docs` remains user-invoked ([contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, failure, timing, ownership, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. - Write directly: name actors and facts ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability. Name the exact check, type, API, operation, or behavior instead of metaphorical "gate", "vocabulary", or "surface". diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a2caf7b784..268fa1c8d0 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: b78171ce51931f02a3f39ef98104ea9dedc27360 -event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f +event-producer-consumer.md: d2e5cd66c2406cf84320fc1c0537d580214dd93d +event-producer-consumer.zh.md: 12ac194d68ed6121cc0b2c5e9bf2c7f77944f57b diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b78171ce51..d2e5cd66c2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -62,7 +62,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | +| `connection/reset` | `runtime` (`emit`) | `runtime`, `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | @@ -71,7 +71,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | | `session/preset-changed` | `runtime` (`emit`) | `ui-command` | -| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | +| `settings/changed` | `runtime` (`emit`) | `runtime`, `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c044385bf9..12ac194d68 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -64,7 +64,7 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | +| `connection/reset` | `runtime` (`emit`) | `runtime`, `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | @@ -73,7 +73,7 @@ | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | | `session/preset-changed` | `runtime` (`emit`) | `ui-command` | -| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | +| `settings/changed` | `runtime` (`emit`) | `runtime`, `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 45e4077203..087e9e9dfe 100644 --- a/docs/i18n/README.i18n.yaml +++ b/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: af6a35294bc23adcd6214747f78a28a69ae443d1 -README.zh.md: 74cb98932460d014dab26d3b48cd81142e0f7bf8 +README.md: 9875eb0c9924daa0b519923e9aac8a67de8cda61 +README.zh.md: eed73226dffd9bc1f6af7b21af5b0b77363878e2 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index af6a35294b..9875eb0c99 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). +This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation. ## The pairing contract @@ -15,7 +15,7 @@ This repo's documentation is read by people and agents both inside and outside t foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). + Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. - **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. @@ -35,7 +35,7 @@ Source-oriented code gates consume an exact `.zh.md` fence sequence as a derivat `pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level. -The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write `), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. +The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. @@ -57,4 +57,4 @@ Generated English references and graphs participate in pairing when a reviewed C ## Division of labor -Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`. +Routine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 74cb989324..eed73226df 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。 ## 配对约定 @@ -15,7 +15,7 @@ foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 - **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。 @@ -35,7 +35,7 @@ `pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。 -这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write `),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 +这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 @@ -57,4 +57,4 @@ ## 分工 -这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。 +日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。 diff --git a/docs/i18n/translation-rules.i18n.yaml b/docs/i18n/translation-rules.i18n.yaml index e8e4d8f801..34b03c956f 100644 --- a/docs/i18n/translation-rules.i18n.yaml +++ b/docs/i18n/translation-rules.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/translation-rules.md -translation-rules.md: fb6aa9ac05bebe68ff9213af99f64457bdb1ad6f -translation-rules.zh.md: 04dd0a704e19502c676ea0966437870c5af0624f +translation-rules.md: ce20ed9a9673b0782ef07c9a4a21ff1c98ace960 +translation-rules.zh.md: daea57ab1d3a1abbad442982c8bb1c189478b8a8 diff --git a/docs/i18n/translation-rules.md b/docs/i18n/translation-rules.md index fb6aa9ac05..ce20ed9a96 100644 --- a/docs/i18n/translation-rules.md +++ b/docs/i18n/translation-rules.md @@ -2,7 +2,7 @@ English | [中文](translation-rules.zh.md) -How to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. +How to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. ## Faithfulness @@ -13,7 +13,7 @@ How to translate between the two sides of a documentation pair in this repo. Bot ## Voice - The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose. -- Write as a native technical author restating the content, not as a translator transposing sentences. Then verify against the source clause by clause: nothing added, nothing dropped — fluency never justifies losing a clause. +- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause. - Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人). - Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it. - Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs. @@ -54,8 +54,7 @@ These rules govern the Chinese side; the English side follows the repo's normal ## Quality bar - A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra. -- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you. -- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone. +- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone. ## References diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md index 04dd0a704e..daea57ab1d 100644 --- a/docs/i18n/translation-rules.zh.md +++ b/docs/i18n/translation-rules.zh.md @@ -2,7 +2,7 @@ [English](translation-rules.md) | 中文 -本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效;应用这些规则的仓库内置 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。 +本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。 ## 忠实性 @@ -13,7 +13,7 @@ ## 行文 - 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。 -- 以母语技术作者的身份重述内容,而不是逐句转写的译者。写完后逐句对照原文核验:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。 +- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。 - 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。 - 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。 - 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。 @@ -54,8 +54,7 @@ ## 质量标准 - 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。 -- 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。 -- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体仍需人工核对。 +- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。 ## 参考资料 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d478f79cd1..128581d1ba 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: 6763ca6e84a5cc2e5e776a56e5cfc20b710017aa -module-graph.zh.md: 339345b39a8225c34ecf0ba73efff34d4b940046 +module-graph.md: a81c8fa85eb85d5ec31bfff286fceff511880715 +module-graph.zh.md: b1ec05f45d1dac545e020f6438f2e2b4d197ebdf diff --git a/docs/module-graph.md b/docs/module-graph.md index 6763ca6e84..a81c8fa85e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -382,6 +382,7 @@ flowchart TD pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_invariants pkg_attachment_local --> pkg_paths + pkg_client_locale --> pkg_client_connection pkg_client_locale --> pkg_client_runtime pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots @@ -455,6 +456,7 @@ flowchart TD pkg_client_ui_slash --> pkg_client_ui_primitives pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_connection pkg_client_ui_theme --> pkg_client_locale pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives @@ -1050,6 +1052,7 @@ flowchart TD pkg_client_ui_conversation --> pkg_agent pkg_client_ui_conversation --> pkg_attachment pkg_client_ui_conversation --> pkg_brand + pkg_client_ui_conversation --> pkg_client_connection pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1289,7 +1292,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | @@ -1305,7 +1308,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | @@ -1424,7 +1427,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 339345b39a..b1ec05f45d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -384,6 +384,7 @@ flowchart TD pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_invariants pkg_attachment_local --> pkg_paths + pkg_client_locale --> pkg_client_connection pkg_client_locale --> pkg_client_runtime pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots @@ -457,6 +458,7 @@ flowchart TD pkg_client_ui_slash --> pkg_client_ui_primitives pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_connection pkg_client_ui_theme --> pkg_client_locale pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives @@ -1052,6 +1054,7 @@ flowchart TD pkg_client_ui_conversation --> pkg_agent pkg_client_ui_conversation --> pkg_attachment pkg_client_ui_conversation --> pkg_brand + pkg_client_ui_conversation --> pkg_client_connection pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1291,7 +1294,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | @@ -1307,7 +1310,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | @@ -1426,7 +1429,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/package.json b/package.json index d417354739..2830640c26 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "verify-agent-note-format": "tsx scripts/verify-agent-note-format.ts", "verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-skill-invocation-metadata": "tsx scripts/verify-skill-invocation-metadata.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "resolve-translation-pairing-conflicts": "tsx scripts/merge-translation-pairing.ts --resolve", diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index d1ef53207f..3918beb028 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/locale/README.md -README.md: f1efefde4557e1c29c0556f8b670f1534430ab79 -README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291 +README.md: 5bea46cd4e3ace61bd2251610abdf0812ded9604 +README.zh.md: 2333bc7c2b2f5918c35286064c50131153ee8711 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index f1efefde45..5bea46cd4e 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; with nothing persisted a fresh browser opens in the language `navigator` asks for — matched on the primary subtag, `zh` when it asks for none this app ships; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). +Locale plugin: LocaleService — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index a8b5704d28..2333bc7c2b 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;未持久化偏好时,全新浏览器以 `navigator` 请求的语言开场——按主子标签匹配,若其请求的语言本应用都不提供则为 `zh`;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。 +locale 插件:LocaleService——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 ## 模型体验 diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 75742a80d0..cfadff76b8 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-locale", - "description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row", + "description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime" ], "platform": "web", @@ -31,6 +32,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", @@ -47,6 +49,10 @@ "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, + "dependencies": { + "@deepseek-ai/dsh-settings": "workspace:^", + "schemastery": "^3.18.0" + }, "files": [ "lib/index.js", "lib/invariant.js", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 795388c5fd..b8c06872db 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -13,7 +13,12 @@ import type { Context } from 'cordis' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { + bindSettingsScope, type ClientContext, type SettingsScope, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings, +} from '../locale-settings.ts' import { en, zh, type CommonKey } from '../locales/index.ts' import { en as settingsEn, zh as settingsZh, type SettingsLocaleKey, @@ -26,6 +31,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' export type { CommonKey } from '../locales/index.ts' +export type { LocaleId, LocaleSettings } from '../locale-settings.ts' // The translate currency lives in ui-slots (the render machinery synthesizes // the seat); re-exported here so dictionary owners import one package. @@ -44,9 +50,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Locale dictionary: flat key to template string ({name} placeholders). */ export type LocaleDict = Record -/** Locale identifier: the two shipped locales. */ -export type LocaleId = 'zh' | 'en' - /** One selectable locale: id plus its self-described display name. */ export interface LocaleDefinition { /** Locale id (persisted; the setLocale argument). */ @@ -91,9 +94,6 @@ export const COMMON_NS = 'common' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.locale' -/** localStorage key holding the persisted locale id. */ -export const STORAGE_KEY = 'dsh.locale' - /** The two shipped locales. */ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ { id: 'zh', label: '中文' }, @@ -116,13 +116,25 @@ export class LocaleService { private snapshot: LocaleSnapshot private listeners = new Set<() => void>() private readonly ctx: Context + private readonly host: SettingsScope | undefined + /** Browser-derived locale standing wherever no explicit Host selection does. */ + private readonly provisional: LocaleId /** - * @param ctx - owning context (change events are emitted on it). + * @param ctx - owning context (change events are emitted on it; the scope + * listener is released through ctx.effect on dispose). + * @param host - durable preference scope owned by the providing plugin; + * absent compositions (standalone dictionary registries) stay process-local. */ - constructor(ctx: Context) { + constructor(ctx: Context, host?: SettingsScope) { this.ctx = ctx - this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 }) + this.host = host + this.provisional = resolveInitialLocale() + this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 }) + if (host !== undefined) { + ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption') + this.adopt(host) + } } /** @@ -155,16 +167,28 @@ export class LocaleService { } /** - * Switch the active locale — the only preference write entry. Persists the - * id and emits `locale/change`. + * Switch the active locale — the only user preference write entry. * @param id - a registered locale id; unknown ids throw. */ setLocale(id: string): void { const match = this.snapshot.locales.find(l => l.id === id) if (match === undefined) throw new Error(`locale "${id}" is not registered`) if (this.snapshot.active === match.id) return - persistPreference(match.id) this.publish(match.id, true) + void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id) + } + + /** + * Adopt the scope's accepted durable selection without writing it back; an + * absent selection returns to the browser-derived locale. + * @param host - the constructor-narrowed scope driving this adoption. + */ + private adopt(host: SettingsScope): void { + const section = host.getSnapshot().value + if (section === undefined) return + const target = section.preference ?? this.provisional + if (this.snapshot.active === target) return + this.publish(target, true) } /** @@ -288,27 +312,11 @@ export class LocaleService { } /** - * The locale a fresh service opens with: an explicit preference the user - * already chose wins over the browser's own language, which in turn wins over - * {@link FALLBACK_LOCALE} (non-browser boots and browsers set to a language - * this app does not ship). + * The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit + * Host preference may replace this provisional value after plugin activation. */ function resolveInitialLocale(): LocaleId { - return restorePreference() ?? detectBrowserLocale() ?? FALLBACK_LOCALE -} - -/** Read the persisted locale id; unknown or unreadable values read as no preference. */ -function restorePreference(): LocaleId | undefined { - // Non-browser runs (node e2e booting the client tree) have no localStorage. - if (typeof localStorage === 'undefined') return undefined - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored === 'zh' || stored === 'en') return stored - } catch { - // Storage access can throw (privacy mode); an unreadable store simply - // records no preference, and the browser language decides instead. - } - return undefined + return detectBrowserLocale() ?? FALLBACK_LOCALE } /** @@ -325,8 +333,7 @@ function detectBrowserLocale(): LocaleId | undefined { /* oxlint-disable-next-line typescript/no-unnecessary-condition -- * The DOM lib types `languages` as always present; embedders and older * WebViews ship a Navigator without it, and spreading undefined would - * throw at boot. Same environment-boundary distrust as the localStorage - * guards below. */ + * throw at boot. */ for (const tag of [...(navigator.languages ?? []), navigator.language]) { const primary = tag.toLowerCase().split('-')[0] const match = LOCALES.find(locale => locale.id === primary) @@ -335,19 +342,8 @@ function detectBrowserLocale(): LocaleId | undefined { return undefined } -/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ -function persistPreference(id: LocaleId): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(STORAGE_KEY, id) - } catch { - // Storage access can throw (privacy mode / quota); the preference simply - // does not survive the session. - } -} - -/** Required services: the slot registry (the feature registers its own settings row). */ -export const inject = ['slots'] +/** Required services: slot registration plus the settings transport. */ +export const inject = ['slots', 'connection'] /** * Client plugin body: provide the locale service with base dictionaries and @@ -356,7 +352,8 @@ export const inject = ['slots'] * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { - const locale = new LocaleService(ctx) + const host = bindSettingsScope(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE }) + const locale = new LocaleService(ctx, host) locale.register(COMMON_NS, { zh, en }) locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) ctx.provide('locale', locale) diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index c220373932..c8d7ed9f95 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,4 +1,23 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ +/** Host registration for the browser locale preference. */ -/** Host plugin body — no host-side behavior for the locale plugin. */ -export function apply(): void {} +import type { Context } from 'cordis' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts' + +export { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, + type LocaleId, type LocaleSettings, +} from './locale-settings.ts' + +/** + * Register the durable locale section when a settings provider exists. + * @param ctx - Host context whose optional settings service owns the section. + */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(LOCALE_SETTINGS_NAMESPACE), + LocaleSettingsSchema, + ) + }) +} diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts new file mode 100644 index 0000000000..c5d0399f86 --- /dev/null +++ b/packages/client/locale/src/locale-settings.ts @@ -0,0 +1,26 @@ +/** Locale preference stored in the Host user-settings document. */ + +import z from 'schemastery' + +/** Settings namespace owned by the locale plugin. */ +export const LOCALE_SETTINGS_NAMESPACE = 'locale' + +/** Field carrying an explicit locale selection; absence delegates to the browser. */ +export const LOCALE_PREFERENCE_FIELD = 'preference' + +/** Locale identifiers shipped by the browser client. */ +export const LOCALE_IDS = ['zh', 'en'] as const + +/** Shipped locale identifier. */ +export type LocaleId = typeof LOCALE_IDS[number] + +/** Durable locale section shared by the Host schema and the browser scope. */ +export interface LocaleSettings { + /** Explicit locale selection; absence delegates to the browser. */ + preference?: LocaleId +} + +/** Durable locale schema; also the wire envelope the browser scope validates against. */ +export const LocaleSettingsSchema: z = z.object({ + [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), +}) diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index a3007f8c78..84f4299ef2 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -4,8 +4,11 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client' +import { + apply, inject, SETTINGS_NS, +} from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '../src/locale-settings.ts' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' @@ -14,7 +17,36 @@ const SLOT = 'settings.general.item' async function bench() { const ctx = new Context() await ctx.plugin(SlotsService).await() - return { ctx, slots: ctx.get('slots') as SlotsService } + let preference: string | undefined + let revision = 0 + const namespace = () => ({ + ns: LOCALE_SETTINGS_NAMESPACE, + schema: LocaleSettingsSchema.toJSON(), + value: preference === undefined ? {} : { preference }, + applies: 'live' as const, + secrets: [], + revision, + }) + const describe = vi.fn(async () => ({ + rpcId: 'locale-describe' as never, + result: { + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [namespace()] }, + }, + })) + const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { + preference = request.ops[0]!.value + revision += 1 + return { + rpcId: 'locale-mutate' as never, + result: { ok: true as const, value: namespace() }, + } + }) + ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) + return { + ctx, slots: ctx.get('slots') as SlotsService, describe, mutate, + setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, + } } /** Stand in for the settings shell: declare the General item slot from root. */ @@ -47,7 +79,7 @@ describe('locale apply', () => { }) it('declares the slot service', () => { - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'connection']) }) it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { @@ -91,6 +123,23 @@ describe('locale apply', () => { expect(locale.getLocale().active).toBe('zh') expect(instance.getSnapshot().active).toBe('zh') expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) + }) + + it('loads and refreshes the explicit Host preference after nonblocking activation', async () => { + const b = await bench() + b.setHostPreference('en') + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const locale = b.ctx.get('locale') as LocaleService + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + b.setHostPreference(undefined) + b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) + b.setHostPreference('en') + b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + expect(b.describe).toHaveBeenCalledTimes(3) }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/locale/tests/host.spec.ts b/packages/client/locale/tests/host.spec.ts new file mode 100644 index 0000000000..8fa339e660 --- /dev/null +++ b/packages/client/locale/tests/host.spec.ts @@ -0,0 +1,30 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + LOCALE_SETTINGS_NAMESPACE, apply, +} from '@deepseek-ai/dsh-client-locale' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('locale host', () => { + it('registers an optional explicit locale preference with the Host settings lifecycle', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(LOCALE_SETTINGS_NAMESPACE) + expect(ctx.settings.get(ns)).toEqual({}) + await ctx.settings.update(ns, { preference: 'en' }) + expect(ctx.settings.get(ns)).toEqual({ preference: 'en' }) + await expect(ctx.settings.update(ns, { preference: 'fr' })).rejects.toThrow() + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) +}) diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts index fa62ca79f6..2b362cb115 100644 --- a/packages/client/locale/tests/invariant.spec.ts +++ b/packages/client/locale/tests/invariant.spec.ts @@ -14,16 +14,16 @@ describe('invariant companion', () => { await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined() }) - it('node-half apply is a no-op host placeholder', () => { - nodeApply() - expect(true).toBe(true) // reaching here without throw is the contract + it('node-half apply tolerates a Host without settings', () => { + nodeApply(new Context()) }) it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { // The feature registers its own Language settings row, hence the slots edge. - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'connection']) const ctx = new Context() new SlotsService(ctx) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await ctx.plugin({ inject, apply: clientApply }).await() const locale = ctx.get('locale') expect(locale).toBeInstanceOf(LocaleService) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 7e9a7802e1..2b696f6fc5 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -1,14 +1,19 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' -import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client' +import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => { +const make = (host?: StubSettingsScope): { + ctx: Context + svc: LocaleService + events: LocaleSnapshot[] +} => { const ctx = new Context() const events: LocaleSnapshot[] = [] ctx.on('locale/change', (snapshot) => { events.push(snapshot) }) - return { ctx, svc: new LocaleService(ctx), events } + return { ctx, svc: new LocaleService(ctx, host?.scope), events } } /** @@ -24,7 +29,6 @@ const stubLanguages = (...tags: string[]): void => { describe('LocaleService', () => { beforeEach(() => { - localStorage.clear() // A Chinese browser is the baseline these specs assert their zh state on. stubLanguages('zh-CN') }) @@ -132,16 +136,25 @@ describe('LocaleService', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { - const { svc, events } = make() + it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => { + const host = stubSettingsScope() + const { svc, events } = make(host) svc.setLocale('en') expect(svc.getLocale().active).toBe('en') - expect(localStorage.getItem(STORAGE_KEY)).toBe('en') + expect(host.set).toHaveBeenCalledWith('preference', 'en') expect(events).toHaveLength(1) expect(events[0]).toBe(svc.getLocale()) expect(events[0]!.revision).toBe(1) svc.setLocale('en') expect(events).toHaveLength(1) + expect(host.set).toHaveBeenCalledOnce() + }) + + it('setLocale without a host scope stays process-local', () => { + const { svc, events } = make() + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + expect(events).toHaveLength(1) }) it('throws on unknown locale ids', () => { @@ -149,14 +162,37 @@ describe('LocaleService', () => { expect(() => { svc.setLocale('fr') }).toThrow('not registered') }) - it('restores a persisted locale over the browser language, and garbage reads as no preference', () => { - localStorage.setItem(STORAGE_KEY, 'en') - expect(make().svc.getLocale().active).toBe('en') - localStorage.setItem(STORAGE_KEY, 'fr') - expect(make().svc.getLocale().active).toBe('zh') + it('adopts a Host preference over the browser language without writing it back', () => { + const host = stubSettingsScope() + const { svc, events } = make(host) + host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true }) + expect(svc.getLocale().active).toBe('en') + expect(events).toHaveLength(1) + expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { preference: 'en' }, revision: 2 }) + expect(events).toHaveLength(1) }) - it('opens in the browser language when nothing is persisted, matching regional variants on their primary subtag', () => { + it('an absent Host preference returns to the browser-derived locale', () => { + const host = stubSettingsScope() + const { svc } = make(host) + host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true }) + expect(svc.getLocale().active).toBe('en') + host.publish({ value: {}, revision: 2 }) + expect(svc.getLocale().active).toBe('zh') + }) + + it('adopts a section already standing at construction and releases its subscription on dispose', async () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true }) + const { ctx, svc } = make(host) + expect(svc.getLocale().active).toBe('en') + expect(host.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(host.listenerCount()).toBe(0) + }) + + it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => { stubLanguages('en-GB', 'zh-CN') expect(make().svc.getLocale().active).toBe('en') stubLanguages('zh-Hant-TW') @@ -176,8 +212,7 @@ describe('LocaleService', () => { expect(make().svc.getLocale().active).toBe('zh') }) - it('runs outside a browser (node boots): the fallback decides, the machine language does not, writes no-op', () => { - vi.stubGlobal('localStorage', undefined) + it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => { vi.stubGlobal('window', undefined) // Node exposes its own global navigator; without a window it must not // reach the resolution at all. @@ -188,12 +223,11 @@ describe('LocaleService', () => { expect(svc.getLocale().active).toBe('en') }) - it('keeps the browser language out of the way once a preference exists', () => { + it('lets an explicit in-process preference replace the browser-derived value', () => { stubLanguages('en-US') const { svc } = make() svc.setLocale('zh') - expect(localStorage.getItem(STORAGE_KEY)).toBe('zh') - expect(make().svc.getLocale().active).toBe('zh') + expect(svc.getLocale().active).toBe('zh') }) it('exposes the two shipped locales with self-described labels', () => { diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 8585ba74ca..313c11f5bf 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 16e055f0bf..7dc91083c9 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/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/client/runtime/README.md -README.md: 753d1de796ba8ff20217d423555710429e9b7a75 -README.zh.md: 9b5b8ba7ce42875afd4b9b83b9c2f64e95298ca5 +README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412 +README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 753d1de796..1ec6cc38ae 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime. + ## Slot declaration injection `ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9b5b8ba7ce..6602152790 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -4,6 +4,8 @@ 客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 + ## Slot 声明注入 `ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 5aaf492eae..bb24c62a32 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -61,7 +62,8 @@ "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.18.0" }, "files": [ "lib/index.js", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 955431d509..0bc673b542 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -43,6 +43,8 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' +export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts' +export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts' export { resolveWorkspacePath } from './workspaces/path.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' diff --git a/packages/client/runtime/src/client/settings-scope.ts b/packages/client/runtime/src/client/settings-scope.ts new file mode 100644 index 0000000000..91b6c7ec3a --- /dev/null +++ b/packages/client/runtime/src/client/settings-scope.ts @@ -0,0 +1,261 @@ +/** Host-backed settings-namespace synchronization for browser plugins. */ + +import type { Context } from 'cordis' +import type { + ConnectionHandle, IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' +import { createSnapshotStore, type SnapshotStore } from './contract/store.ts' + +/** Client-side sync state of one settings namespace. */ +export interface SettingsScopeSnapshot { + /** + * `loading` until the first accepted section, `ready` while one stands, and + * `unavailable` when the namespace is not exposed to this client or the + * connection keeps preferences process-local (memory mode). + */ + status: 'loading' | 'ready' | 'unavailable' + /** Last accepted schema-resolved section; undefined before the first acceptance. */ + value: T | undefined + /** Namespace revision fencing the next write; undefined before the first Host view. */ + revision: number | undefined + /** Whether the Host document accepts writes; memory mode never does. */ + writable: boolean + /** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */ + mode: 'host' | 'memory' +} + +/** Domain-owned description of one settings namespace consumed by a browser plugin. */ +export interface SettingsScopeSpec { + /** Settings namespace registered by the owning Host plugin. */ + namespace: string + /** + * Narrow one wire section; undefined keeps the last accepted value. The + * default validates the section against the namespace's own serialized wire + * schema, so domains add a decoder only to narrow beyond that schema. + */ + decode?: (section: unknown) => T | undefined +} + +/** + * Reactive owner handle over one namespace's durable section — the browser + * mirror of the Host-side `SettingsScope` owner seam. Domain services read + * and observe the snapshot and route explicit user choices through `set`. + */ +export interface SettingsScope { + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsScopeSnapshot + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void + /** + * Queue one field write. Rapid writes preserve mutation order, each carries + * the latest known namespace revision, and only the latest settlement may + * publish; a rejected or failed latest write reloads Host state instead. + * @param field - scalar field inside the namespace section. + * @param value - JSON-shaped value selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + set(field: string, value: unknown): Promise +} + +type SettingsFace = Pick + +/** + * Serializes one namespace's Host reads and writes behind a snapshot store. + * Reads never block plugin activation; writes carry the latest known + * namespace revision and teardown waits for the operation already crossing + * the wire. + */ +export class SettingsScopeController implements SettingsScope { + private readonly store: SnapshotStore> + private tail: Promise = Promise.resolve() + private readGeneration = 0 + private writeGeneration = 0 + private disposed = false + + /** + * @param api - settings wire face. + * @param spec - namespace identity and optional narrowing decoder. + * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. + */ + constructor( + private readonly api: SettingsFace, + private readonly spec: SettingsScopeSpec, + private readonly persistence: 'host' | 'memory' = 'host', + ) { + this.store = createSnapshotStore>({ + status: persistence === 'host' ? 'loading' : 'unavailable', + value: undefined, + revision: undefined, + writable: false, + mode: persistence, + }) + } + + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsScopeSnapshot { + return this.store.getSnapshot() + } + + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void { + return this.store.subscribe(listener) + } + + /** + * Queue a Host refresh; a newer read or user write suppresses stale publication. + * @returns settlement after the queued read completes or is skipped. + */ + load(): Promise { + const generation = ++this.readGeneration + return this.enqueue(() => this.read(generation)) + } + + /** + * Queue one field write; see {@link SettingsScope.set} for the ordering, + * revision, and recovery contract. + * @param field - scalar field inside the namespace section. + * @param value - JSON-shaped value selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + set(field: string, value: unknown): Promise { + this.readGeneration += 1 + const generation = ++this.writeGeneration + return this.enqueue(async () => { + const revision = this.getSnapshot().revision + let response: Awaited> + try { + response = await this.api.settings.mutate({ + ns: this.spec.namespace, + ops: [{ op: 'set', path: [field], value }], + ...(revision === undefined ? {} : { expectedRevision: revision }), + }) + } catch (_settingsWriteFailure) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + if (!response.result.ok) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + this.accept(response.result.value, generation === this.writeGeneration) + }) + } + + /** + * Stop queued operations and wait for the current wire call to settle. + * @returns settlement after the controller reaches quiescence. + */ + async dispose(): Promise { + this.disposed = true + this.readGeneration += 1 + this.writeGeneration += 1 + await this.tail + } + + private enqueue(operation: () => Promise): Promise { + if (this.persistence === 'memory' || this.disposed) return Promise.resolve() + const task = this.tail.then(async () => { + if (this.disposed) return + await operation() + }) + // The returned task carries its own settlement to the caller; the queue + // tail is kept fulfilled so one failed subscriber cannot strand later operations. + this.tail = task.catch(() => {}) + return task + } + + private async read(generation: number): Promise { + let response: Awaited> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + return + } + if (!response.result.ok || this.disposed) return + const { namespaces, writable } = response.result.value + const view = namespaces.find(candidate => candidate.ns === this.spec.namespace) + const publish = generation === this.readGeneration + if (view === undefined) { + if (publish) { + this.store.update((draft) => { + draft.status = 'unavailable' + draft.writable = writable + }) + } + return + } + this.accept(view, publish, writable) + } + + private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void { + const decoded = publish ? this.decode(view) : undefined + this.store.update((draft) => { + draft.revision = view.revision + if (writable !== undefined) draft.writable = writable + if (decoded === undefined) return + draft.status = 'ready' + draft.value = decoded + }) + } + + private decode(view: SettingsNamespaceView): T | undefined { + if (this.spec.decode !== undefined) return this.spec.decode(view.value) + // Sections are plain objects by construction; schemastery alone would + // resolve null or an array through object defaults instead of refusing. + if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined + let failure: string | undefined + try { + failure = validateDraft(rehydrateSchema(view.schema), view.value) + } catch (_malformedSchemaEnvelope) { + // A schema envelope this client cannot rehydrate vouches for no section; + // the value is treated exactly like a schema-invalid one. + return undefined + } + return failure === undefined ? view.value as T : undefined + } +} + +/** + * Bind one namespace scope to settings and connection invalidations on the + * caller's plugin lifecycle. Listeners exist before the initial background + * read starts, so activation never blocks on the settings transport. + * @param ctx - owning browser plugin context. + * @param spec - domain-owned namespace contract. + * @returns the bound scope consumed by the domain's services and rows. + */ +export function bindSettingsScope( + ctx: Context, + spec: SettingsScopeSpec, +): SettingsScope { + const connection = ctx.get('connection') as ConnectionHandle + const controller = new SettingsScopeController( + connection.api, + spec, + connection.isLoopback ? 'host' : 'memory', + ) + ctx.effect(() => { + const refresh = (namespace?: string): void => { + if (namespace !== undefined && namespace !== spec.namespace) return + void controller.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + void controller.load() + return async () => { + for (const dispose of disposers) dispose() + await controller.dispose() + } + }, `runtime: ${spec.namespace} settings scope`) + return controller +} diff --git a/packages/client/runtime/tests/settings-scope.spec.ts b/packages/client/runtime/tests/settings-scope.spec.ts new file mode 100644 index 0000000000..db980bf6d1 --- /dev/null +++ b/packages/client/runtime/tests/settings-scope.spec.ts @@ -0,0 +1,352 @@ +import { Context } from 'cordis' +import z from 'schemastery' +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + bindSettingsScope, SettingsScopeController, type SettingsScope, +} from '../src/client/settings-scope.ts' + +interface UiTestSettings { + preference: 'light' | 'dark' | 'system' +} + +const ENVELOPE = z.object({ + preference: z.union(['light', 'dark', 'system']).default('system'), +}).toJSON() + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } } +} + +function rejected(): RpcResponse { + return { + rpcId: `scope-${rpc++}` as never, + result: { + ok: false, + error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } }, + }, + } +} + +function view(value: unknown, revision = 0): SettingsNamespaceView { + return { + ns: 'ui-test', + schema: ENVELOPE, + value, + applies: 'live', + secrets: [], + revision, + } +} + +function described(value: unknown, revision = 0) { + return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] }) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +/** Record each distinct published section, starting from the current one. */ +function trackValues(scope: SettingsScope): Array { + const seen: Array = [scope.getSnapshot().value] + scope.subscribe(() => { + const value = scope.getSnapshot().value + if (value !== seen[seen.length - 1]) seen.push(value) + }) + return seen +} + +describe('SettingsScopeController', () => { + it('starts loading and publishes a schema-valid section with revision and writability', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + expect(scope.getSnapshot()).toEqual({ + status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', + }) + await scope.load() + expect(scope.getSnapshot()).toEqual({ + status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host', + }) + }) + + it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + .mockResolvedValueOnce(described({ preference: 'sepia' }, 4)) + .mockResolvedValueOnce(described(null, 5)) + .mockResolvedValueOnce(described('scalar', 6)) + .mockResolvedValueOnce(described(['queue'], 7)) + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + const good = trackValues(scope) + for (let i = 0; i < 7; i++) await scope.load() + expect(scope.getSnapshot()).toMatchObject({ + status: 'ready', value: { preference: 'dark' }, revision: 7, + }) + expect(good).toEqual([undefined, { preference: 'dark' }]) + }) + + it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => { + const broken = { ...view({ preference: 'dark' }, 2), schema: null } + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] })) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 }) + }) + + it('suppresses a superseded read of an unexposed namespace', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + const statuses: string[] = [] + scope.subscribe(() => { statuses.push(scope.getSnapshot().status) }) + const stale = scope.load() + const fresh = scope.load() + await Promise.all([stale, fresh]) + expect(statuses).not.toContain('unavailable') + expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) + }) + + it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'light' }, 1)) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described({ preference: 'system' }, 2)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + expect(scope.getSnapshot().status).toBe('ready') + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } }) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 }) + }) + + it('applies a custom decode override in place of the wire schema', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'light' }, 1)) + .mockResolvedValueOnce(described({ preference: 'dark' }, 2)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { + namespace: 'ui-test', + decode: section => (section as UiTestSettings).preference === 'dark' + ? section as UiTestSettings + : undefined, + }, + ) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 }) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 }) + }) + + it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => { + const first = deferred>() + const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + await scope.load() + const dark = scope.set('preference', 'dark') + const light = scope.set('preference', 'light') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + first.resolve(ok(view({ preference: 'dark' }, 5))) + await Promise.all([dark, light]) + expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 }) + expect(mutate).toHaveBeenNthCalledWith(1, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + expectedRevision: 4, + }) + expect(mutate).toHaveBeenNthCalledWith(2, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'light' }], + expectedRevision: 5, + }) + }) + + it('recovers the latest rejected or thrown write from Host state', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 2)) + .mockResolvedValueOnce(described({ preference: 'light' }, 3)) + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + await scope.set('preference', 'dark') + await scope.set('preference', 'system') + expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) + }) + + it('does not recover superseded rejected or thrown writes', async () => { + const describeCall = vi.fn() + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + await Promise.all([ + scope.set('preference', 'dark'), + scope.set('preference', 'system'), + scope.set('preference', 'light'), + ]) + expect(describeCall).not.toHaveBeenCalled() + expect(published.map(section => section?.preference)).toEqual([undefined, 'light']) + }) + + it('keeps the write queue usable when a subscriber throws', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + let thrown = false + scope.subscribe(() => { + if (thrown) return + thrown = true + throw new Error('subscriber failed') + }) + await expect(scope.load()).rejects.toThrow('subscriber failed') + await expect(scope.load()).resolves.toBeUndefined() + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) + }) + + it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { + const first = deferred>() + const mutate = vi.fn().mockReturnValue(first.promise) + const describeCall = vi.fn() + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + const dark = scope.set('preference', 'dark') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + const light = scope.set('preference', 'light') + let stopped = false + const stop = scope.dispose().then(() => { stopped = true }) + await Promise.resolve() + expect(stopped).toBe(false) + first.resolve(ok(view({ preference: 'dark' }, 1))) + await Promise.all([dark, light, stop]) + await scope.set('preference', 'system') + await scope.load() + expect(mutate).toHaveBeenCalledOnce() + expect(describeCall).not.toHaveBeenCalled() + expect(published).toEqual([undefined]) + }) + + it('keeps a remote browser in memory mode without Host calls', async () => { + const describeCall = vi.fn() + const mutate = vi.fn() + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + 'memory', + ) + expect(scope.getSnapshot()).toEqual({ + status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory', + }) + await scope.load() + await scope.set('preference', 'dark') + await scope.dispose() + expect(describeCall).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) +}) + +describe('bindSettingsScope', () => { + it('subscribes before the initial read and converges to the latest queued invalidation', async () => { + const initial = deferred>() + const describeCall = vi.fn() + .mockReturnValueOnce(initial.promise) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + .mockResolvedValueOnce(described({ preference: 'system' }, 3)) + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe: describeCall } }, + isLoopback: true, + } as never) + let scope!: SettingsScope + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (plugin: Context) => { + scope = bindSettingsScope(plugin, { namespace: 'ui-test' }) + }, + }) + await fiber.await() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() }) + ctx.emit('settings/changed', 'unrelated') + ctx.emit('settings/changed', 'ui-test') + ctx.emit('connection/reset') + initial.resolve(described({ preference: 'dark' }, 1)) + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 }) + }) + await fiber.dispose() + ctx.emit('settings/changed', 'ui-test') + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(3) + }) + + it('binds a remote browser in memory mode without starting a settings read', async () => { + const describeCall = vi.fn() + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe: describeCall } }, + isLoopback: false, + } as never) + let scope!: SettingsScope + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (plugin: Context) => { + scope = bindSettingsScope(plugin, { namespace: 'ui-test' }) + }, + }) + await fiber.await() + expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false }) + await fiber.dispose() + expect(describeCall).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index f22a810522..7ccf95dd73 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../connection" }, + { + "path": "../schema-form" + }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/test-runtime/README.i18n.yaml b/packages/client/test-runtime/README.i18n.yaml index 7c67e91f5d..ee0488a20b 100644 --- a/packages/client/test-runtime/README.i18n.yaml +++ b/packages/client/test-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/client/test-runtime/README.md -README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e -README.zh.md: 1df28f7b25c35333e91476e10480c22a728cdab3 +README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0 +README.zh.md: 7c4bd0e552c71f55e3766a0c64580cc178461310 diff --git a/packages/client/test-runtime/README.md b/packages/client/test-runtime/README.md index 74da8fde7f..455d6f564c 100644 --- a/packages/client/test-runtime/README.md +++ b/packages/client/test-runtime/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic. -The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face. +The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`; `stubSettingsScope` is a `SettingsScope` with test-driven publications and a write spy), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face. Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `
` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis. diff --git a/packages/client/test-runtime/README.zh.md b/packages/client/test-runtime/README.zh.md index 1df28f7b25..7c4bd0e552 100644 --- a/packages/client/test-runtime/README.zh.md +++ b/packages/client/test-runtime/README.zh.md @@ -4,7 +4,7 @@ 面向客户端功能测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。功能套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。 -替身实现的正是功能通过 ctx 获得的对外接口(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。 +替身实现的正是功能通过 ctx 获得的对外接口(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`;`stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。 局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `
` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。 diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 0b8c732683..d1a82406ef 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -36,6 +36,8 @@ import type { Stabilizer } from './fixtures.ts' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' +export { stubSettingsScope } from './settings-scope.ts' +export type { StubSettingsScope } from './settings-scope.ts' export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' diff --git a/packages/client/test-runtime/src/settings-scope.ts b/packages/client/test-runtime/src/settings-scope.ts new file mode 100644 index 0000000000..c901221018 --- /dev/null +++ b/packages/client/test-runtime/src/settings-scope.ts @@ -0,0 +1,48 @@ +/** Test double for the client settings-scope seam. */ +import { vi } from 'vitest' +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' + +/** Handle over one stubbed scope: the scope, its write spy, and publication controls. */ +export interface StubSettingsScope { + /** The scope face handed to the service under test. */ + scope: SettingsScope + /** Spy behind `scope.set`; resolves immediately. */ + set: ReturnType + /** @returns how many listeners are currently subscribed (disposal assertions). */ + listenerCount(): number + /** + * Replace part of the snapshot and notify subscribers, as a Host + * acceptance would. + * @param next - snapshot fields to replace. + */ + publish(next: Partial>): void +} + +/** + * Build an in-memory settings scope for service specs: starts in the host + * loading state, records writes, and lets the test publish Host acceptances. + * @returns the stub handle. + */ +export function stubSettingsScope(): StubSettingsScope { + let snapshot: SettingsScopeSnapshot = { + status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', + } + const listeners = new Set<() => void>() + const set = vi.fn(() => Promise.resolve()) + return { + scope: { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + set, + }, + set, + listenerCount: () => listeners.size, + publish: (next) => { + snapshot = { ...snapshot, ...next } + for (const listener of [...listeners]) listener() + }, + } +} diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3170e8eada..4f28cf5627 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/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: 23dbb1a5492afefe9064d86429f21b926f594a53 -README.zh.md: 1d27beed9ca426096692b5710cc8d77396499449 +README.md: 7c4855a75abb982ff55b903808d6a65c42cbc91c +README.zh.md: c3a5d7beb2e90289f4340fc251fd3527370e3e23 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 23dbb1a549..7c4855a75a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -32,7 +32,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the Host-backed `ui-conversation.busyEnter` General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; the local settings provider stores it in `$DSH_HOME/settings.yaml`, so the choice follows the same user home across Web ports. Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1d27beed9c..c3a5d7beb2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,由 Host settings 支撑的 `ui-conversation.busyEnter` General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;本地 settings 提供方将其存入 `$DSH_HOME/settings.yaml`,因此该选择会跟随同一个用户 home 跨越 Web 端口。Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约定:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index ea4d48b42b..51aef9470b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", - "description": "Conversation domain: skeleton, ordered chat flow, composer, and details host", + "description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-layout" @@ -36,12 +37,15 @@ }, "license": "BSD-3-Clause", "dependencies": { - "clsx": "^2.0.0" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0", + "schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-attachment": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -60,6 +64,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7b0c9e8ad2..cad25cc84e 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,7 +1,9 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import { resolveWorkspacePath, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { + bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId, +} from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -33,6 +35,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' import { registerConversationNodes } from './conversation-nodes/register.ts' import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' +import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -43,7 +46,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by the conversation plugin. */ export const inject = [ - 'slots', 'layout', 'sessions', 'workspaces', 'locale', + 'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', 'conversationEvents', 'conversationViews', ] @@ -124,7 +127,9 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() - const submissionPolicy = new ComposerSubmissionPolicy() + const submissionPolicy = new ComposerSubmissionPolicy( + bindSettingsScope(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }), + ) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', diff --git a/packages/client/ui-conversation/src/client/contract/composer-submission.ts b/packages/client/ui-conversation/src/client/contract/composer-submission.ts index c5bcdc7826..23d9df94c1 100644 --- a/packages/client/ui-conversation/src/client/contract/composer-submission.ts +++ b/packages/client/ui-conversation/src/client/contract/composer-submission.ts @@ -1,10 +1,11 @@ /** Composer submission vocabulary shared by the input and settings domains. */ -/** Delivery mode requested for one ordinary composer message. */ -export type InputSubmitMode = 'queue' | 'steer' +import type { BusyEnterBehavior } from '../../submission-settings.ts' -/** Configurable meaning of plain Enter while the addressed agent is busy. */ -export type BusyEnterBehavior = InputSubmitMode +export type { BusyEnterBehavior } from '../../submission-settings.ts' + +/** Delivery mode requested for one ordinary composer message. */ +export type InputSubmitMode = BusyEnterBehavior /** Keyboard gesture whose delivery mode the submission policy resolves. */ export type ComposerSubmitGesture = 'enter' | 'accelerated' diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts index 6ef87e42c8..27b1cff326 100644 --- a/packages/client/ui-conversation/src/client/input/submission-policy.ts +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -1,27 +1,42 @@ /** - * Browser-local Composer submission policy. It owns the persisted busy-Enter + * Composer submission policy. It owns the live busy-Enter * preference and resolves keyboard gestures into queue/steer delivery modes; * Host and Agent keep the actual delivery-window authority. */ -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, type SettingsScope, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' import type { BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, } from '../contract/composer-submission.ts' +import { BUSY_ENTER_FIELD, DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' +import type { ConversationSettings } from '../../submission-settings.ts' -/** localStorage key holding the busy-Enter preference. */ -export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter' - -/** Default preserves Enter-as-Queue for running conversations. */ -export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' +export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' /** - * Persisted policy used by both the composer inject face and its Settings row. + * Busy-Enter policy used by both the composer inject face and its Settings row. * Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window * submission into the next waking Queue item. */ export class ComposerSubmissionPolicy { /** Reactive preference source for the Settings row. */ - readonly busyEnter: SnapshotStore = createSnapshotStore(restoreBusyEnter()) + readonly busyEnter: SnapshotStore = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR) + private readonly host: SettingsScope | undefined + + /** + * @param host - durable preference scope owned by the providing plugin; + * absent compositions stay process-local. The adoption subscription shares + * the scope's plugin lifetime — a disposed scope never publishes again, so + * the policy needs no release hook. + */ + constructor(host?: SettingsScope) { + this.host = host + if (host !== undefined) { + host.subscribe(() => { this.adopt(host) }) + this.adopt(host) + } + } /** * Resolve one keyboard gesture without changing state. @@ -42,36 +57,23 @@ export class ComposerSubmissionPolicy { } /** - * Change and persist the plain-Enter behavior used during busy state. + * Change the plain-Enter behavior used during busy state; the live value + * publishes before the durable write starts. * @param behavior - Queue or Steer. */ setBusyEnter(behavior: BusyEnterBehavior): void { if (this.busyEnter.getSnapshot() === behavior) return this.busyEnter.set(behavior) - persistBusyEnter(behavior) + void this.host?.set(BUSY_ENTER_FIELD, behavior) } -} -/** Restore a valid preference; unavailable or corrupt storage uses Queue. */ -function restoreBusyEnter(): BusyEnterBehavior { - if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR - let stored: string | null - try { - stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY) - } catch { - // Storage access can fail in privacy modes; the default remains usable. - return DEFAULT_BUSY_ENTER_BEHAVIOR - } - if (stored === 'queue' || stored === 'steer') return stored - return DEFAULT_BUSY_ENTER_BEHAVIOR -} - -/** Persist a preference when browser storage is available. */ -function persistBusyEnter(behavior: BusyEnterBehavior): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior) - } catch { - // A storage failure makes the preference session-only; input stays usable. + /** + * Adopt the scope's accepted durable behavior without writing it back. + * @param host - the constructor-narrowed scope driving this adoption. + */ + private adopt(host: SettingsScope): void { + const section = host.getSnapshot().value + if (section === undefined || this.busyEnter.getSnapshot() === section.busyEnter) return + this.busyEnter.set(section.busyEnter) } } diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index 142d3853e3..b49d7dcf0d 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,4 +1,23 @@ -/** Host loader entry for the browser-only conversation plugin. */ +/** Host registration for browser conversation preferences. */ -/** Provides no host-side behavior. */ -export function apply(): void {} +import type { Context } from 'cordis' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { CONVERSATION_SETTINGS_NAMESPACE, ConversationSettingsSchema } from './submission-settings.ts' + +export { + BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, + DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings, +} from './submission-settings.ts' + +/** + * Register the durable conversation section when a settings provider exists. + * @param ctx - Host context whose optional settings service owns the section. + */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE), + ConversationSettingsSchema, + ) + }) +} diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts new file mode 100644 index 0000000000..0bd42d33cf --- /dev/null +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -0,0 +1,29 @@ +/** Busy-Enter preference stored in the Host user-settings document. */ + +import z from 'schemastery' + +/** Settings namespace owned by the conversation plugin. */ +export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation' + +/** Field carrying the delivery mode for plain Enter while an agent is busy. */ +export const BUSY_ENTER_FIELD = 'busyEnter' + +/** Busy-Enter behaviors accepted at settings and input boundaries. */ +export const BUSY_ENTER_BEHAVIORS = ['queue', 'steer'] as const + +/** Configurable meaning of plain Enter while the addressed agent is busy. */ +export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number] + +/** Default preserves Enter-as-Queue for running conversations. */ +export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' + +/** Durable conversation section shared by the Host schema and the browser scope. */ +export interface ConversationSettings { + /** Delivery mode for plain Enter while the addressed agent is busy. */ + busyEnter: BusyEnterBehavior +} + +/** Durable conversation schema; also the wire envelope the browser scope validates against. */ +export const ConversationSettingsSchema: z = z.object({ + [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), +}) diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 49682fea52..c6eb5407f9 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -47,6 +47,7 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 9ea1c937ff..5df7a62698 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -50,6 +50,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -74,6 +75,7 @@ async function bench(opts?: { blank?: boolean }) { describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -90,6 +92,7 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -153,6 +156,7 @@ describe('resident composer', () => { describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 9a3e5e2908..7963e4b4f4 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -21,6 +21,7 @@ const CHILD = 'child-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false }) await runtime.sessions.add( { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 88e6c04141..ad644d940b 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom -// Branch tails the acceptance specs do not reach: the node-half empty apply -// and AssistantMarkdown reasoning/unknown block arms. +// Branch tails the acceptance specs do not reach: the node-half apply +// without a settings service and AssistantMarkdown reasoning/unknown block arms. import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' import { cleanup, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -16,8 +17,8 @@ const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) afterEach(cleanup) describe('tails', () => { - it('node-half apply is an intentional no-op', () => { - expect(() => { nodeApply() }).not.toThrow() + it('node-half apply tolerates a Host without settings', () => { + expect(() => { nodeApply(new Context()) }).not.toThrow() }) it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { diff --git a/packages/client/ui-conversation/tests/host.spec.ts b/packages/client/ui-conversation/tests/host.spec.ts new file mode 100644 index 0000000000..0d16a23da2 --- /dev/null +++ b/packages/client/ui-conversation/tests/host.spec.ts @@ -0,0 +1,30 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, apply, +} from '@deepseek-ai/dsh-client-ui-conversation' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-conversation host', () => { + it('registers, validates, and disposes the durable busy-Enter preference', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE) + expect(ctx.settings.get(ns)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) + await ctx.settings.update(ns, { busyEnter: 'steer' }) + expect(ctx.settings.get(ns)).toEqual({ busyEnter: 'steer' }) + await expect(ctx.settings.update(ns, { busyEnter: 'invalid' })).rejects.toThrow() + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) +}) diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.spec.ts index 5b892982ab..3117c39032 100644 --- a/packages/client/ui-conversation/tests/submission-policy.spec.ts +++ b/packages/client/ui-conversation/tests/submission-policy.spec.ts @@ -1,13 +1,10 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { - BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, + ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, } from '../src/client/input/submission-policy.ts' - -afterEach(() => { - vi.unstubAllGlobals() - localStorage.clear() -}) +import type { ConversationSettings } from '../src/submission-settings.ts' describe('ComposerSubmissionPolicy', () => { it('defaults to Queue and only applies the preference while running', () => { @@ -28,40 +25,42 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', true)).toBe('queue') expect(policy.resolve(false, 'enter', true)).toBe('queue') expect(policy.resolve(false, 'accelerated', true)).toBe('queue') - expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer') }) - it('restores a valid preference and leaves an identical write untouched', () => { - localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer') - const write = vi.spyOn(Storage.prototype, 'setItem') - const policy = new ComposerSubmissionPolicy() + it('writes an explicit change through the scope after publishing it locally', () => { + const host = stubSettingsScope() + const observed: string[] = [] + let liveBehavior = (): string => 'unconstructed' + const scope: typeof host.scope = { + ...host.scope, + set: (field, value) => { + observed.push(`${field}=${String(value)}:${liveBehavior()}`) + return host.scope.set(field, value) + }, + } + const policy = new ComposerSubmissionPolicy(scope) + liveBehavior = () => policy.busyEnter.getSnapshot() + policy.setBusyEnter('steer') + expect(observed).toEqual(['busyEnter=steer:steer']) + expect(host.set).toHaveBeenCalledWith('busyEnter', 'steer') + expect(host.set).toHaveBeenCalledOnce() + }) + + it('adopts a Host preference without writing it back and leaves an identical write untouched', () => { + const host = stubSettingsScope() + const policy = new ComposerSubmissionPolicy(host.scope) + host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true }) expect(policy.busyEnter.getSnapshot()).toBe('steer') policy.setBusyEnter('steer') - expect(write).not.toHaveBeenCalled() - write.mockRestore() + expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { busyEnter: 'steer' }, revision: 2 }) + expect(policy.busyEnter.getSnapshot()).toBe('steer') }) - it('uses Queue for invalid, unavailable, or unreadable storage', () => { - localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid') - expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') - - vi.stubGlobal('localStorage', undefined) - expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') - - vi.stubGlobal('localStorage', { - getItem: () => { throw new Error('blocked') }, - setItem: vi.fn(), - }) - expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') - }) - - it('keeps the in-memory preference when persistence throws', () => { - vi.stubGlobal('localStorage', { - getItem: () => null, - setItem: () => { throw new Error('quota') }, - }) - const policy = new ComposerSubmissionPolicy() - policy.setBusyEnter('steer') + it('adopts a section already standing at construction', () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true }) + const policy = new ComposerSubmissionPolicy(host.scope) expect(policy.busyEnter.getSnapshot()).toBe('steer') }) }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 115dee1f1e..49763e5fb8 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection" + }, { "path": "../ui-slots" }, @@ -65,6 +68,9 @@ { "path": "../locale" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 91b15cf361..75f422787a 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -340,6 +340,7 @@ describe('plugin registration', () => { name: 'root', children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index af85c1c5ae..024382fa79 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -22,9 +22,10 @@ beforeEach(() => { async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) - // Theme now injects ['slots', 'locale'] (it registers its Appearance - // settings row); seat a real locale service so the theme fiber activates. + // Theme registers its Appearance settings row and requires the connection + // seam for persistence; model this bench as a remote, memory-only browser. ctx.provide('locale', new LocaleService(ctx)) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() return { ctx, slots: ctx.get('slots') as SlotsService } diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 9796c68637..78d04489ab 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -20,7 +20,7 @@ import { import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' -import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { SubagentCatalogAction, type SubagentCatalogInjected, } from '../src/client/SubagentCatalogAction.tsx' @@ -86,8 +86,9 @@ async function fullBench(sessions: SessionSummary[]) { const face = sessionsWith(sessions) ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await provideSlotFaces(ctx) - await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() return { source: captured!, face, ctx } } @@ -121,8 +122,9 @@ describe('apply', () => { const ctx = new Context() await ctx.plugin(SlashService).await() ctx.provide('sessions', sessionsWith(FAMILY)) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await provideSlotFaces(ctx) - await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 7cf091e0f5..ac380c1e96 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 2d576cc16b0b8fef453ad54cacd9cb195dc75e7a -README.zh.md: b1f17c65749a94f1f48b5f7af9c8b18a5d85eb76 +README.md: 7b81728b02c0dadcbd134a4bca20c49721a3dd52 +README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 2d576cc16b..7b81728b02 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. `src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index b1f17c6574..81b64c3567 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。 +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 `src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 1adad710cc..a4f9a78d37 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -25,6 +25,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale" ], @@ -33,6 +34,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -64,6 +66,8 @@ "watch": "tsdown --watch" }, "dependencies": { - "clsx": "^2.0.0" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0", + "schemastery": "^3.18.0" } } diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index a0e04b67a6..e482f5ed2e 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -10,7 +10,7 @@ import { IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { ThemePreference } from './index.ts' +import type { ThemePreference } from '../theme-settings.ts' import type { ThemeKey } from './locales.ts' import type {} from './settings-contract.ts' import type { createAppearanceRowStore } from './settings-store.ts' diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index eb096412f5..8ef7f3fee7 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -1,24 +1,32 @@ /** * Browser theme registry over the `--dsw-*` token stylesheets. The service - * owns the theme preference (light/dark/system), resolves `system` through + * owns the live theme preference (light/dark/system), resolves `system` through * `prefers-color-scheme`, and publishes immutable snapshots; it never touches - * the DOM — ui-layout's presenter consumes the resolved snapshot. The plugin - * also registers the Appearance preference row into the settings General - * section — the theme feature owns its own settings surface. + * the DOM — ui-layout's presenter consumes the resolved snapshot. The Host + * settings scope loads and stores the preference in the user-settings + * document. The plugin also registers the Appearance preference row into the + * settings General section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { + bindSettingsScope, type ClientContext, type SettingsScope, +} from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' import { en, zh, type ThemeKey } from './locales.ts' +import { + DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + type ThemePreference, type ThemeSettings, +} from '../theme-settings.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' export type { ThemeKey } from './locales.ts' +export type { ThemePreference, ThemeSettings } from '../theme-settings.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' @@ -33,9 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record -/** Theme preference: a concrete theme id or follow-the-OS. */ -export type ThemePreference = 'light' | 'dark' | 'system' - /** One selectable theme: id, dark/light semantics, and alias-token overrides. */ export interface ThemeDefinition { /** Theme id (the setTheme argument for concrete themes). */ @@ -76,12 +81,6 @@ declare module 'cordis' { } } -/** localStorage key holding the persisted theme preference. */ -export const STORAGE_KEY = 'dsh.theme' - -/** Default preference when nothing (or garbage) is persisted. */ -export const DEFAULT_PREFERENCE: ThemePreference = 'system' - const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([ Object.freeze({ id: 'light', colorScheme: 'light' as const, tokens: Object.freeze({}) }), Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }), @@ -98,6 +97,7 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([ */ export class ThemeService { private readonly ctx: Context + private readonly host: SettingsScope private themes: ThemeDefinition[] = [...BUILTIN_THEMES] private preference: ThemePreference private revision = 0 @@ -106,11 +106,13 @@ export class ThemeService { /** * @param ctx - owning context (change events are emitted on it; the - * media-query listener is released through ctx.effect on dispose). + * media-query and scope listeners are released through ctx.effect on dispose). + * @param host - durable preference scope owned by the same plugin. */ - constructor(ctx: Context) { + constructor(ctx: Context, host: SettingsScope) { this.ctx = ctx - this.preference = restorePreference() + this.host = host + this.preference = DEFAULT_PREFERENCE // Non-browser runs (node e2e booting the client tree) have no matchMedia. this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)') this.snapshot = this.buildSnapshot() @@ -125,6 +127,8 @@ export class ThemeService { return () => { media.removeEventListener('change', onChange) } }, 'ui-theme: prefers-color-scheme listener') } + ctx.effect(() => host.subscribe(() => { this.adopt() }), 'ui-theme: settings scope adoption') + this.adopt() } /** @@ -136,8 +140,9 @@ export class ThemeService { } /** - * Switch the theme preference — the only preference write entry. Persists - * the preference and emits `theme/change`. + * Switch the theme preference — the only user preference write entry. + * Built-in preferences are written through the settings scope and every + * accepted value emits `theme/change`. * @param id - a registered theme id or `system`; unknown ids throw. */ setTheme(id: string): void { @@ -146,7 +151,15 @@ export class ThemeService { } if (this.preference === id) return this.preference = id as ThemePreference - persistPreference(this.preference) + if (isThemePreference(id)) void this.host.set(THEME_PREFERENCE_FIELD, id) + this.publish() + } + + /** Adopt the scope's accepted durable preference without writing it back. */ + private adopt(): void { + const section = this.host.getSnapshot().value + if (section === undefined || this.preference === section.preference) return + this.preference = section.preference this.publish() } @@ -170,7 +183,6 @@ export class ThemeService { this.themes = this.themes.filter(t => t.id !== definition.id) if (this.preference === definition.id) { this.preference = DEFAULT_PREFERENCE - persistPreference(this.preference) } this.publish() } @@ -200,32 +212,8 @@ export class ThemeService { } } -/** Read the persisted preference; unknown or unreadable values fall back to the default. */ -function restorePreference(): ThemePreference { - // Non-browser runs (node e2e booting the client tree) have no localStorage. - if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored === 'light' || stored === 'dark' || stored === 'system') return stored - } catch { - // Storage access can throw (privacy mode); the default below covers it. - } - return DEFAULT_PREFERENCE -} - -/** Persist the preference; storage failures are non-fatal (preference resets next boot). */ -function persistPreference(preference: ThemePreference): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(STORAGE_KEY, preference) - } catch { - // Storage access can throw (privacy mode / quota); the preference simply - // does not survive the session. - } -} - -/** Required services: slots + locale (the feature registers its own settings row with localized copy). */ -export const inject = ['slots', 'locale'] +/** Required services: settings transport plus slots/locale for the Appearance row. */ +export const inject = ['slots', 'locale', 'connection'] /** * Client plugin body: provide the theme service and register the @@ -234,7 +222,8 @@ export const inject = ['slots', 'locale'] * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { - const theme = new ThemeService(ctx) + const host = bindSettingsScope(ctx, { namespace: THEME_SETTINGS_NAMESPACE }) + const theme = new ThemeService(ctx, host) ctx.provide('theme', theme) ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts index 256b04a299..e4c76154e5 100644 --- a/packages/client/ui-theme/src/client/settings-store.ts +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -4,7 +4,7 @@ * reads via props.useStore. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' -import type { ThemePreference } from './index.ts' +import type { ThemePreference } from '../theme-settings.ts' /** Store state mirrored from the theme snapshot. */ export interface AppearanceRowState { diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 4777b0eb43..576028d37d 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -1,4 +1,23 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ +/** Host registration for the browser theme preference. */ -/** Host plugin body — no host-side behavior for the theme plugin. */ -export function apply(): void {} +import type { Context } from 'cordis' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts' + +export { + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, + type ThemePreference, type ThemeSettings, +} from './theme-settings.ts' + +/** + * Register the durable theme section when a settings provider exists. + * @param ctx - Host context whose optional settings service owns the section. + */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(THEME_SETTINGS_NAMESPACE), + ThemeSettingsSchema, + ) + }) +} diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index 4ec3296cd6..51667dc5a9 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -15,10 +15,10 @@ export const name = 'client-ui-theme-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the theme registry publishes immutable snapshots on - * its own `theme/change` event synchronously with the setter/registry - * mutation in the same service — snapshot/event agreement is asserted - * directly by this package's behavior specs. + * No runtime invariant: the settings scope validates and publishes the durable + * theme section, while the registry emits `theme/change` synchronously with + * its own mutations. Store/registry agreement is covered directly by this + * package's Host, scope, and service behavior specs. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts new file mode 100644 index 0000000000..d7fc966031 --- /dev/null +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -0,0 +1,38 @@ +/** Theme preferences stored in the Host user-settings document. */ + +import z from 'schemastery' + +/** Built-in preferences accepted at the registry and settings boundaries. */ +export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const + +/** Settings namespace owned by the theme plugin. */ +export const THEME_SETTINGS_NAMESPACE = 'ui-theme' + +/** Field carrying the selected built-in theme preference. */ +export const THEME_PREFERENCE_FIELD = 'preference' + +/** Theme preference persisted by the product Appearance row. */ +export type ThemePreference = typeof THEME_PREFERENCES[number] + +/** Default preference when the user-settings document has no override. */ +export const DEFAULT_PREFERENCE: ThemePreference = 'system' + +/** Durable theme section shared by the Host schema and the browser scope. */ +export interface ThemeSettings { + /** Selected built-in preference. */ + preference: ThemePreference +} + +/** Durable theme schema; also the wire envelope the browser scope validates against. */ +export const ThemeSettingsSchema: z = z.object({ + [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), +}) + +/** + * Narrow one wire or registry value to a persistable preference. + * @param value - value crossing the settings or registry boundary. + * @returns whether the value is a built-in preference. + */ +export function isThemePreference(value: unknown): value is ThemePreference { + return THEME_PREFERENCES.some(preference => preference === value) +} diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index a4da553516..25c2ac14df 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -2,12 +2,13 @@ * locale service, declaration-aware Appearance row registration, snapshot * projection into the row store, and HMR collapse recovery. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' @@ -17,12 +18,45 @@ usePinnedBrowserLanguages('zh-CN') const SLOT = 'settings.general.item' -async function bench() { +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) - return { ctx, slots: ctx.get('slots') as SlotsService, locale } + let preference = 'system' + const namespace = () => ({ + ns: THEME_SETTINGS_NAMESPACE, + schema: ThemeSettingsSchema.toJSON(), + value: { preference }, + applies: 'live' as const, + secrets: [], + revision: 0, + }) + const describe = vi.fn(() => Promise.resolve({ + rpcId: 'theme-describe' as never, + result: { + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [namespace()] }, + }, + })) + const mutate = vi.fn((request: { ops: { value: string }[] }) => { + preference = request.ops[0]!.value + return Promise.resolve({ + rpcId: 'theme-mutate' as never, + result: { ok: true as const, value: namespace() }, + }) + }) + ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) + return { + ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate, + setHostPreference: (next: string) => { preference = next }, + } } /** Stand in for the settings shell: declare the General item slot from root. */ @@ -45,7 +79,7 @@ function faceOf(slots: SlotsService) { describe('ui-theme apply', () => { it('declares the slot and locale services', () => { - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['slots', 'locale', 'connection']) }) it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => { @@ -84,6 +118,57 @@ describe('ui-theme apply', () => { face.setTheme('system') expect(theme.getTheme().preference).toBe('system') expect(instance.getSnapshot().preference).toBe('system') + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) + }) + + it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => { + const b = await bench() + b.setHostPreference('dark') + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + b.ctx.emit('settings/changed', 'unrelated') + expect(b.describe).toHaveBeenCalledOnce() + b.setHostPreference('light') + b.ctx.emit('settings/changed', THEME_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') }) + b.setHostPreference('dark') + b.ctx.emit('connection/reset') + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + + const remote = await bench(false) + declareItems(remote.slots) + await remote.ctx.plugin({ inject: [...inject], apply }).await() + const remoteTheme = remote.ctx.get('theme') as ThemeService + remoteTheme.setTheme('dark') + await Promise.resolve() + expect(remote.describe).not.toHaveBeenCalled() + expect(remote.mutate).not.toHaveBeenCalled() + }) + + it('activates before a slow initial settings read and converges when it settles', async () => { + const b = await bench() + b.setHostPreference('dark') + const describe = b.describe.getMockImplementation()! + const pending = deferred>>() + b.describe.mockImplementationOnce(() => pending.promise) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const theme = b.ctx.get('theme') as ThemeService + expect(theme.getTheme().preference).toBe('system') + pending.resolve(await describe()) + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + await fiber.dispose() + }) + + it('ignores an invalid preference crossing the settings wire', async () => { + const b = await bench() + b.setHostPreference('sepia') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() }) + expect(theme.getTheme().preference).toBe('system') }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/ui-theme/tests/host.spec.ts b/packages/client/ui-theme/tests/host.spec.ts new file mode 100644 index 0000000000..6cbbd91c27 --- /dev/null +++ b/packages/client/ui-theme/tests/host.spec.ts @@ -0,0 +1,30 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply, +} from '@deepseek-ai/dsh-client-ui-theme' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-theme host', () => { + it('registers, validates, and disposes the durable theme namespace with its fiber', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(THEME_SETTINGS_NAMESPACE) + expect(ctx.settings.get(ns)).toEqual({ preference: DEFAULT_PREFERENCE }) + await ctx.settings.update(ns, { preference: 'dark' }) + expect(ctx.settings.get(ns)).toEqual({ preference: 'dark' }) + await expect(ctx.settings.update(ns, { preference: 'sepia' })).rejects.toThrow() + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) +}) diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 640599ea43..c5eedc9dd7 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -4,7 +4,7 @@ import { Context } from 'cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' -import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client' +import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -15,18 +15,25 @@ describe('invariant companion', () => { await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined() }) - it('node-half apply is a no-op host placeholder', () => { - nodeApply() - expect(true).toBe(true) // reaching here without throw is the contract + it('node-half waits for an optional settings provider', () => { + nodeApply(new Context()) + expect(true).toBe(true) }) it('client apply provides ctx.theme over the slots/locale edges', async () => { // The feature registers its own Appearance settings row with localized // copy, hence the slots + locale edges. - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['slots', 'locale', 'connection']) const ctx = new Context() new SlotsService(ctx) - await ctx.plugin({ inject: ['slots'], apply: localeApply }).await() + ctx.provide('connection', { + api: { settings: { describe: () => Promise.resolve({ + rpcId: 'theme-invariant' as never, + result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } }, + }) } }, + isLoopback: true, + } as never) + await ctx.plugin({ inject: localeInject, apply: localeApply }).await() await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) }) diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index c853c9fd67..b7fc3bd17a 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,21 +1,23 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' -import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' -const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => { +const make = (host = stubSettingsScope()): { + ctx: Context + theme: ThemeService + events: ThemeSnapshot[] + host: StubSettingsScope +} => { const ctx = new Context() const events: ThemeSnapshot[] = [] ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) - return { ctx, theme: new ThemeService(ctx), events } + return { ctx, theme: new ThemeService(ctx, host.scope), events, host } } describe('ThemeService', () => { - beforeEach(() => { - localStorage.clear() - }) - it('defaults to the system preference resolved against prefers-color-scheme', () => { const { theme } = make() const snapshot = theme.getTheme() @@ -26,12 +28,12 @@ describe('ThemeService', () => { expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark']) }) - it('setTheme switches, persists, republishes, and keeps DOM untouched', () => { - const { theme, events } = make() + it('setTheme switches, writes through the scope, republishes, and keeps DOM untouched', () => { + const { theme, events, host } = make() theme.setTheme('dark') expect(theme.getTheme().preference).toBe('dark') expect(theme.getTheme().active.colorScheme).toBe('dark') - expect(localStorage.getItem(STORAGE_KEY)).toBe('dark') + expect(host.set).toHaveBeenCalledWith('preference', 'dark') expect(events).toHaveLength(1) expect(events[0]).toBe(theme.getTheme()) // The service never touches presentation state. @@ -39,13 +41,24 @@ describe('ThemeService', () => { // Same-value set is a no-op (no extra event). theme.setTheme('dark') expect(events).toHaveLength(1) + expect(host.set).toHaveBeenCalledOnce() }) - it('restores a persisted preference and falls back on garbage', () => { - localStorage.setItem(STORAGE_KEY, 'dark') - expect(make().theme.getTheme().preference).toBe('dark') - localStorage.setItem(STORAGE_KEY, 'sepia') - expect(make().theme.getTheme().preference).toBe('system') + it('adopts a published Host section without writing it back', () => { + const { theme, events, host } = make() + host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true }) + expect(theme.getTheme().preference).toBe('dark') + expect(events).toHaveLength(1) + expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { preference: 'dark' }, revision: 2 }) + expect(events).toHaveLength(1) + }) + + it('adopts a section already standing at construction', () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true }) + const { theme } = make(host) + expect(theme.getTheme().preference).toBe('dark') }) it('throws on unknown setTheme ids, duplicate registration, and the system id', () => { @@ -56,7 +69,7 @@ describe('ThemeService', () => { }) it('registered themes join the snapshot; disposing the active one resets to default', () => { - const { theme, events } = make() + const { theme, events, host } = make() const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } }) expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia']) theme.setTheme('sepia') @@ -64,7 +77,9 @@ describe('ThemeService', () => { dispose() expect(theme.getTheme().preference).toBe('system') expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) - expect(localStorage.getItem(STORAGE_KEY)).toBe('system') + // Custom ids are in-process extension themes; only the built-in product + // preferences cross the Host settings schema. + expect(host.set).not.toHaveBeenCalled() // register + set + dispose = three publishes; disposer is idempotent. expect(events.length).toBe(3) dispose() @@ -88,16 +103,11 @@ describe('ThemeService', () => { expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) - it('runs without localStorage (node boots): defaults on read, no-op on write', () => { - vi.stubGlobal('localStorage', undefined) - try { - const { theme } = make() - expect(theme.getTheme().preference).toBe('system') - theme.setTheme('dark') - expect(theme.getTheme().preference).toBe('dark') - } finally { - vi.unstubAllGlobals() - } + it('context dispose releases the scope subscription', async () => { + const { ctx, host } = make() + expect(host.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(host.listenerCount()).toBe(0) }) describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 7d5cc6f235..f3ca3240c2 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx index 8f4fb384b0..e006b106a8 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx @@ -68,6 +68,7 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 3b70851a02..df5dcf6718 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -155,6 +155,7 @@ async function bench(snapshot: ConversationSnapshot) { } ctx.provide('workspaces', workspaces) ctx.provide('layout', layout) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) const locale = new LocaleService(ctx) ctx.provide('locale', locale) slots.installLocale(locale) diff --git a/packages/client/ui-tool/tests/toolview-slot.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.spec.tsx index f1a9df3392..cab91c6457 100644 --- a/packages/client/ui-tool/tests/toolview-slot.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.spec.tsx @@ -64,6 +64,7 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.provide('layout', layout) const locale = new LocaleService(runtime.ctx) @@ -196,6 +197,7 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index c30ee60ec2..e4ee0922d6 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 64f6ae7bcd92735f821c8f8d2b3b93203dbac17e -README.zh.md: 680fcee730674a21b5c2407247ce46b1a01cf6f3 +README.md: 03726c6671ec711704870d23722d83c72d9c4d35 +README.zh.md: f001866af2015671ed6429b392e3f880000e6c38 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 64f6ae7bcd..03726c6671 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -52,7 +52,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 680fcee730..f001866af2 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -52,7 +52,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 5576f2e75c..cb59deecbe 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -93,7 +93,7 @@ import { canOpenNativePath, openNativePath, openNativeTextFile } from './native- const DEFAULT_MAX_MESSAGES = 50 /** Non-model settings namespaces intentionally served to the Web client. */ -const WEB_SETTINGS_NAMESPACES = ['permission'] as const +const WEB_SETTINGS_NAMESPACES = ['locale', 'permission', 'ui-conversation', 'ui-theme'] as const /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 15b0092107..b8b067cae3 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -309,8 +309,8 @@ describe('settings domain', () => { // The settings seam is general: any plugin may register a namespace for // its own configuration. The Web configuration plane remains opt-in, so a // future internal plugin cannot become remotely configurable just by - // registering; permission and the product onboarding namespace are the - // non-model namespaces intentionally admitted by this surface. + // registering; locale, permission, conversation, theme, and the product + // onboarding namespace are intentionally admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) @@ -319,15 +319,41 @@ describe('settings domain', () => { }), { base: { defaultPreset: 'read-only' }, }) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + preference: z.union(['light', 'dark', 'system']).default('system'), + })) + ctx.settings.register(settingsNamespace('locale'), z.object({ + preference: z.union(['zh', 'en']).required(false), + })) + ctx.settings.register(settingsNamespace('ui-conversation'), z.object({ + busyEnter: z.union(['queue', 'steer']).default('queue'), + })) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission']) + expect(value.namespaces.map(view => view.ns)).toEqual([ + 'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation', + ]) const permission = expectOk(await api.settings.mutate(request({ ns: 'permission', ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], }))) expect(permission.value).toEqual({ defaultPreset: 'workspace-write' }) + const theme = expectOk(await api.settings.mutate(request({ + ns: 'ui-theme', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + }))) + expect(theme.value).toEqual({ preference: 'dark' }) + const locale = expectOk(await api.settings.mutate(request({ + ns: 'locale', + ops: [{ op: 'set', path: ['preference'], value: 'en' }], + }))) + expect(locale.value).toEqual({ preference: 'en' }) + const conversation = expectOk(await api.settings.mutate(request({ + ns: 'ui-conversation', + ops: [{ op: 'set', path: ['busyEnter'], value: 'steer' }], + }))) + expect(conversation.value).toEqual({ busyEnter: 'steer' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), @@ -341,19 +367,29 @@ describe('settings domain', () => { expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({}) }) - it('serves the product onboarding namespace without invalidating the model catalog', async () => { + it('serves product preference namespaces without invalidating the model catalog', async () => { const ctx = await harness() ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() })) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + preference: z.union(['light', 'dark', 'system']).default('system'), + })) const api = createApiProxy(ctx, DEFAULTS) expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) - .toEqual(['ui-onboarding']) - const frames = await collectHost(api, ['host/settings-changed'], 1, async () => { + .toEqual(['ui-onboarding', 'ui-theme']) + const frames = await collectHost(api, ['host/settings-changed'], 2, async () => { expectOk(await api.settings.mutate(request({ ns: 'ui-onboarding', ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }], }))) + expectOk(await api.settings.mutate(request({ + ns: 'ui-theme', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + }))) }) - expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }]) + expect(frames).toEqual([ + { type: 'host/settings-changed', ns: 'ui-onboarding' }, + { type: 'host/settings-changed', ns: 'ui-theme' }, + ]) }) it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4912b264d7..0ff65ff8cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1651,6 +1651,16 @@ importers: version: link:../../../vendor/cordis packages/client/locale: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: ^0.0.1 + version: link:../connection + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -1700,6 +1710,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -1758,6 +1771,9 @@ importers: cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery packages/client/schema-form: dependencies: @@ -1902,9 +1918,15 @@ importers: packages/client/ui-conversation: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings clsx: specifier: ^2.0.0 version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -1915,6 +1937,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2637,9 +2662,18 @@ importers: packages/client/ui-theme: dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: ^0.0.1 + version: link:../connection + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings clsx: specifier: ^2.0.0 version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e8055db866..3b88da217f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -599,6 +599,7 @@ function docSyncLeafGates(options: { pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }), pnpmScript('archived-agent-notes', 'verify-archived-agent-notes', { label: 'archived agent notes' }), pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }), + pnpmScript('skill-invocation-metadata', 'verify-skill-invocation-metadata', { label: 'skill invocation metadata' }), pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 7f2c71353e..63050b2079 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,27 +24,27 @@ }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write `), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [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.\n- `.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.\n\n**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.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [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.\n- `.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.\n\n**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.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write `),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", - "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences. Then verify against the source clause by clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" + "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" }, { "role": "assistant", - "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效;应用这些规则的仓库内置 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是逐句转写的译者。写完后逐句对照原文核验:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体仍需人工核对。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" + "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" }, { "role": "user", - "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" + "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" }, { "role": "assistant", - "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" + "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" }, { "role": "user", diff --git a/scripts/verify-skill-invocation-metadata.spec.ts b/scripts/verify-skill-invocation-metadata.spec.ts new file mode 100644 index 0000000000..88dbfed84e --- /dev/null +++ b/scripts/verify-skill-invocation-metadata.spec.ts @@ -0,0 +1,53 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectSkillInvocationMetadataViolations } from './verify-skill-invocation-metadata.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixtureRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-skill-invocation-metadata-')) + roots.push(root) + return root +} + +function writeSkill(root: string, name: string, frontmatter: string, policy = ''): void { + const directory = join(root, '.agents/skills', name) + mkdirSync(join(directory, 'agents'), { recursive: true }) + writeFileSync(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Test skill\n${frontmatter}---\n\nTest.\n`) + writeFileSync( + join(directory, 'agents/openai.yaml'), + `interface:\n display_name: "Test"\n${policy}`, + ) +} + +describe('cross-product skill invocation metadata gate', () => { + it('accepts aligned default and manual-only policies', () => { + const root = fixtureRoot() + writeSkill(root, 'default-skill', '') + writeSkill( + root, + 'manual-skill', + 'disable-model-invocation: true\nuser-invocable: true\n', + 'policy:\n allow_implicit_invocation: false\n', + ) + + expect(collectSkillInvocationMetadataViolations(root)).toEqual([]) + }) + + it('rejects either direction of a manual-only policy mismatch', () => { + const root = fixtureRoot() + writeSkill(root, 'claude-only', 'disable-model-invocation: true\n') + writeSkill(root, 'codex-only', '', 'policy:\n allow_implicit_invocation: false\n') + + expect(collectSkillInvocationMetadataViolations(root)).toEqual([ + '.agents/skills/claude-only: Claude Code manual-only=true but Codex manual-only=false', + '.agents/skills/codex-only: Claude Code manual-only=false but Codex manual-only=true', + ]) + }) +}) diff --git a/scripts/verify-skill-invocation-metadata.ts b/scripts/verify-skill-invocation-metadata.ts new file mode 100644 index 0000000000..f7e7712c6e --- /dev/null +++ b/scripts/verify-skill-invocation-metadata.ts @@ -0,0 +1,122 @@ +/** + * Keep Claude Code and Codex invocation metadata aligned for repository skills. + * @module scripts/verify-skill-invocation-metadata + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { load } from 'js-yaml' + +const ROOT = resolve(import.meta.dirname, '..') + +/** Return an object-shaped YAML value, or undefined for every other shape. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +/** Parse a skill's YAML frontmatter as an object. */ +function parseSkillFrontmatter(source: string): Record { + const lines = source.split('\n') + if (lines[0] !== '---') throw new Error('SKILL.md must start with YAML frontmatter') + const end = lines.indexOf('---', 1) + if (end < 0) throw new Error('SKILL.md frontmatter is not closed') + const metadata = asRecord(load(lines.slice(1, end).join('\n'))) + if (metadata === undefined) throw new Error('SKILL.md frontmatter must be a YAML object') + return metadata +} + +/** Find repository skill directories that carry Codex product metadata. */ +function skillDirectories(root: string): string[] { + const skillsRoot = resolve(root, '.agents/skills') + if (!existsSync(skillsRoot)) return [] + return readdirSync(skillsRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && existsSync(resolve(skillsRoot, entry.name, 'agents/openai.yaml'))) + .map(entry => entry.name) + .sort() +} + +/** + * Report cross-product invocation-policy mismatches for repository skills. + * @param root - Repository root containing `.agents/skills`. + * @returns diagnostics for malformed metadata or policies that expose a skill differently. + */ +export function collectSkillInvocationMetadataViolations(root: string): string[] { + const violations: string[] = [] + + for (const skill of skillDirectories(root)) { + const relativeRoot = `.agents/skills/${skill}` + const skillFile = resolve(root, relativeRoot, 'SKILL.md') + const openaiFile = resolve(root, relativeRoot, 'agents/openai.yaml') + if (!existsSync(skillFile)) { + violations.push(`${relativeRoot}: agents/openai.yaml has no sibling SKILL.md`) + continue + } + + let frontmatter: Record + let openai: Record + try { + frontmatter = parseSkillFrontmatter(readFileSync(skillFile, 'utf8')) + } + catch (error) { + violations.push(`${relativeRoot}/SKILL.md: ${error instanceof Error ? error.message : String(error)}`) + continue + } + try { + const parsed = asRecord(load(readFileSync(openaiFile, 'utf8'))) + if (parsed === undefined) throw new Error('agents/openai.yaml must be a YAML object') + openai = parsed + } + catch (error) { + violations.push(`${relativeRoot}/agents/openai.yaml: ${error instanceof Error ? error.message : String(error)}`) + continue + } + + const disableModelInvocation = frontmatter['disable-model-invocation'] + if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') { + violations.push(`${relativeRoot}/SKILL.md: disable-model-invocation must be a boolean`) + continue + } + const userInvocable = frontmatter['user-invocable'] + if (userInvocable !== undefined && typeof userInvocable !== 'boolean') { + violations.push(`${relativeRoot}/SKILL.md: user-invocable must be a boolean`) + continue + } + + const policy = asRecord(openai.policy) + const allowImplicitInvocation = policy?.allow_implicit_invocation + if (allowImplicitInvocation !== undefined && typeof allowImplicitInvocation !== 'boolean') { + violations.push(`${relativeRoot}/agents/openai.yaml: policy.allow_implicit_invocation must be a boolean`) + continue + } + + const claudeManualOnly = disableModelInvocation === true + const codexManualOnly = allowImplicitInvocation === false + if (claudeManualOnly !== codexManualOnly) { + violations.push( + `${relativeRoot}: Claude Code manual-only=${String(claudeManualOnly)}` + + ` but Codex manual-only=${String(codexManualOnly)}`, + ) + } + if (claudeManualOnly && userInvocable === false) { + violations.push(`${relativeRoot}/SKILL.md: a manual-only skill must remain user-invocable`) + } + } + + return violations +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + const skills = skillDirectories(ROOT) + const violations = collectSkillInvocationMetadataViolations(ROOT) + if (violations.length > 0) { + process.stderr.write('verify-skill-invocation-metadata: violations found:\n') + for (const violation of violations) process.stderr.write(` ${violation}\n`) + process.exit(1) + } + + process.stdout.write( + `verify-skill-invocation-metadata: ${String(skills.length)} cross-product skill policy pair(s) aligned.\n`, + ) +} diff --git a/vitest.config.ts b/vitest.config.ts index 68bfafec8d..75c1e64941 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -162,8 +162,13 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', 'packages/client/web-react/src/*', - 'packages/client/runtime/src/*', - 'packages/client/ui-conversation/src/*', + // This isolated settings-scope lifecycle has complete unit coverage; + // keep it out of the broader client-runtime GUI debt exemption. + 'packages/client/runtime/src/**/!(settings-scope).ts', + // Keep the browser conversation tree under its existing GUI debt + // exemption while gating the newly stateful Host half and vocabulary. + 'packages/client/ui-conversation/src/client/*', + 'packages/client/ui-conversation/src/invariant.ts', 'packages/client/ui-primitives/src/DisclosureRow.tsx', 'packages/client/ui-tool/src/*', 'packages/client/ui-slots/src/*',