Merge remote-tracking branch 'origin/master' into worktree/pr343-retarget-latest-master

# Conflicts:
#	docs/i18n/README.i18n.yaml
#	docs/i18n/README.zh.md
This commit is contained in:
Tianyi Cui committed 2026-07-23 23:43:08 +08:00
commit e6bd126ef3
360 files changed
+12190 -1335

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process'
// Each UI's node invocation matches its base demo script plus the overlay config.
const UIS = new Map([
['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
['tui', ['--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
])
@@ -0,0 +1,23 @@
<translation>
---
layout: doc
---
# 快照说明
agent(智能体)执行一个步骤。
</translation>
<review>
- 无修正
</review>
<final>
---
layout: doc
---
# 快照说明
agent(智能体)执行一个步骤。
</final>
@@ -0,0 +1,7 @@
---
layout: doc
---
# Snapshot note
The agent performs one step.
+10
View File
@@ -94,6 +94,7 @@ export const LINK_MAP: Record<string, string> = {
CreateSessionOptions: 'persistence.md',
SessionHeader: 'persistence.md',
SessionLocation: 'persistence.md',
SessionPersistenceSnapshot: 'persistence.md',
ConfinedArgv: 'sandbox.md',
SandboxExecutionPolicy: 'sandbox.md',
SandboxMode: 'sandbox.md',
@@ -120,11 +121,20 @@ export const LINK_MAP: Record<string, string> = {
TurnTrigger: 'session.md',
SessionEventReadRequest: 'session-query.md',
SessionEventRecord: 'session-query.md',
SessionEventResultFilter: 'session-query.md',
SessionEventSearchDocument: 'session-query.md',
SessionEventSearchHit: 'session-query.md',
SessionEventSearchRequest: 'session-query.md',
SessionEventTrace: 'session-query.md',
SessionEventTraceRequest: 'session-query.md',
SessionEventWindow: 'session-query.md',
SessionLineageTrace: 'session-query.md',
SessionRecord: 'session-query.md',
SessionResultFilter: 'session-query.md',
SessionSearchExecContext: 'session-query.md',
SessionSearchHit: 'session-query.md',
SessionSearchPage: 'session-query.md',
SessionSearchRequest: 'session-query.md',
SessionTitleProvider: 'session-title.md',
SessionTitleSnapshot: 'session-title.md',
SkillDefinition: 'skills.md',
+17 -7
View File
@@ -112,7 +112,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'session',
title: 'In-memory session store',
mode: 'core',
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess'],
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'],
note: 'Owns append-only Session instances and emits the durable session event feed.',
},
{
@@ -129,16 +129,17 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Durable session persistence seam',
mode: 'seam',
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query'],
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query', 'session-query-sqlite'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
{
key: 'sessionQuery',
pkg: 'session-query',
title: 'Exact session-history reads and traces',
title: 'Session reads, traces, filters, and search',
mode: 'seam',
implementations: ['session-query-sqlite'],
consumers: ['session-reference'],
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces.',
note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service.',
},
{
key: 'sessionReferences',
@@ -978,7 +979,7 @@ function renderToolPipeline(): string {
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
return [
...generatedHeader('Tool Execution Pipeline'),
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them.',
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards, definition-owned `finalizeContent`, and `tools/result` are the owner-enforced boundaries around them.',
'',
'```mermaid',
'flowchart TD',
@@ -994,6 +995,8 @@ function renderToolPipeline(): string {
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
' normalized["Registry outer normalization<br/>pipeline/result snapshot throws become isError"]',
' finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]',
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
' context["Active-batch additionalContexts FIFO<br/>context/message after recorded tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
@@ -1005,25 +1008,32 @@ function renderToolPipeline(): string {
' pre -->|allow| guards',
' guards -->|allow| around',
' guards -->|deny| denied',
' guards -.->|throw| normalized',
' around --> toolBody',
' pre -->|deny| denied',
' pre -->|ask| approval',
' approval -->|allowed-once| guards',
' approval -->|rejected, cancelled, unavailable| denied',
' approval -.->|throw| normalized',
' denied --> post',
' pre -.->|throw| normalized',
' toolBody --> fsGate',
' fsGate --> toolBody',
' toolBody --> owned',
' toolBody --> around',
' around --> post',
' post --> final',
' around -.->|wrapper throws| normalized',
' post -.->|throw| normalized',
' post --> finalize',
' normalized --> finalize',
' finalize --> final',
' final --> toolResult',
' toolResult --> presentResult',
' toolResult --> allResults',
' allResults --> context',
'```',
'',
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
'',
...maintenanceFooter(maintenance),
].join('\n')
+3 -3
View File
@@ -379,9 +379,9 @@ function coverageGate(): Gate {
})
}
// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node,
// plugins via real exports) — CI and check-all already build, so they exercise what ships rather
// than the tsx/source path dev uses. It therefore waits on `build`.
// Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node,
// plugins via real exports); repository-script snapshots execute their real source entry path.
// CI and check-all already build before either class runs, so the suite waits on `build`.
function snapshotGate(): Gate {
return pnpmScript('snapshot', 'test:snapshot', {
env: { DSH_EXAMPLE_MODE: 'lib' },
@@ -0,0 +1,60 @@
{
"request": {
"targetFilename": "snapshot-note.zh.md",
"messages": [
{
"role": "system",
"content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the establisheLine truncated
},
{
"role": "user",
"content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer clones DeepSeek Harness to `~/.dsh/source`, links `dsh` into `~/.local/bin`, and launches it. Re-running the command updates the checkout. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the frontend after installation and after each update, then start the Web UI:\n\n```sh\npnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n"
},
{
"role": "assistant",
"content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会将 DeepSeek Harness 克隆到 `~/.dsh/source`,把 `dsh` 链接到 `~/.local/bin`,然后启动它。再次运行该命令会更新源码目录。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI:\n\n```sh\npnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n"
},
{
"role": "user",
"content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git.\n- Optional: a DeepSeek API key for the TUI/Headless/ACP agent demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\npnpm exec lefthook install --force\n```\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the Line truncated
},
{
"role": "assistant",
"content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git。\n- 可选:一个 DeepSeek API key,用于 TUI/Headless/ACP(Agent Client Protocol) agent(智能体)演示和真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\npnpm exec lefthook install --force\n```\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 eLine truncated
},
{
"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 the README, Agent Notes, and docs tree are maintained in English and Simplified Chinese. This page defines the pairing contract, the enforcement gate, and the rollout policy; [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. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p <hash>`), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. 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.\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\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 file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair.\n2. Every pair that exists at all — required or not — 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), both sides carry the language switcher, 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.\n4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new date-named Agent Notes merge bilingual from birth.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok — and is the work list for translation batches. It never fails; it reports.\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 shape; 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 passesLine truncated
},
{
"role": "assistant",
"content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README、Agent Note(agent 决策记录)与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[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`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」,从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.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. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。\n2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。\n4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok),是翻译批次的工作清单。它从不失败;它只报告。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围、排除与推进\n\n**范围**:根 `README.md`,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部内容。包(package)README(Line truncated
},
{
"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.\nLine truncated
},
{
"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- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语Line truncated
},
{
"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 README and docs tree are 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](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`) 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: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows.\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## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: 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 counterparLine truncated
},
{
"role": "assistant",
"content": "# Agent Note:通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](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`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。\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- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.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`Line truncated
},
{
"role": "user",
"content": "---\nlayout: doc\n---\n\n# Snapshot note\n\nThe agent performs one step.\n"
}
]
},
"response": {
"translation": "---\nlayout: doc\n---\n\n# 快照说明\n\nagent(智能体)执行一个步骤。",
"review": "- 无修正",
"final": "---\nlayout: doc\n---\n\n# 快照说明\n\n[English](snapshot-note.md) | 中文\n\nagent(智能体)执行一个步骤。\n"
}
}
+32
View File
@@ -0,0 +1,32 @@
/** Runnable keyless snapshot for the assembled translation request and consumed response. */
import { execFile } from 'node:child_process'
import { access, mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
const execFileAsync = promisify(execFile)
const root = resolve(import.meta.dirname, '..')
const expected = join(root, 'scripts/snapshots/translation-prompt-v4/request-response.expected.json')
const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
describe('translation prompt runnable snapshot', () => {
it('assembles the reviewed examples and consumes a recorded new-pair response', async () => {
const { stdout, stderr } = await execFileAsync(process.execPath, [
join(root, 'scripts/verify-translation-prompt.ts'),
'--snapshot',
], { cwd: root, maxBuffer: 4 * 1024 * 1024 })
expect(stderr).toBe('')
expect(() => {
JSON.parse(stdout)
}).not.toThrow()
if (refreshing) {
await mkdir(dirname(expected), { recursive: true })
await writeFile(expected, stdout)
} else {
await access(expected)
}
await expect(stdout).toMatchFileSnapshot(expected)
})
})
+177 -53
View File
@@ -1,76 +1,200 @@
/** Regression tests for the executable translation prompt contract. */
/** Unit tests for the prompt-v4 renderer and three-section response parser. */
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
consumeTranslationResponse,
parseTranslationResponse,
renderTranslationPrompt,
renderTranslationRequest,
renderTranslationResponse,
} from './translation-prompt.ts'
const document = `# Wrapper
## 模板正文
\`\`\`\`text
{{source_lang}} to {{target_lang}}
{{translation_rules}}
{{terminology}}
[English]({{source_filename}}) | [中文]({{source_filename_zh}})
\`\`\`\`
`
const root = resolve(import.meta.dirname, '..')
const document = readFileSync(join(root, 'docs/i18n/translation-prompt.md'), 'utf8')
const terminology = '| English | 中文 |\n|---|---|\n| agent | agent |'
describe('translation prompt rendering', () => {
it('renders every supported placeholder without recursively rewriting injected rules', () => {
const rendered = renderTranslationPrompt(document, {
it('renders both directions with every placeholder resolved', () => {
const en = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })
expect(en).toContain('from English to Chinese')
expect(en).toContain(terminology)
expect(en).not.toContain('{{')
expect(en).toContain('plain source stays plain (必须)')
expect(en).toContain('When the target language is English, use the "English" column without a Chinese gloss')
expect(en).toContain('for a Chinese target, use an established Chinese rendering')
expect(en).toContain('for an English target, use the established English technical term')
expect(en).toContain('does an English target use established English terminology')
expect(en).toContain('The parser removes exactly one framing escape')
const zh = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', sourceFilename: 'guide.zh.md', terminology })
expect(zh).toContain('from Chinese to English')
})
it('rejects a template with unknown or missing placeholders', () => {
const alien = document.replaceAll('{{terminology}}', '{{terms_prompt}}')
expect(() => renderTranslationPrompt(alien, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/unsupported placeholder/)
const missing = document.replaceAll('{{terminology}}', '')
expect(() => renderTranslationPrompt(missing, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/required placeholder/)
})
it('rejects unmatched placeholder delimiters', () => {
for (const delimiter of ['{{', '}}']) {
const malformed = document.replace('Your task is to translate', `Your task ${delimiter} is to translate`)
expect(() => renderTranslationPrompt(malformed, {
sourceLanguage: 'English',
sourceFilename: 'guide.md',
terminology,
})).toThrow(/malformed placeholder syntax/)
}
})
it('assembles bare few-shot turns before the real source document', () => {
const request = renderTranslationRequest(document, {
sourceLanguage: 'English',
sourceFilename: 'guide.md',
translationRules: 'A literal {{source_lang}} in injected rules.',
terminology: '| English | 中文 |',
sourceDocument: '# Guide\n\nNew source.',
terminology,
examples: [{ english: '# Example\n\nEnglish.', chinese: '# 示例\n\n中文。' }],
})
expect(rendered).toContain('English to Chinese')
expect(rendered).toContain('A literal {{source_lang}} in injected rules.')
expect(rendered).toContain('[English](guide.md) | [中文](guide.zh.md)')
})
expect(request.targetFilename).toBe('guide.zh.md')
expect(request.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant', 'user'])
expect(request.messages.slice(1).map(message => message.content)).toEqual([
'# Example\n\nEnglish.',
'# 示例\n\n中文。',
'# Guide\n\nNew source.',
])
it('rejects a filename whose suffix contradicts the source language', () => {
expect(() => renderTranslationPrompt(document, {
const reverse = renderTranslationRequest(document, {
sourceLanguage: 'Chinese',
sourceFilename: 'guide.md',
translationRules: 'rules',
terminology: 'terms',
})).toThrow('does not match source language Chinese')
})
it('rejects malformed template placeholders before injecting rule contents', () => {
expect(() => renderTranslationPrompt(document.replace('{{source_lang}}', '{{source-lang}}'), {
sourceLanguage: 'English',
sourceFilename: 'guide.md',
translationRules: 'A literal {{source_lang}} in injected rules.',
terminology: '| English | 中文 |',
})).toThrow('template contains malformed placeholder syntax')
sourceFilename: 'guide.zh.md',
sourceDocument: '# 指南\n\n新源文。',
terminology,
examples: [{ english: '# Example\n\nEnglish.', chinese: '# 示例\n\n中文。' }],
})
expect(reverse.targetFilename).toBe('guide.md')
expect(reverse.messages.slice(1).map(message => message.content)).toEqual([
'# 示例\n\n中文。',
'# Example\n\nEnglish.',
'# 指南\n\n新源文。',
])
})
})
describe('translation response XML', () => {
it('round-trips Markdown and the CDATA terminator', () => {
const response = {
translation: '# Draft\n\nA ]]> marker.',
review: '- [Tone] Fixed.',
final: '# Final\n\nA ]]> marker.',
}
describe('translation response sections', () => {
it('round-trips Markdown bodies', () => {
const response = { translation: '# 标题\n\n正文 **加粗**。', review: '- [Tone] 修正一处。\n- 无修正', final: '# 标题\n\n定稿。' }
expect(parseTranslationResponse(renderTranslationResponse(response))).toEqual(response)
})
it('rejects missing, reordered, nested, attributed, or non-CDATA children', () => {
expect(() => parseTranslationResponse('<dsh-translation-response version="1"/>')).toThrow('translation, review, and final')
expect(() => parseTranslationResponse('<dsh-translation-response version="1"><review><![CDATA[x]]></review></dsh-translation-response>'))
.toThrow('expected translation, got review')
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' })
.replace('<translation><![CDATA[x]]></translation>', '<translation><b><![CDATA[x]]></b></translation>')))
.toThrow('nested element b is not allowed')
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<review>', '<review lang="en">')))
.toThrow('review must not have attributes')
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<![CDATA[x]]>', 'x')))
.toThrow('all response field content must be inside CDATA')
it('tolerates a fenced xml wrapper around the whole response', () => {
const fenced = '```xml\n<translation>\nA\n</translation>\n\n<review>\n- 无修正\n</review>\n\n<final>\nA\n</final>\n```'
expect(parseTranslationResponse(fenced).final).toBe('A')
})
it('keeps an inline close tag inside prose from terminating the section', () => {
const doc = { translation: 'the wire format uses </translation> as its close tag', review: '- 无修正', final: 'F' }
expect(parseTranslationResponse(renderTranslationResponse(doc))).toEqual(doc)
})
it('round-trips wrapper-tag lines inside Markdown bodies', () => {
const doc = {
translation: '```xml\n</translation>\n```',
review: '- [Structure] Preserved `<final>` on its own line.',
final: 'literal delimiters\n</final>\n\\</final>',
}
const rendered = renderTranslationResponse(doc)
expect(parseTranslationResponse(rendered)).toEqual(doc)
expect(() => parseTranslationResponse(rendered.replace('\\</translation>', '</translation>'))).toThrow(/duplicate <translation>/)
})
it('rejects a duplicate section appearing before final', () => {
const early = '<translation>\nA\n</translation>\n<translation>\nB\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>'
expect(() => parseTranslationResponse(early)).toThrow(/duplicate <translation>/)
})
it('rejects missing, unterminated, or duplicated sections', () => {
expect(() => parseTranslationResponse('<translation>\nA\n</translation>')).toThrow(/missing or unterminated <review>/)
expect(() => parseTranslationResponse('<translation>\nA')).toThrow(/missing or unterminated <translation>/)
const dup = '<translation>\nA\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>\n<final>\nG\n</final>'
expect(() => parseTranslationResponse(dup)).toThrow(/duplicate <final>/)
expect(() => parseTranslationResponse(`${renderTranslationResponse({ translation: 'A', review: 'R', final: 'F' })}\nstray`))
.toThrow(/content is not allowed outside/)
})
it('inserts or corrects the target switcher after parsing a new-pair response', () => {
const response = renderTranslationResponse({
translation: '# 指南\n\n初稿。',
review: '- 无修正',
final: '# 指南\n\nEnglish | [中文](guide.zh.md)\n\n定稿。',
})
expect(consumeTranslationResponse(response, { sourceLanguage: 'English', sourceFilename: 'guide.md' }).final).toBe([
'# 指南',
'',
'[English](guide.md) | 中文',
'',
'定稿。',
'',
].join('\n'))
})
it('preserves YAML frontmatter before inserting the target switcher', () => {
const response = renderTranslationResponse({
translation: '# 指南\n\n初稿。',
review: '- 无修正',
final: [
'---',
'layout: home',
'---',
'',
'# 指南',
'',
'定稿。',
].join('\n'),
})
expect(consumeTranslationResponse(response, { sourceLanguage: 'English', sourceFilename: 'guide.md' }).final).toBe([
'---',
'layout: home',
'---',
'',
'# 指南',
'',
'[English](guide.md) | 中文',
'',
'定稿。',
'',
].join('\n'))
})
it('rejects unterminated YAML frontmatter before the target H1', () => {
const response = renderTranslationResponse({
translation: '# 指南\n\n初稿。',
review: '- 无修正',
final: '---\nlayout: home\n\n# 指南\n\n定稿。',
})
expect(() => consumeTranslationResponse(response, {
sourceLanguage: 'English',
sourceFilename: 'guide.md',
})).toThrow(/unterminated YAML frontmatter/)
})
it('rejects a source filename that contradicts the translation direction', () => {
expect(() => renderTranslationPrompt(document, {
sourceLanguage: 'Chinese',
sourceFilename: 'guide.md',
terminology,
})).toThrow(/does not match source language Chinese/)
})
it('inserts the English target switcher for a Chinese source', () => {
const response = renderTranslationResponse({
translation: '# Guide\n\nDraft.',
review: '- [None] No corrections.',
final: '# Guide\n\nFinal.',
})
expect(consumeTranslationResponse(response, {
sourceLanguage: 'Chinese',
sourceFilename: 'guide.zh.md',
}).final).toContain('\n\nEnglish | [中文](guide.zh.md)\n\n')
})
})
+180 -92
View File
@@ -1,20 +1,18 @@
/**
* Executable renderer and strict response parser for the committed
* documentation-translation prompt contract.
* Executable renderer and response parser for the committed
* documentation-translation prompt contract (prompt-v4).
*
* The v4 contract: three placeholders (`source_lang`, `target_lang`,
* `terminology`), whole-document translation, and a three-section response
* (`<translation>`, `<review>`, `<final>` in order, bare XML tags with raw
* Markdown bodies). The pipeline retains filename context outside the model
* request and corrects the final language switcher after parsing.
*/
import { basename } from 'node:path'
import { SaxesParser } from 'saxes'
/** Placeholder names supported by the committed translation prompt. */
export const TRANSLATION_PROMPT_PLACEHOLDERS = [
'source_lang',
'target_lang',
'translation_rules',
'terminology',
'source_filename',
'source_filename_zh',
] as const
export const TRANSLATION_PROMPT_PLACEHOLDERS = ['source_lang', 'target_lang', 'terminology'] as const
type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number]
@@ -26,13 +24,35 @@ export interface TranslationPromptInput {
sourceLanguage: TranslationLanguage
/** Source basename, including `.md` or `.zh.md`. */
sourceFilename: string
/** Complete current `translation-rules.md` contents. */
translationRules: string
/** Complete current `terminology.md` contents. */
terminology: string
}
/** Parsed contents of the three-element XML response. */
/** One reviewed whole-document example available in both directions. */
export interface TranslationExample {
english: string
chinese: string
}
/** Inputs for one complete model request. */
export interface TranslationRequestInput extends TranslationPromptInput {
sourceDocument: string
examples: TranslationExample[]
}
/** One model message in the provider-neutral translation request. */
interface TranslationMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
/** Fully assembled request plus the filename that receives the final body. */
export interface TranslationRequest {
targetFilename: string
messages: TranslationMessage[]
}
/** Parsed contents of the three-section response. */
export interface TranslationResponse {
translation: string
review: string
@@ -42,7 +62,35 @@ export interface TranslationResponse {
const PLACEHOLDER = /{{([a-z_]+)}}/g
const TEMPLATE_OPEN = '## 模板正文\n\n````text\n'
const TEMPLATE_CLOSE = '\n````'
const RESPONSE_CHILDREN = ['translation', 'review', 'final'] as const
const RESPONSE_SECTIONS = ['translation', 'review', 'final'] as const
const RESPONSE_DELIMITERS = new Set(RESPONSE_SECTIONS.flatMap(section => [`<${section}>`, `</${section}>`]))
const LANGUAGE_SWITCHER = /^(?:English \| \[中文\]\(.+\)|\[English\]\(.+\) \| 中文)$/
interface TranslationFiles {
targetFilename: string
targetSwitcher: string
}
function translationFiles(input: Pick<TranslationPromptInput, 'sourceFilename' | 'sourceLanguage'>): TranslationFiles {
if (basename(input.sourceFilename) !== input.sourceFilename) {
throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`)
}
const sourceIsChinese = input.sourceFilename.endsWith('.zh.md')
const sourceIsEnglish = input.sourceFilename.endsWith('.md') && !sourceIsChinese
if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : !sourceIsEnglish) {
throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`)
}
if (sourceIsChinese) {
return {
targetFilename: input.sourceFilename.replace(/\.zh\.md$/, '.md'),
targetSwitcher: `English | [中文](${input.sourceFilename})`,
}
}
return {
targetFilename: input.sourceFilename.replace(/\.md$/, '.zh.md'),
targetSwitcher: `[English](${input.sourceFilename}) | 中文`,
}
}
/** Extract the machine-consumed text fence from `translation-prompt.md`. */
function extractTranslationPrompt(document: string): string {
@@ -61,25 +109,14 @@ export function documentedTranslationPromptPlaceholders(document: string): strin
return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '')
}
/** Render one system prompt from the checked-in template and canonical rules. */
/** Render one system prompt from the checked-in template. */
export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string {
if (basename(input.sourceFilename) !== input.sourceFilename) {
throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`)
}
const sourceIsChinese = input.sourceFilename.endsWith('.zh.md')
if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : sourceIsChinese || !input.sourceFilename.endsWith('.md')) {
throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`)
}
translationFiles(input)
const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English'
const sourceFilenameZh = sourceIsChinese ? input.sourceFilename : input.sourceFilename.replace(/\.md$/, '.zh.md')
const values: Record<TranslationPromptPlaceholder, string> = {
source_lang: input.sourceLanguage,
target_lang: targetLanguage,
translation_rules: input.translationRules,
terminology: input.terminology,
source_filename: input.sourceFilename,
source_filename_zh: sourceFilenameZh,
}
const template = extractTranslationPrompt(document)
const placeholderFreeTemplate = template.replace(PLACEHOLDER, '')
@@ -95,77 +132,128 @@ export function renderTranslationPrompt(document: string, input: TranslationProm
return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder])
}
/** Escape one value so it remains byte-identical inside an XML CDATA field. */
function escapeTranslationCdata(value: string): string {
return value.replaceAll(']]>', ']]]]><![CDATA[>')
/**
* Assemble the calibrated system prompt, reviewed bare-text examples, and source document.
*
* @param document - Checked-in translation prompt asset.
* @param input - Direction, filename, terminology, examples, and source document.
* @returns Provider-neutral messages and the target basename.
*/
export function renderTranslationRequest(document: string, input: TranslationRequestInput): TranslationRequest {
const files = translationFiles(input)
const sourceKey = input.sourceLanguage === 'English' ? 'english' : 'chinese'
const targetKey = input.sourceLanguage === 'English' ? 'chinese' : 'english'
const messages: TranslationMessage[] = [{ role: 'system', content: renderTranslationPrompt(document, input) }]
for (const example of input.examples) {
messages.push(
{ role: 'user', content: example[sourceKey] },
{ role: 'assistant', content: example[targetKey] },
)
}
messages.push({ role: 'user', content: input.sourceDocument })
return { targetFilename: files.targetFilename, messages }
}
/** Serialize a response using the exact XML wire contract in the prompt. */
function escapeResponseBody(value: string): string {
return value.split('\n').map((line) => {
const delimiter = line.replace(/^\\+/, '')
return RESPONSE_DELIMITERS.has(delimiter) ? `\\${line}` : line
}).join('\n')
}
function unescapeResponseBody(value: string): string {
return value.split('\n').map((line) => {
if (!line.startsWith('\\')) return line
const candidate = line.slice(1)
return RESPONSE_DELIMITERS.has(candidate.replace(/^\\+/, '')) ? candidate : line
}).join('\n')
}
/** Serialize a response in the exact escaped three-section shape the prompt requests. */
export function renderTranslationResponse(response: TranslationResponse): string {
return [
'<dsh-translation-response version="1">',
`<translation><![CDATA[${escapeTranslationCdata(response.translation)}]]></translation>`,
`<review><![CDATA[${escapeTranslationCdata(response.review)}]]></review>`,
`<final><![CDATA[${escapeTranslationCdata(response.final)}]]></final>`,
'</dsh-translation-response>',
].join('\n')
return RESPONSE_SECTIONS.map(section => `<${section}>\n${escapeResponseBody(response[section])}\n</${section}>`).join('\n\n')
}
/** Parse and validate the exact XML response shape emitted by the model. */
export function parseTranslationResponse(xml: string): TranslationResponse {
const values: TranslationResponse = { translation: '', review: '', final: '' }
const stack: string[] = []
const cdataFields = new Set<string>()
let rootSeen = false
let childIndex = 0
const fail = (message: string): never => {
throw new Error(`translation response: ${message}`)
}
const parser = new SaxesParser({ xmlns: false })
/**
* Parse the three-section response. Sections must each appear exactly once
* and in order; escaped delimiter lines in Markdown bodies are restored.
* A fenced ```xml wrapper around the whole response is tolerated, matching
* the shape some models echo back from the prompt's own example.
*/
export function parseTranslationResponse(text: string): TranslationResponse {
let body = text.trim()
const fenced = /^```(?:xml)?\n([\s\S]*?)\n```$/.exec(body)
if (fenced?.[1] !== undefined) body = fenced[1].trim()
parser.on('opentag', (tag) => {
if (stack.length === 0) {
if (rootSeen) fail('contains more than one root element')
if (tag.name !== 'dsh-translation-response') fail(`expected dsh-translation-response root, got ${tag.name}`)
const attributes = Object.keys(tag.attributes)
if (attributes.length !== 1 || tag.attributes.version !== '1') fail('root must have only version="1"')
rootSeen = true
} else if (stack.length === 1) {
const expected = RESPONSE_CHILDREN[childIndex]
if (tag.name !== expected) fail(`expected ${expected ?? 'no more children'}, got ${tag.name}`)
if (Object.keys(tag.attributes).length !== 0) fail(`${tag.name} must not have attributes`)
childIndex++
} else {
fail(`nested element ${tag.name} is not allowed`)
const values: Partial<Record<(typeof RESPONSE_SECTIONS)[number], string>> = {}
const lines = body.split('\n')
let previousCloseEnd = 0
for (const [index, section] of RESPONSE_SECTIONS.entries()) {
const open = `<${section}>`
const close = `</${section}>`
const openCount = lines.filter(line => line === open).length
const closeCount = lines.filter(line => line === close).length
if (openCount === 0 || closeCount === 0) {
throw new Error(`translation response: missing or unterminated <${section}> section`)
}
stack.push(tag.name)
})
parser.on('text', (value) => {
if (stack.length <= 1 && value.trim() === '') return
fail('all response field content must be inside CDATA')
})
parser.on('cdata', (value) => {
const field = stack.at(-1)
if (field === undefined || !RESPONSE_CHILDREN.includes(field as (typeof RESPONSE_CHILDREN)[number])) {
fail('CDATA is allowed only inside translation, review, or final')
}
const key = field as (typeof RESPONSE_CHILDREN)[number]
values[key] += value
cdataFields.add(key)
})
parser.on('closetag', (tag) => {
const expected = stack.pop()
if (expected !== tag.name) fail(`closing ${tag.name} does not match ${expected ?? 'nothing'}`)
})
parser.on('comment', () => fail('comments are not allowed'))
parser.on('doctype', () => fail('doctypes are not allowed'))
parser.on('processinginstruction', () => fail('processing instructions are not allowed'))
parser.on('error', error => fail(`invalid XML: ${error.message}`))
parser.write(xml).close()
if (openCount > 1 || closeCount > 1) throw new Error(`translation response: duplicate <${section}> section`)
if (childIndex !== RESPONSE_CHILDREN.length) fail('translation, review, and final must each appear exactly once and in order')
for (const field of RESPONSE_CHILDREN) {
if (!cdataFields.has(field)) fail(`${field} must contain a CDATA section`)
const openStart = body.search(new RegExp(`^<${section}>$`, 'm'))
const closeStart = body.search(new RegExp(`^</${section}>$`, 'm'))
const separator = body.slice(previousCloseEnd, openStart)
if (closeStart < openStart || (index === 0 ? separator !== '' : !/^\n+$/.test(separator))) {
throw new Error('translation response: sections must appear in translation, review, final order')
}
let contentStart = openStart + open.length
if (body[contentStart] === '\n') contentStart++
let contentEnd = closeStart
if (body[contentEnd - 1] === '\n') contentEnd--
values[section] = unescapeResponseBody(body.slice(contentStart, contentEnd))
previousCloseEnd = closeStart + close.length
}
return values
if (previousCloseEnd !== body.length) throw new Error('translation response: content is not allowed outside response sections')
return values as TranslationResponse
}
function correctLanguageSwitcher(markdown: string, switcher: string): string {
const lines = markdown.replaceAll('\r\n', '\n').split('\n')
while (lines.at(-1) === '') lines.pop()
let headingIndex = 0
if (lines[0] === '---') {
const frontmatterEnd = lines.indexOf('---', 1)
if (frontmatterEnd === -1) throw new Error('translation response: final document has unterminated YAML frontmatter')
headingIndex = frontmatterEnd + 1
while (lines[headingIndex] === '') headingIndex++
}
if (!/^#\s+\S/.test(lines[headingIndex] ?? '')) {
throw new Error('translation response: final document must start with an H1 heading')
}
let contentStart = headingIndex + 1
while (lines[contentStart] === '') contentStart++
if (LANGUAGE_SWITCHER.test(lines[contentStart] ?? '')) contentStart++
while (lines[contentStart] === '') contentStart++
const output = [...lines.slice(0, headingIndex), lines[headingIndex] as string, '', switcher]
const content = lines.slice(contentStart)
if (content.length > 0) output.push('', ...content)
return `${output.join('\n')}\n`
}
/**
* Parse a model response and make its consumed final document target-path correct.
*
* @param text - Raw three-section model response.
* @param input - Source direction and basename retained by the pipeline.
* @returns Parsed response whose `final` body has the canonical target switcher.
*/
export function consumeTranslationResponse(
text: string,
input: Pick<TranslationPromptInput, 'sourceFilename' | 'sourceLanguage'>,
): TranslationResponse {
const parsed = parseTranslationResponse(text)
const files = translationFiles(input)
return { ...parsed, final: correctLanguageSwitcher(parsed.final, files.targetSwitcher) }
}
+55
View File
@@ -1148,6 +1148,61 @@
"doc": "docs/core-data-structures/lsp.md",
"symbol": "LspService",
"source": "packages/lsp/lsp/src/types.ts"
},
{
"doc": "docs/core-data-structures/persistence.md",
"symbol": "SessionPersistenceRevision",
"source": "packages/session-persistence/session-persistence/src/revision.ts"
},
{
"doc": "docs/core-data-structures/persistence.md",
"symbol": "SessionPersistenceSnapshot",
"source": "packages/session-persistence/session-persistence/src/index.ts"
},
{
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionResultFilter",
"source": "packages/session-query/session-query/src/types.ts"
},
{
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionEventResultFilter",
"source": "packages/session-query/session-query/src/types.ts"
},
{
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionEventSearchDocument",
"source": "packages/session-query/session-query/src/types.ts"
},
{
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionSearchCursor",
"source": "packages/session-query/session-query/src/cursor.ts"
},
{
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionSearchRequest",
"source": "packages/session-query/session-query/src/types.ts"
},
{
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionEventSearchRequest",
"source": "packages/session-query/session-query/src/types.ts"
},
{
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionSearchPage",
"source": "packages/session-query/session-query/src/types.ts"
},
{
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionEventSearchHit",
"source": "packages/session-query/session-query/src/types.ts"
},
{
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionSearchHit",
"source": "packages/session-query/session-query/src/types.ts"
}
]
}
@@ -53,6 +53,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
@@ -76,6 +77,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
+56 -15
View File
@@ -3,11 +3,14 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import {
consumeTranslationResponse,
documentedTranslationPromptPlaceholders,
parseTranslationResponse,
renderTranslationPrompt,
renderTranslationRequest,
renderTranslationResponse,
TRANSLATION_PROMPT_PLACEHOLDERS,
type TranslationExample,
} from './translation-prompt.ts'
const root = resolve(import.meta.dirname, '..')
@@ -17,38 +20,76 @@ function read(path: string): string {
}
try {
const mode = process.argv[2]
if (mode !== undefined && mode !== '--snapshot') throw new Error(`unsupported argument ${JSON.stringify(mode)}`)
const document = read('docs/i18n/translation-prompt.md')
const translationRules = read('docs/i18n/translation-rules.md')
const terminology = read('docs/i18n/terminology.md')
const examplePaths = [
['README.md', 'README.zh.md'],
['docs/development.md', 'docs/development.zh.md'],
['docs/i18n/README.md', 'docs/i18n/README.zh.md'],
['docs/i18n/translation-rules.md', 'docs/i18n/translation-rules.zh.md'],
[
'.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md',
'.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md',
],
] as const
const examples: TranslationExample[] = examplePaths.map(([english, chinese]) => ({
english: read(english),
chinese: read(chinese),
}))
const sourceDocument = read('scripts/fixtures/translation-prompt/snapshot-note.md')
const recordedResponse = read('scripts/fixtures/translation-prompt/response.txt')
const documented = documentedTranslationPromptPlaceholders(document)
if (documented.join('\n') !== TRANSLATION_PROMPT_PLACEHOLDERS.join('\n')) {
throw new Error(`placeholder table must list exactly: ${TRANSLATION_PROMPT_PLACEHOLDERS.join(', ')}`)
}
const englishSource = renderTranslationPrompt(document, {
sourceLanguage: 'English',
sourceFilename: 'example.md',
translationRules,
terminology,
})
const englishInput = { sourceLanguage: 'English' as const, sourceFilename: 'snapshot-note.md', terminology }
const englishSource = renderTranslationPrompt(document, englishInput)
const chineseSource = renderTranslationPrompt(document, {
sourceLanguage: 'Chinese',
sourceFilename: 'example.zh.md',
translationRules,
sourceFilename: 'snapshot-note.zh.md',
terminology,
})
if (!englishSource.includes('[English](example.md) | 中文')) throw new Error('English-source render does not carry the Chinese switcher instruction')
if (!chineseSource.includes('English | [中文](example.zh.md)')) throw new Error('Chinese-source render does not carry the English switcher instruction')
if (englishSource.includes('{{') || chineseSource.includes('{{')) throw new Error('rendered prompt contains an unresolved placeholder')
if (!englishSource.includes('from English to Chinese')) throw new Error('English-source render does not translate into Chinese')
if (!chineseSource.includes('from Chinese to English')) throw new Error('Chinese-source render does not translate into English')
const example = /```xml\n([\s\S]*?)\n```/.exec(englishSource)?.[1]
if (example === undefined) throw new Error('rendered prompt has no XML response example')
if (example === undefined) throw new Error('rendered prompt has no three-section response example')
parseTranslationResponse(example)
const roundTrip = { translation: 'first ]]> pass', review: '- [None] No corrections.', final: 'final ]]> text' }
const roundTrip = { translation: 'first pass\n\nwith **markdown**', review: '- 无修正', final: 'final text' }
const parsed = parseTranslationResponse(renderTranslationResponse(roundTrip))
if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('CDATA split rule does not round-trip response content')
if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('three-section response does not round-trip')
console.log('verify-translation-prompt: both directions render and the XML response contract parses.')
const request = renderTranslationRequest(document, { ...englishInput, sourceDocument, examples })
if (request.targetFilename !== 'snapshot-note.zh.md') throw new Error('English request resolves the wrong target filename')
const expectedRoles = ['system', ...examples.flatMap(() => ['user', 'assistant']), 'user']
if (request.messages.map(message => message.role).join('\n') !== expectedRoles.join('\n')) {
throw new Error('reviewed examples are not assembled as system, example pairs, then source')
}
const consumed = consumeTranslationResponse(recordedResponse, englishInput)
const expectedFinalPrefix = [
'---',
'layout: doc',
'---',
'',
'# 快照说明',
'',
'[English](snapshot-note.md) | 中文',
'',
].join('\n')
if (!consumed.final.startsWith(expectedFinalPrefix)) {
throw new Error('recorded frontmatter response does not preserve metadata and receive the canonical target switcher')
}
if (mode === '--snapshot') {
process.stdout.write(`${JSON.stringify({ request, response: consumed }, null, 2)}\n`)
} else {
console.log('verify-translation-prompt: both directions render, reviewed examples assemble, and the consumed response is target-path correct.')
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`verify-translation-prompt: ${message}`)