From 7cdf36dfd057dbfc51238069390b6972eb88953c Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:18:16 +0800 Subject: [PATCH 01/45] docs: require appropriate PR labels --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 8007f32b90..98b23b88ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- **Label PRs appropriately.** Apply labels required by each PR's changes, including labels that trigger optional CI workflows. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From fd33a039a2d4c58afc50f870bf7e3e37b9d9c2e9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:25:44 +0800 Subject: [PATCH 02/45] docs: fit PR label rule within budget --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 95364b5c56..f2d25c71d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs appropriately.** Apply labels required by each PR's changes, including labels that trigger optional CI workflows. +- **Label PRs appropriately.** Apply labels required by each PR's changes, including optional CI-trigger labels. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From dc5045dc0ae32b0ec15d945b9978109efd75e5b7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:30:52 +0800 Subject: [PATCH 03/45] docs: keep PR label guidance general --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f2d25c71d6..8c4cc143f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs appropriately.** Apply labels required by each PR's changes, including optional CI-trigger labels. +- **Label PRs appropriately.** Apply labels required by each PR's changes. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 5209656086944adcbbf637468b89bb99799c9633 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 17:43:26 +0800 Subject: [PATCH 04/45] docs: clarify PR label selection --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8c4cc143f8..6ec6383d6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs appropriately.** Apply labels required by each PR's changes. +- Use matching existing PR labels (`documentation`, `web`, `tui`, `core`); never create one implicitly. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From fd8d93da12903b8c9d8455cab0972ff272f36bc0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 18:02:36 +0800 Subject: [PATCH 05/45] docs: prohibit creating PR labels --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6ec6383d6a..eae3007c86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- Use matching existing PR labels (`documentation`, `web`, `tui`, `core`); never create one implicitly. +- Pick matching existing GitHub labels such as `documentation`, `web`, `tui`, or `core`; never create new labels. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From be3a9f6a75ba2d1c86b2e738c1ccc95018073522 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:05:29 +0800 Subject: [PATCH 06/45] docs: document repository clean workflow --- .../process/2026-06-17-ts-build-config.i18n.yaml | 4 ++-- .../implemented/process/2026-06-17-ts-build-config.md | 8 ++++++++ .../implemented/process/2026-06-17-ts-build-config.zh.md | 8 ++++++++ AGENTS.md | 1 + 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 80f7838d45..32202d837f 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.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 -2026-06-17-ts-build-config.md: 1275a635242ea887c941db9dc0554fd33acd4102 -2026-06-17-ts-build-config.zh.md: 7691118dd152874e2849f1ace686069c9ea3f61f +2026-06-17-ts-build-config.md: 527570393c42d581d28da0380efdf9ba8bade8ae +2026-06-17-ts-build-config.zh.md: 6535add99115bff0e396e87729bef225dae39e6c diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 1275a63524..527570393c 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -43,6 +43,8 @@ In-package relative imports use explicit `.ts` specifiers. - Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. - The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. +Composite projects keep their incremental build information inside their package-local `lib/` output. `pnpm run clean` explicitly removes package/vendor/CLI `lib/` outputs, legacy root build information, and deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. + The command orchestration shape is: ```sh @@ -55,6 +57,9 @@ tsx scripts/verify-node-next-types.ts pnpm run typecheck: tsc -b + +pnpm run clean: +tsx scripts/clean.ts ``` `pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. @@ -63,6 +68,8 @@ tsc -b - **Keep `tsdown`/oxc as the TypeScript transformer** — oxc's transform is not `tsc` behavior (decorator transform differs, bundled JS differs from per-file emit), and its bundled `.d.ts` conflicts with Cordis' internal relative module augmentation shape. - **One root strict program over packages, vendor, examples, tests, and scripts** — vendor source triggers type errors outside this project's ownership under the root strict flags; project references with per-project strictness are the boundary that works. +- **Clean before every build** — this would discard the incremental state owned by `tsc` and the bundler even when the workspace layout is unchanged. +- **Remove every package-level `node_modules`** — valid package dependency links do not cause the workspace-discovery failure, and deleting them would turn build cleanup into dependency reinstallation. ## Consequences @@ -76,5 +83,6 @@ Build responsibilities are clearer: - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. - `pnpm run verify-node-next-types` scans built declarations for relative specifiers without file extensions, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish. - The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. +- After changing branches or updating a checkout that deleted packages, contributors can run `pnpm run clean` to remove stale package directories before rebuilding. Unknown files in a manifest-less package directory require manual classification instead of being deleted. The Cordis vendor copy now has one more type-structure divergence from upstream. During upstream sync, that divergence must be reapplied or explicitly retired. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 7691118dd1..6535add991 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -43,6 +43,8 @@ Status: implemented - 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 - 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 +复合项目将增量构建信息保存在各包本地的 `lib/` 输出中。`pnpm run clean` 会显式删除包、vendor 和 CLI(命令行界面)的 `lib/` 输出、遗留的根目录构建信息,以及已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 + 命令编排结构如下: ```sh @@ -55,6 +57,9 @@ tsx scripts/verify-node-next-types.ts pnpm run typecheck: tsc -b + +pnpm run clean: +tsx scripts/clean.ts ``` `pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤。 @@ -63,6 +68,8 @@ tsc -b - **继续使用 `tsdown`/oxc 作为 TypeScript 转换器**:oxc 的转换行为与 `tsc` 不同(装饰器转换有差异、打包 JS 与逐文件输出不同),且其打包 `.d.ts` 与 Cordis 内部的相对模块增强结构冲突。 - **用一个根目录严格程序覆盖包、vendor、示例、测试和脚本**:vendor 源码在根目录严格标志下会触发不属于本项目所有权范围的类型错误;带有逐项目严格度的 project references 才是可行的边界。 +- **每次构建前都执行清理**:即使工作区布局没有变化,这也会丢弃 `tsc` 和打包器拥有的增量状态。 +- **删除所有包级 `node_modules`**:有效的包依赖链接不会导致工作区发现失败,而删除这些链接会使构建清理变成重新安装依赖。 ## 后果 @@ -76,5 +83,6 @@ tsc -b - `lib/index.*` 是发布用的运行时输出,由打包器(当前为 `tsdown`)生成。 - `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 接口进行临时外部 ESM 消费方的类型检查,确保声明说明符的回归在发布前被捕获。 - `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,包和 vendor 模块保持与 `build` 相同的输出行为。包和 vendor 源码始终处于 project-reference 边界之后。 +- 切换分支或更新工作副本后,如果其中删除了包,贡献者可在重新构建前运行 `pnpm run clean`,删除残留的包目录。不含 `package.json` 的包目录如果存在未知文件,必须手动判定其类别,不能直接删除。 Cordis 的 vendor 副本现在与上游多了一处类型结构差异。在上游同步时,该差异必须被重新应用或明确废弃。 diff --git a/AGENTS.md b/AGENTS.md index 64259784fb..1ce8e5e0c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ Package groups: [packages/README.md](packages/README.md). ```sh pnpm install # pnpm workspaces, node ^22.19 || >=24 +pnpm run clean # remove build outputs and safe residue from deleted packages pnpm run test # vitest unit tests pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY From 22875a6c9d86d801cffaaa8d6af42aed76ea3463 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:06:01 +0800 Subject: [PATCH 07/45] fix(build): clean stale workspace residue --- package.json | 2 +- packages/examples/cli-demo/tsconfig.json | 3 +- scripts/clean.ts | 107 +++++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 scripts/clean.ts diff --git a/package.json b/package.json index a925ea3ec9..f5017bea2f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "scripts": { "build": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", - "clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo", + "clean": "tsx scripts/clean.ts", "typecheck": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/examples/cli-demo/tsconfig.json b/packages/examples/cli-demo/tsconfig.json index df5758b7b8..095f7ce6a3 100644 --- a/packages/examples/cli-demo/tsconfig.json +++ b/packages/examples/cli-demo/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "composite": true, "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "../../../.typecheck/cli-demo.tsbuildinfo" + "outDir": "lib/types" }, "include": ["src/**/*.ts"], "references": [ diff --git a/scripts/clean.ts b/scripts/clean.ts new file mode 100644 index 0000000000..3f0f85836f --- /dev/null +++ b/scripts/clean.ts @@ -0,0 +1,107 @@ +import { lstat, readdir, rm } from 'node:fs/promises' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck']) + +function isMissing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function exists(path: string): Promise { + try { + await lstat(path) + return true + } catch (error) { + if (isMissing(error)) return false + throw error + } +} + +async function childDirectories(path: string): Promise { + try { + const entries = await readdir(path, { withFileTypes: true }) + return entries.filter(entry => entry.isDirectory()).map(entry => join(path, entry.name)) + } catch (error) { + if (isMissing(error)) return [] + throw error + } +} + +function repositoryPath(root: string, path: string): string { + return relative(root, path).split(sep).join('/') +} + +class RepositoryCleaner { + constructor(private readonly root: string) {} + + /** + * Remove generated build state and package directories containing only known residue. + * @returns Repository-relative paths that were removed. + */ + async clean(): Promise { + const targets = await this.plan() + for (const target of targets) await rm(target, { recursive: true, force: true }) + return targets.map(target => repositoryPath(this.root, target)) + } + + private async plan(): Promise { + const targets = new Set() + const unsafeOrphans: string[] = [] + + await this.addIfPresent(targets, join(this.root, '.typecheck')) + for (const entry of await readdir(this.root, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name)) + } + + for (const vendorDirectory of await childDirectories(join(this.root, 'vendor'))) { + await this.addIfPresent(targets, join(vendorDirectory, 'lib')) + } + await this.addIfPresent(targets, join(this.root, 'apps', 'cli', 'lib')) + + for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) { + for (const packageDirectory of await childDirectories(groupDirectory)) { + if (await exists(join(packageDirectory, 'package.json'))) { + await this.addIfPresent(targets, join(packageDirectory, 'lib')) + continue + } + + const entries = await readdir(packageDirectory) + const unknown = entries.filter(entry => !knownOrphanEntries.has(entry) && !entry.endsWith('.tsbuildinfo')) + if (unknown.length > 0) { + unsafeOrphans.push(...unknown.map(entry => repositoryPath(this.root, join(packageDirectory, entry)))) + } else { + targets.add(packageDirectory) + } + } + } + + if (unsafeOrphans.length > 0) { + throw new Error([ + 'clean: refusing to remove package directories without package.json; unknown entries remain:', + ...unsafeOrphans.sort().map(path => ` ${path}`), + ].join('\n')) + } + + return [...targets].sort() + } + + private async addIfPresent(targets: Set, path: string): Promise { + if (await exists(path)) targets.add(path) + } +} + +const scriptPath = fileURLToPath(import.meta.url) +if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) { + try { + const removed = await new RepositoryCleaner(resolve(dirname(scriptPath), '..')).clean() + if (removed.length === 0) { + console.log('clean: already clean') + } else { + console.log(`clean: removed ${removed.length} paths`) + } + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} From 787b4e6b9e1dbe1e88649bbc4061dee4b8912819 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:30:35 +0800 Subject: [PATCH 08/45] fix(build): derive clean outputs from project graph --- .../2026-06-17-ts-build-config.i18n.yaml | 4 +- .../process/2026-06-17-ts-build-config.md | 2 +- .../process/2026-06-17-ts-build-config.zh.md | 2 +- scripts/clean.spec.ts | 62 ++++++++++++++ scripts/clean.ts | 80 +++++++++++++++++-- 5 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 scripts/clean.spec.ts diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 32202d837f..d8530ac919 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.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 -2026-06-17-ts-build-config.md: 527570393c42d581d28da0380efdf9ba8bade8ae -2026-06-17-ts-build-config.zh.md: 6535add99115bff0e396e87729bef225dae39e6c +2026-06-17-ts-build-config.md: 17036438b83a77f72b49f55abf29632af3f4ffef +2026-06-17-ts-build-config.zh.md: 70e49c61deba418894a48be3016898d1d85c78a0 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 527570393c..17036438b8 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -43,7 +43,7 @@ In-package relative imports use explicit `.ts` specifiers. - Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. - The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. -Composite projects keep their incremental build information inside their package-local `lib/` output. `pnpm run clean` explicitly removes package/vendor/CLI `lib/` outputs, legacy root build information, and deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. +Composite projects keep their incremental build information inside their project-local `lib/` output. `pnpm run clean` derives live output directories from the root TypeScript project-reference graph, removes legacy root build information, and removes deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. The command orchestration shape is: diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 6535add991..70e49c61de 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -43,7 +43,7 @@ Status: implemented - 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 - 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 -复合项目将增量构建信息保存在各包本地的 `lib/` 输出中。`pnpm run clean` 会显式删除包、vendor 和 CLI(命令行界面)的 `lib/` 输出、遗留的根目录构建信息,以及已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 +复合项目将增量构建信息保存在各项目本地的 `lib/` 输出中。`pnpm run clean` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 命令编排结构如下: diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts new file mode 100644 index 0000000000..0d3aced888 --- /dev/null +++ b/scripts/clean.spec.ts @@ -0,0 +1,62 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { RepositoryCleaner } from './clean.ts' + +const roots: string[] = [] + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-clean-')) + roots.push(root) + return root +} + +function write(path: string, content = ''): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function addProject(root: string, path: string): void { + write(join(root, 'tsconfig.json'), JSON.stringify({ files: [], references: [{ path }] })) + write(join(root, path, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { composite: true, outDir: 'lib/types' }, + include: ['src'], + })) + write(join(root, path, 'src/index.ts'), 'export {}\n') +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('RepositoryCleaner', () => { + it('derives live build outputs from project references and removes safe stale package residue', async () => { + const root = fixture() + addProject(root, 'products/shell') + write(join(root, 'products/shell/lib/types/index.js')) + write(join(root, 'products/shell/lib/index.js')) + write(join(root, '.typecheck/legacy.tsbuildinfo')) + write(join(root, 'root.tsbuildinfo')) + write(join(root, 'packages/removed/ghost/node_modules/.bin/tool')) + + await new RepositoryCleaner(root).clean() + + expect(existsSync(join(root, 'products/shell/lib'))).toBe(false) + expect(existsSync(join(root, 'products/shell/src/index.ts'))).toBe(true) + expect(existsSync(join(root, '.typecheck'))).toBe(false) + expect(existsSync(join(root, 'root.tsbuildinfo'))).toBe(false) + expect(existsSync(join(root, 'packages/removed/ghost'))).toBe(false) + }) + + it('does not delete any target when a manifest-less package contains an unknown file', async () => { + const root = fixture() + addProject(root, 'products/shell') + write(join(root, 'products/shell/lib/types/index.js')) + write(join(root, 'packages/removed/ghost/notes.txt')) + + await expect(new RepositoryCleaner(root).clean()).rejects.toThrow('packages/removed/ghost/notes.txt') + expect(existsSync(join(root, 'products/shell/lib'))).toBe(true) + }) +}) diff --git a/scripts/clean.ts b/scripts/clean.ts index 3f0f85836f..93b9b2519c 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -1,9 +1,21 @@ import { lstat, readdir, rm } from 'node:fs/promises' -import { dirname, join, relative, resolve, sep } from 'node:path' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' +import ts from 'typescript' const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck']) +const configHost: ts.ParseConfigFileHost = { + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + readDirectory: (...args) => ts.sys.readDirectory(...args), + fileExists: fileName => ts.sys.fileExists(fileName), + readFile: fileName => ts.sys.readFile(fileName), + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) + }, +} + function isMissing(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } @@ -32,7 +44,17 @@ function repositoryPath(root: string, path: string): string { return relative(root, path).split(sep).join('/') } -class RepositoryCleaner { +function parseConfig(configPath: string): ts.ParsedCommandLine { + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) + if (!parsed) throw new Error(`clean: cannot parse TypeScript config ${configPath}`) + if (parsed.errors.length > 0) { + throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) + } + return parsed +} + +/** Plans and removes repository-owned build output without crossing the repository boundary. */ +export class RepositoryCleaner { constructor(private readonly root: string) {} /** @@ -41,6 +63,7 @@ class RepositoryCleaner { */ async clean(): Promise { const targets = await this.plan() + // Planning validates every target first, so an unsafe orphan prevents all deletion. for (const target of targets) await rm(target, { recursive: true, force: true }) return targets.map(target => repositoryPath(this.root, target)) } @@ -49,23 +72,29 @@ class RepositoryCleaner { const targets = new Set() const unsafeOrphans: string[] = [] + // These checks cover legacy root-level incremental state emitted by older configs. await this.addIfPresent(targets, join(this.root, '.typecheck')) for (const entry of await readdir(this.root, { withFileTypes: true })) { if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name)) } - for (const vendorDirectory of await childDirectories(join(this.root, 'vendor'))) { - await this.addIfPresent(targets, join(vendorDirectory, 'lib')) + // The root project-reference graph is the source of truth for live build targets. + // Each emitting project declares lib/types as outDir; its parent lib also owns + // the sibling runtime bundles, so the complete build output root is removed. + for (const outputDirectory of this.buildOutputDirectories()) { + await this.addIfPresent(targets, outputDirectory) } - await this.addIfPresent(targets, join(this.root, 'apps', 'cli', 'lib')) for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) { for (const packageDirectory of await childDirectories(groupDirectory)) { + // A package.json marks a live package; its output was discovered from the + // project graph above, and its package-local node_modules must be preserved. if (await exists(join(packageDirectory, 'package.json'))) { - await this.addIfPresent(targets, join(packageDirectory, 'lib')) continue } + // A manifest-less package directory is stale only when every remaining + // entry is known generated residue; unknown files make the whole clean fail. const entries = await readdir(packageDirectory) const unknown = entries.filter(entry => !knownOrphanEntries.has(entry) && !entry.endsWith('.tsbuildinfo')) if (unknown.length > 0) { @@ -86,7 +115,46 @@ class RepositoryCleaner { return [...targets].sort() } + private buildOutputDirectories(): string[] { + const outputs = new Set() + const pending = [join(this.root, 'tsconfig.json')] + const visited = new Set() + + while (pending.length > 0) { + const nextConfigPath = pending.pop() + if (nextConfigPath === undefined) break + const configPath = resolve(nextConfigPath) + if (visited.has(configPath)) continue + visited.add(configPath) + + const parsed = parseConfig(configPath) + if (parsed.options.outDir !== undefined) { + const typesDirectory = resolve(parsed.options.outDir) + if (basename(typesDirectory) !== 'types') { + throw new Error(`clean: expected TypeScript outDir to end in /types: ${repositoryPath(this.root, typesDirectory)}`) + } + const outputDirectory = dirname(typesDirectory) + this.assertRepositoryTarget(outputDirectory) + outputs.add(outputDirectory) + } + + for (const reference of parsed.projectReferences ?? []) { + pending.push(ts.resolveProjectReferencePath(reference)) + } + } + + return [...outputs] + } + + private assertRepositoryTarget(path: string): void { + const repositoryRelative = relative(this.root, path) + if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) { + throw new Error(`clean: refusing build output outside repository: ${path}`) + } + } + private async addIfPresent(targets: Set, path: string): Promise { + // Missing outputs are normal on a clean checkout; only existing paths become deletion targets. if (await exists(path)) targets.add(path) } } From 6e721b9fdd5524b31fc399bf7b72f0cba0f32cba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:16:05 +0800 Subject: [PATCH 09/45] feat(gui): settings panel with locale and theme preferences Add the browser Settings surface as slot-composed plugins over new preference services: - Rename dsh-client-i18n to dsh-client-locale (locale is the domain name); LocaleService adds getLocale()/setLocale(id), immutable snapshots, a locale/change event, and dsh.locale persistence. - ThemeService owns the light/dark/system preference (default system), resolves system via prefers-color-scheme, publishes theme/change snapshots, persists dsh.theme, and no longer touches the DOM; ui-layout's ThemePresenter applies resolved snapshots (body[data-ds-dark-theme] + alias tokens) and cleans up on dispose. - ui-sidebar drops the phase-1 settings dropdown/modal; the foot renders the new sidebar.settings slot with the column state. - New ui-settings shell occupies sidebar.settings: foot trigger row and the centered 1080x700 panel (figma 501:29947) with 24% mask, close button / mask click / Escape all closing, and a 188px nav projected from the settings.section list slot it declares. Nav labels are registrant-localized; sections re-register on locale change, so the ledger version is the shell's only subscription. - ui-settings-general registers the General section: Permission and Tool Call skeletons, live Language (locale menu) and Appearance (Light/Dark/System cubes following the persisted preference); its slot store mirrors both service snapshots via apply-side listeners. - ui-settings-models registers the Models nav entry with an empty content column. - Portaled menus pin z-index above modal overlays (a menu anchored inside the settings dialog rendered underneath it and was unclickable). - theme/data/list-pen icons in ui-primitives; settings copy ships as zh/en dictionaries; fixture manifests gain the settings rows. --- ...6-07-25-client-settings-locale-theme.zh.md | 99 +++++++++ apps/cli/cordis.yml | 13 +- apps/cli/package.json | 5 +- apps/cli/tsconfig.json | 11 +- apps/web/tests/session-title.snapshot.ts | 5 +- apps/web/tests/smoke-real.e2e.ts | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 5 +- packages/client/i18n/README.md | 16 -- packages/client/i18n/src/client/index.ts | 108 --------- packages/client/i18n/tests/i18n.spec.ts | 53 ----- packages/client/i18n/tests/invariant.spec.ts | 30 --- packages/client/i18n/tsdown.config.ts | 3 - packages/client/locale/README.md | 16 ++ packages/client/{i18n => locale}/package.json | 13 +- packages/client/locale/src/client/index.ts | 195 +++++++++++++++++ packages/client/{i18n => locale}/src/index.ts | 2 +- .../client/{i18n => locale}/src/invariant.ts | 8 +- .../client/{i18n => locale}/src/locales/en.ts | 0 .../client/{i18n => locale}/src/locales/zh.ts | 0 .../client/locale/tests/invariant.spec.ts | 30 +++ packages/client/locale/tests/locale.spec.ts | 90 ++++++++ .../client/{i18n => locale}/tsconfig.json | 3 - packages/client/locale/tsdown.config.ts | 3 + packages/client/tsdown.client.ts | 2 +- packages/client/ui-conversation/package.json | 2 +- .../tests/apply-inject.spec.tsx | 2 +- .../ui-conversation/tests/chat-apply.spec.tsx | 2 +- .../tests/chat-toolview-slot.spec.tsx | 4 +- packages/client/ui-conversation/tsconfig.json | 2 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/package.json | 5 +- packages/client/ui-layout/src/client/index.ts | 19 +- .../ui-layout/src/client/theme-presenter.ts | 43 ++++ packages/client/ui-layout/tests/apply.spec.ts | 21 +- .../ui-layout/tests/theme-presenter.spec.ts | 56 +++++ packages/client/ui-layout/tsconfig.json | 3 + .../client/ui-primitives/src/Menu.module.css | 4 +- .../client/ui-primitives/src/icons/index.tsx | 87 ++++++++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-settings-general/README.md | 15 ++ .../client/ui-settings-general/package.json | 70 ++++++ .../src/client/GeneralSection.module.css | 131 +++++++++++ .../src/client/GeneralSection.tsx | 118 ++++++++++ .../src/client/contract.ts | 66 ++++++ .../ui-settings-general/src/client/index.ts | 111 ++++++++++ .../ui-settings-general/src/client/locales.ts | 42 ++++ .../ui-settings-general/src/client/store.ts | 43 ++++ .../ui-settings-general/src/css-modules.d.ts | 6 + .../client/ui-settings-general/src/index.ts | 4 + .../ui-settings-general/src/invariant.ts | 32 +++ .../client/ui-settings-general/tsconfig.json | 36 +++ .../ui-settings-general/tsdown.config.ts | 3 + packages/client/ui-settings-models/README.md | 15 ++ .../client/ui-settings-models/package.json | 63 ++++++ .../src/client/ModelsSection.tsx | 13 ++ .../ui-settings-models/src/client/index.ts | 63 ++++++ .../ui-settings-models/src/css-modules.d.ts | 6 + .../client/ui-settings-models/src/index.ts | 4 + .../ui-settings-models/src/invariant.ts | 31 +++ .../client/ui-settings-models/tsconfig.json | 30 +++ .../ui-settings-models/tsdown.config.ts | 3 + packages/client/ui-settings/README.md | 15 ++ packages/client/ui-settings/package.json | 68 ++++++ .../src/client/SettingsRoot.module.css | 192 ++++++++++++++++ .../ui-settings/src/client/SettingsRoot.tsx | 125 +++++++++++ .../ui-settings/src/client/contract/slots.ts | 62 ++++++ .../client/ui-settings/src/client/index.ts | 66 ++++++ .../client/ui-settings/src/css-modules.d.ts | 6 + packages/client/ui-settings/src/index.ts | 4 + packages/client/ui-settings/src/invariant.ts | 32 +++ packages/client/ui-settings/tsconfig.json | 33 +++ packages/client/ui-settings/tsdown.config.ts | 3 + packages/client/ui-sidebar/README.md | 4 +- .../src/client/SidebarRoot.module.css | 46 +--- .../ui-sidebar/src/client/SidebarRoot.tsx | 9 +- .../ui-sidebar/src/client/contract/slots.ts | 22 +- .../client/ui-sidebar/src/client/index.ts | 11 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 16 +- packages/client/ui-theme/README.md | 5 +- packages/client/ui-theme/package.json | 8 +- packages/client/ui-theme/src/client/index.ts | 207 ++++++++++++++---- packages/client/ui-theme/src/invariant.ts | 7 +- packages/client/ui-theme/tests/theme.spec.ts | 109 +++++---- packages/client/web/src/boot.tsx | 2 +- pnpm-lock.yaml | 120 +++++++++- .../verify-package-readme-model-experience.ts | 2 +- tsconfig.base.json | 5 +- tsconfig.client.json | 5 +- 88 files changed, 2653 insertions(+), 404 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md delete mode 100644 packages/client/i18n/README.md delete mode 100644 packages/client/i18n/src/client/index.ts delete mode 100644 packages/client/i18n/tests/i18n.spec.ts delete mode 100644 packages/client/i18n/tests/invariant.spec.ts delete mode 100644 packages/client/i18n/tsdown.config.ts create mode 100644 packages/client/locale/README.md rename packages/client/{i18n => locale}/package.json (78%) create mode 100644 packages/client/locale/src/client/index.ts rename packages/client/{i18n => locale}/src/index.ts (61%) rename packages/client/{i18n => locale}/src/invariant.ts (86%) rename packages/client/{i18n => locale}/src/locales/en.ts (100%) rename packages/client/{i18n => locale}/src/locales/zh.ts (100%) create mode 100644 packages/client/locale/tests/invariant.spec.ts create mode 100644 packages/client/locale/tests/locale.spec.ts rename packages/client/{i18n => locale}/tsconfig.json (87%) create mode 100644 packages/client/locale/tsdown.config.ts create mode 100644 packages/client/ui-layout/src/client/theme-presenter.ts create mode 100644 packages/client/ui-layout/tests/theme-presenter.spec.ts create mode 100644 packages/client/ui-settings-general/README.md create mode 100644 packages/client/ui-settings-general/package.json create mode 100644 packages/client/ui-settings-general/src/client/GeneralSection.module.css create mode 100644 packages/client/ui-settings-general/src/client/GeneralSection.tsx create mode 100644 packages/client/ui-settings-general/src/client/contract.ts create mode 100644 packages/client/ui-settings-general/src/client/index.ts create mode 100644 packages/client/ui-settings-general/src/client/locales.ts create mode 100644 packages/client/ui-settings-general/src/client/store.ts create mode 100644 packages/client/ui-settings-general/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-general/src/index.ts create mode 100644 packages/client/ui-settings-general/src/invariant.ts create mode 100644 packages/client/ui-settings-general/tsconfig.json create mode 100644 packages/client/ui-settings-general/tsdown.config.ts create mode 100644 packages/client/ui-settings-models/README.md create mode 100644 packages/client/ui-settings-models/package.json create mode 100644 packages/client/ui-settings-models/src/client/ModelsSection.tsx create mode 100644 packages/client/ui-settings-models/src/client/index.ts create mode 100644 packages/client/ui-settings-models/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-models/src/index.ts create mode 100644 packages/client/ui-settings-models/src/invariant.ts create mode 100644 packages/client/ui-settings-models/tsconfig.json create mode 100644 packages/client/ui-settings-models/tsdown.config.ts create mode 100644 packages/client/ui-settings/README.md create mode 100644 packages/client/ui-settings/package.json create mode 100644 packages/client/ui-settings/src/client/SettingsRoot.module.css create mode 100644 packages/client/ui-settings/src/client/SettingsRoot.tsx create mode 100644 packages/client/ui-settings/src/client/contract/slots.ts create mode 100644 packages/client/ui-settings/src/client/index.ts create mode 100644 packages/client/ui-settings/src/css-modules.d.ts create mode 100644 packages/client/ui-settings/src/index.ts create mode 100644 packages/client/ui-settings/src/invariant.ts create mode 100644 packages/client/ui-settings/tsconfig.json create mode 100644 packages/client/ui-settings/tsdown.config.ts diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md new file mode 100644 index 0000000000..d3c61794b9 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -0,0 +1,99 @@ +# Agent Note: Client Settings、Locale 与 Theme 分层 + +Status: proposed + +## Problem + +浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。 + +## Proposal + +Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由独立插件贡献;Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。 + +Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。 + +`@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`,`ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。 + +General 的 apply 层订阅 `locale/change` 和 `theme/change`,把 snapshot 投影到该 section 声明的 Zustand store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。 + +Theme 偏好三态:`light`、`dark`、`system`,默认 `system`(无持久化偏好或坏值时)。system 的解析属主题领域:ThemeService 持有 `prefers-color-scheme` matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshot;snapshot 同时携带 `preference` 与解析后的 `active` 定义。 + +Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。 + +### 首期 section 范围 + +| section | 插件 | 首期内容 | +|---|---|---| +| General | `ui-settings-general` | Language(Selector 下拉)与 Appearance(Light/Dark/System 三 cube)真实可切;Permission、Tool Call 仅视觉骨架,无写操作 | +| Models | `ui-settings-models` | 仅导航项,内容区为空 | +| Plugin | 不建包 | 首期不做,导航不出现该项(无目标的外链入口不上屏;后续插件注册 section 即自动出现) | + +首期只翻译 Settings 浮层内文案(General 各行 + 导航);其他页面文案不动。 + +### Slot topology + +```text +root +└─ sidebar + └─ sidebar.settings single/root + └─ ui-settings + └─ settings.section list/root + ├─ general ui-settings-general + └─ models ui-settings-models +``` + +section contribution 使用 declaration-aware deferral,不依赖 client manifest 的 apply 顺序。 + +### Service contracts + +```ts +type ThemePreference = 'light' | 'dark' | 'system' + +interface ThemeSnapshot { + preference: ThemePreference + active: ThemeDefinition // system 已解析为具体 light/dark 定义 + themes: readonly ThemeDefinition[] + revision: number +} + +interface LocaleSnapshot { + active: 'zh' | 'en' + locales: readonly LocaleDefinition[] + revision: number +} + +interface Events { + /** @param snapshot - Current locale registry snapshot. @mode emit */ + 'locale/change'(snapshot: LocaleSnapshot): void + /** @param snapshot - Current theme registry snapshot. @mode emit */ + 'theme/change'(snapshot: ThemeSnapshot): void +} +``` + +Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未知 id 失败。 + +## Alternatives considered + +**由 app shell 统一订阅偏好并重渲染 root slot tree。** 语言和主题变化只需要更新实际消费者;全树刷新放大影响面,也把业务偏好接入 shell。 + +**Theme service 直接修改 DOM。** registry service 因此依赖呈现环境,生命周期与全局样式所有权不清;Layout 已经拥有页面根呈现边界。 + +**system 由 Layout presenter 解析。** presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义,呈现层被迫理解偏好语义;解析放服务侧则所有消费者拿到一致的已解析 snapshot。 + +**Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。 + +**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。 + +## Acceptance criteria + +- Settings 壳只依赖 slot ledger,不依赖任一 section 实现。 +- Locale 与 Theme 的写入只走 setter,持续同步只走 change event。 +- General store 初始化走 getter,后续由两个 event 更新并局部重渲染。 +- Layout 独立应用 Theme snapshot,Theme service 不访问 DOM;presenter 不出现 system 分支。 +- 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。 +- Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。 +- 浮层经 close 按钮、遮罩点击、ESC 均可关闭。 + +## Risks + +slot 声明与 contribution 的 apply 顺序不固定,所有新 section 必须保留 declaration-aware registration 和幂等防护。service event 可能早于 section 首次渲染,General store 的 init 与 controller attach 都必须从 getter 对齐当前 snapshot。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b89df77c46..8412a313b4 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -221,8 +221,8 @@ - id: ui-theme name: '@deepseek-ai/dsh-client-ui-theme' -- id: i18n - name: '@deepseek-ai/dsh-client-i18n' +- id: locale + name: '@deepseek-ai/dsh-client-locale' - id: ui-layout name: '@deepseek-ai/dsh-client-ui-layout' @@ -230,6 +230,15 @@ - id: ui-sidebar name: '@deepseek-ai/dsh-client-ui-sidebar' +- id: ui-settings + name: '@deepseek-ai/dsh-client-ui-settings' + +- id: ui-settings-general + name: '@deepseek-ai/dsh-client-ui-settings-general' + +- id: ui-settings-models + name: '@deepseek-ai/dsh-client-ui-settings-models' + - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/apps/cli/package.json b/apps/cli/package.json index e1c07f90b5..74a6e417e1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -23,13 +23,16 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", - "@deepseek-ai/dsh-client-i18n": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 4db4861b93..7614ae2e31 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -42,7 +42,16 @@ "path": "../../packages/client/ui-theme" }, { - "path": "../../packages/client/i18n" + "path": "../../packages/client/ui-settings" + }, + { + "path": "../../packages/client/ui-settings-general" + }, + { + "path": "../../packages/client/ui-settings-models" + }, + { + "path": "../../packages/client/locale" }, { "path": "../../packages/client/ui-layout" diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index c1616bb724..b8ee3408a3 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -10,9 +10,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a3d511df16..70d1d81523 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -147,7 +147,7 @@ async function detailsTrack(page: Page): Promise { // Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI // plugin's client bundle exists and exports apply, the loader fail-louds and // the frame never appears. -const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory'] +const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory'] const ROUND_DONE_MARKER = 'WEB_ROUND_DONE' const notReady = UI_PLUGIN_DIRS.filter((dir) => { const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js') diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 78ac843a64..e232d8e123 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -10,9 +10,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', diff --git a/packages/client/i18n/README.md b/packages/client/i18n/README.md deleted file mode 100644 index db6be0fe2a..0000000000 --- a/packages/client/i18n/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# @deepseek-ai/dsh-client-i18n - -i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8. - -## Model Experience - -None, as the i18n registry serves browser UI copy; nothing here reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks. -- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity. diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts deleted file mode 100644 index 9dea9c4bd4..0000000000 --- a/packages/client/i18n/src/client/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Browser-side locale registry. Bound translation functions retain stable - * identity for injected consumers. - */ -import type { Context } from 'cordis' -// Snapshot stores are framework-neutral; React consumers bind hooks at their -// rendering boundary. -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { en } from '../locales/en.ts' -import { zh } from '../locales/zh.ts' - -/** Translate a key with optional params. */ -export type Translate = (key: string, params?: Record) => string - -/** Locale dictionary: flat key to template string ({name} placeholders). */ -export type LocaleDict = Record - -declare module 'cordis' { - interface Context { - i18n: I18nService - } -} - -/** Fallback locale consulted after the active locale misses. */ -export const FALLBACK_LOCALE = 'zh' - -/** Shared namespace for shell-level texts. */ -export const COMMON_NS = 'common' - -/** - * Dictionary registry plus locale switch. Lookup chain per key: active locale - * -> zh fallback -> the key itself (missing text stays visible, fail loud in - * the UI rather than blank). - */ -export class I18nService { - private dicts = new Map>() - private bound = new Map() - private localeStore = createSnapshotStore(FALLBACK_LOCALE) - - /** - * Register a dictionary for a namespace and locale. Duplicate (ns, locale) - * throws (single occupant; a namespace's texts have one owner). - * @param ns - namespace. - * @param locale - locale tag (zh/en to start). - * @param dict - dictionary. - * @returns disposer (idempotent). - */ - register(ns: string, locale: string, dict: LocaleDict): () => void { - let locales = this.dicts.get(ns) - if (!locales) { - locales = new Map() - this.dicts.set(ns, locales) - } - if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`) - locales.set(locale, dict) - return () => { - const owner = this.dicts.get(ns) - if (owner?.get(locale) === dict) owner.delete(locale) - } - } - - /** - * Bind a namespace to a translate function. The returned reference is - * stable per namespace (repeat binds return the same function), so it can - * ride inject surfaces without breaking memoization. - * @param ns - namespace. - * @returns the translate function (reads the locale store at call time). - */ - bind(ns: string): Translate { - let t = this.bound.get(ns) - if (!t) { - t = (key, params) => this.translate(ns, key, params) - this.bound.set(ns, t) - return t - } - return t - } - - /** Active locale store (switching re-renders the tree; low frequency). */ - get locale(): SnapshotStore { - return this.localeStore - } - - private translate(ns: string, key: string, params?: Record): string { - const locales = this.dicts.get(ns) - const template = locales?.get(this.localeStore.getSnapshot())?.[key] - ?? locales?.get(FALLBACK_LOCALE)?.[key] - ?? key - if (!params) return template - return template.replace(/\{(\w+)\}/g, (match, name: string) => - name in params ? String(params[name]) : match) - } -} - -/** Required services (none; the loader passes the export surface as an object plugin). */ -export const inject: string[] = [] - -/** - * Client plugin body: provide the i18n service with base dictionaries. - * @param ctx - client cordis context. - */ -export function apply(ctx: Context): void { - const i18n = new I18nService() - i18n.register(COMMON_NS, 'zh', zh) - i18n.register(COMMON_NS, 'en', en) - ctx.provide('i18n', i18n) -} diff --git a/packages/client/i18n/tests/i18n.spec.ts b/packages/client/i18n/tests/i18n.spec.ts deleted file mode 100644 index 12fef2c716..0000000000 --- a/packages/client/i18n/tests/i18n.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { I18nService } from '@deepseek-ai/dsh-client-i18n/client' - -describe('I18nService', () => { - it('translates from the active locale with zh fallback then key passthrough', () => { - const i18n = new I18nService() - i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' }) - i18n.register('ns', 'en', { hello: 'Hello' }) - const t = i18n.bind('ns') - expect(i18n.locale.getSnapshot()).toBe('zh') - expect(t('hello')).toBe('你好') - i18n.locale.set('en') - expect(t('hello')).toBe('Hello') - expect(t('onlyZh')).toBe('仅中文') - expect(t('missing.key')).toBe('missing.key') - }) - - it('interpolates {name} params and leaves unknown placeholders intact', () => { - const i18n = new I18nService() - i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' }) - const t = i18n.bind('ns') - expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次') - expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}') - expect(t('greet')).toBe('你好,{name}!第 {n} 次') - }) - - it('bind returns a stable reference per namespace', () => { - const i18n = new I18nService() - expect(i18n.bind('a')).toBe(i18n.bind('a')) - expect(i18n.bind('a')).not.toBe(i18n.bind('b')) - }) - - it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => { - const i18n = new I18nService() - const dispose = i18n.register('ns', 'zh', { k: 'v1' }) - expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale') - dispose() - dispose() - const t = i18n.bind('ns') - expect(t('k')).toBe('k') - i18n.register('ns', 'zh', { k: 'v2' }) - expect(t('k')).toBe('v2') - }) - - it('locale store is subscribable (snapshot store contract)', () => { - const i18n = new I18nService() - let notified = 0 - i18n.locale.subscribe(() => { notified += 1 }) - i18n.locale.set('en') - expect(i18n.locale.getSnapshot()).toBe('en') - expect(notified).toBe(1) - }) -}) diff --git a/packages/client/i18n/tests/invariant.spec.ts b/packages/client/i18n/tests/invariant.spec.ts deleted file mode 100644 index b992e50d52..0000000000 --- a/packages/client/i18n/tests/invariant.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n' -import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client' -import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/invariant' -import InvariantService from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(I18nInvariant).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('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => { - expect(inject).toEqual([]) - const ctx = new Context() - await ctx.plugin({ inject, apply: clientApply }).await() - const i18n = ctx.get('i18n') - expect(i18n).toBeInstanceOf(I18nService) - // Seeded dictionaries occupy the (ns, locale) seats even while empty. - expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale') - expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale') - }) -}) diff --git a/packages/client/i18n/tsdown.config.ts b/packages/client/i18n/tsdown.config.ts deleted file mode 100644 index 1c0802be49..0000000000 --- a/packages/client/i18n/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md new file mode 100644 index 0000000000..078967c2cc --- /dev/null +++ b/packages/client/locale/README.md @@ -0,0 +1,16 @@ +# @deepseek-ai/dsh-client-locale + +Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key). + +## Model Experience + +None, as the locale registry serves browser UI copy; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred. +- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount. diff --git a/packages/client/i18n/package.json b/packages/client/locale/package.json similarity index 78% rename from packages/client/i18n/package.json rename to packages/client/locale/package.json index 3e617174bf..340efe042e 100644 --- a/packages/client/i18n/package.json +++ b/packages/client/locale/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-i18n", - "description": "i18n plugin: I18nService (ns x locale dictionaries, bind(ns) -> t, locale store); zh/en skeleton", + "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)", "version": "0.0.1", "private": true, "type": "module", @@ -28,9 +28,6 @@ "immediately": true }, "license": "BSD-3-Clause", - "dependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^" - }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -46,5 +43,9 @@ "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" - ] + ], + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + } } diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts new file mode 100644 index 0000000000..e01d69ea25 --- /dev/null +++ b/packages/client/locale/src/client/index.ts @@ -0,0 +1,195 @@ +/** + * Browser-side locale registry. Bound translation functions retain stable + * identity for injected consumers. + */ +import type { Context } from 'cordis' +import { en } from '../locales/en.ts' +import { zh } from '../locales/zh.ts' + +/** Translate a key with optional params. */ +export type Translate = (key: string, params?: Record) => string + +/** 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). */ + id: LocaleId + /** Display name in its own language (中文 / English). */ + label: string +} + +/** Immutable locale state published on every change. */ +export interface LocaleSnapshot { + /** Active locale id. */ + active: LocaleId + /** Selectable locales in display order. */ + locales: readonly LocaleDefinition[] + /** Monotonic change counter (registry or active changes). */ + revision: number +} + +declare module 'cordis' { + interface Context { + locale: LocaleService + } + interface Events { + /** + * Locale state changed (active locale switched or registry updated). + * @param snapshot - Current immutable locale snapshot. + * @mode emit + */ + 'locale/change'(snapshot: LocaleSnapshot): void + } +} + +/** Fallback locale consulted after the active locale misses (also the default). */ +export const FALLBACK_LOCALE: LocaleId = 'zh' + +/** Shared namespace for shell-level texts. */ +export const COMMON_NS = 'common' + +/** 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: '中文' }, + { id: 'en', label: 'English' }, +]) + +/** + * Dictionary registry plus locale preference. Lookup chain per key: active + * locale -> zh fallback -> the key itself (missing text stays visible, fail + * loud in the UI rather than blank). Reads go through {@link getLocale}; + * writes only through {@link setLocale}; continuous sync only through the + * `locale/change` event. + */ +export class LocaleService { + private dicts = new Map>() + private bound = new Map() + private snapshot: LocaleSnapshot + private readonly ctx: Context + + /** + * @param ctx - owning context (change events are emitted on it). + */ + constructor(ctx: Context) { + this.ctx = ctx + this.snapshot = Object.freeze({ active: restorePreference(), locales: LOCALES, revision: 0 }) + } + + /** + * Read the current immutable locale snapshot. + * @returns the current snapshot (stable reference until the next change). + */ + getLocale(): LocaleSnapshot { + return this.snapshot + } + + /** + * Switch the active locale — the only preference write entry. Persists the + * id and emits `locale/change`. + * @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 + this.snapshot = Object.freeze({ + active: match.id, + locales: this.snapshot.locales, + revision: this.snapshot.revision + 1, + }) + persistPreference(match.id) + this.ctx.emit('locale/change', this.snapshot) + } + + /** + * Register a dictionary for a namespace and locale. Duplicate (ns, locale) + * throws (single occupant; a namespace's texts have one owner). + * @param ns - namespace. + * @param locale - locale tag (zh/en to start). + * @param dict - dictionary. + * @returns disposer (idempotent). + */ + register(ns: string, locale: string, dict: LocaleDict): () => void { + let locales = this.dicts.get(ns) + if (!locales) { + locales = new Map() + this.dicts.set(ns, locales) + } + if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`) + locales.set(locale, dict) + return () => { + const owner = this.dicts.get(ns) + if (owner?.get(locale) === dict) owner.delete(locale) + } + } + + /** + * Bind a namespace to a translate function. The returned reference is + * stable per namespace (repeat binds return the same function), so it can + * ride inject surfaces without breaking memoization. + * @param ns - namespace. + * @returns the translate function (reads the active locale at call time). + */ + bind(ns: string): Translate { + let t = this.bound.get(ns) + if (!t) { + t = (key, params) => this.translate(ns, key, params) + this.bound.set(ns, t) + return t + } + return t + } + + private translate(ns: string, key: string, params?: Record): string { + const locales = this.dicts.get(ns) + const template = locales?.get(this.snapshot.active)?.[key] + ?? locales?.get(FALLBACK_LOCALE)?.[key] + ?? key + if (!params) return template + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match) + } +} + +/** Read the persisted locale id; unknown or unreadable values fall back to zh. */ +function restorePreference(): LocaleId { + try { + const stored = globalThis.localStorage?.getItem(STORAGE_KEY) + if (stored === 'zh' || stored === 'en') return stored + } catch { + // Storage access can throw (privacy mode); the default below covers it. + } + return FALLBACK_LOCALE +} + +/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ +function persistPreference(id: LocaleId): void { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, id) + } catch { + // Storage access can throw (privacy mode / quota); the preference simply + // does not survive the session. + } +} + +/** Required services (none; the loader passes the export surface as an object plugin). */ +export const inject: string[] = [] + +/** + * Client plugin body: provide the locale service with base dictionaries. + * @param ctx - client cordis context. + */ +export function apply(ctx: Context): void { + const locale = new LocaleService(ctx) + locale.register(COMMON_NS, 'zh', zh) + locale.register(COMMON_NS, 'en', en) + ctx.provide('locale', locale) +} diff --git a/packages/client/i18n/src/index.ts b/packages/client/locale/src/index.ts similarity index 61% rename from packages/client/i18n/src/index.ts rename to packages/client/locale/src/index.ts index e759f1edc1..c220373932 100644 --- a/packages/client/i18n/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,4 +1,4 @@ /** Host loader entry for the browser implementation exported from `./client`. */ -/** Host plugin body — no host-side behavior for the i18n plugin. */ +/** Host plugin body — no host-side behavior for the locale plugin. */ export function apply(): void {} diff --git a/packages/client/i18n/src/invariant.ts b/packages/client/locale/src/invariant.ts similarity index 86% rename from packages/client/i18n/src/invariant.ts rename to packages/client/locale/src/invariant.ts index b2c196e93b..96c94018f0 100644 --- a/packages/client/i18n/src/invariant.ts +++ b/packages/client/locale/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`. - * @module @deepseek-ai/dsh-client-i18n/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-locale`. + * @module @deepseek-ai/dsh-client-locale/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale' /** Cordis companion plugin name. */ -export const name = 'client-i18n-invariant' +export const name = 'client-locale-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/client/i18n/src/locales/en.ts b/packages/client/locale/src/locales/en.ts similarity index 100% rename from packages/client/i18n/src/locales/en.ts rename to packages/client/locale/src/locales/en.ts diff --git a/packages/client/i18n/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts similarity index 100% rename from packages/client/i18n/src/locales/zh.ts rename to packages/client/locale/src/locales/zh.ts diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts new file mode 100644 index 0000000000..e9f4fdee36 --- /dev/null +++ b/packages/client/locale/tests/invariant.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' +import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client' +import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + 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('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { + expect(inject).toEqual([]) + const ctx = new Context() + await ctx.plugin({ inject, apply: clientApply }).await() + const locale = ctx.get('locale') + expect(locale).toBeInstanceOf(LocaleService) + // Seeded dictionaries occupy the (ns, locale) seats even while empty. + expect(() => (locale as LocaleService).register(COMMON_NS, 'zh', {})).toThrow('already has locale') + expect(() => (locale as LocaleService).register(COMMON_NS, 'en', {})).toThrow('already has locale') + }) +}) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts new file mode 100644 index 0000000000..750c2452e8 --- /dev/null +++ b/packages/client/locale/tests/locale.spec.ts @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } 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' + +const make = (): { 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 } +} + +describe('LocaleService', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('translates through the active-locale -> zh -> key chain', () => { + const { svc } = make() + svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' }) + svc.register('ns', 'en', { hello: 'Hello' }) + const t = svc.bind('ns') + expect(svc.getLocale().active).toBe('zh') + expect(t('hello')).toBe('你好') + svc.setLocale('en') + expect(t('hello')).toBe('Hello') + expect(t('onlyZh')).toBe('仅中文') + expect(t('missing.key')).toBe('missing.key') + }) + + it('interpolates {name} params and leaves unknown placeholders intact', () => { + const { svc } = make() + svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' }) + const t = svc.bind('ns') + expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次') + expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}') + }) + + it('bind returns a stable per-namespace function identity', () => { + const { svc } = make() + expect(svc.bind('a')).toBe(svc.bind('a')) + expect(svc.bind('a')).not.toBe(svc.bind('b')) + }) + + it('rejects duplicate (ns, locale) and disposer only removes its own dict', () => { + const { svc } = make() + const dispose = svc.register('ns', 'zh', { k: 'v1' }) + expect(() => svc.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale') + dispose() + const t = svc.bind('ns') + expect(t('k')).toBe('k') + svc.register('ns', 'zh', { k: 'v2' }) + expect(t('k')).toBe('v2') + dispose() + expect(t('k')).toBe('v2') + }) + + it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { + const { svc, events } = make() + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + expect(localStorage.getItem(STORAGE_KEY)).toBe('en') + expect(events).toHaveLength(1) + expect(events[0]).toBe(svc.getLocale()) + expect(events[0]!.revision).toBe(1) + svc.setLocale('en') + expect(events).toHaveLength(1) + }) + + it('throws on unknown locale ids', () => { + const { svc } = make() + expect(() => { svc.setLocale('fr') }).toThrow('not registered') + }) + + it('restores a persisted locale and falls back to zh on garbage', () => { + 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('exposes the two shipped locales with self-described labels', () => { + const { svc } = make() + expect(svc.getLocale().locales).toEqual([ + { id: 'zh', label: '中文' }, + { id: 'en', label: 'English' }, + ]) + }) +}) diff --git a/packages/client/i18n/tsconfig.json b/packages/client/locale/tsconfig.json similarity index 87% rename from packages/client/i18n/tsconfig.json rename to packages/client/locale/tsconfig.json index 63eb9779ca..51f9171643 100644 --- a/packages/client/i18n/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../runtime" - }, { "path": "../../support/invariants" } diff --git a/packages/client/locale/tsdown.config.ts b/packages/client/locale/tsdown.config.ts new file mode 100644 index 0000000000..2141970e5d --- /dev/null +++ b/packages/client/locale/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 56bdd29320..9b93feae8b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -33,7 +33,7 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools| * Documented TEMPORARY exemption, not a platform module (hence not in * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ * shallowEqual) lives in runtime pending its promotion-time rehoming, and - * five importers (i18n, ui-layout, ui-conversation ×3) ride this single + * five importers (locale, ui-layout, ui-conversation ×3) ride this single * exemption. At runtime the lazy CJS table answers the require natively: * runtime is an immediately-tier row, its factory is registered before any * dependent bundle materializes. TODO(webload/store-rehome): remove with the diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index d7306a6810..95fde30b5a 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -24,7 +24,7 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-client-i18n", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-layout" ], diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 21fe70499e..b040e847e0 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -99,7 +99,7 @@ async function bench() { ctx.provide('workspaces', workspacesFake) const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() } ctx.provide('layout', layoutFake) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) // The AppFrame role: the three conversation-package slots must be declared // by a live entry before apply can contribute into them (the stand-in diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index dcfe2d7de7..31b251843a 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -48,7 +48,7 @@ async function bench() { sendSession: vi.fn(), }) ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) // Declared by ui-layout's root entry in production; a stand-in root // occupant declares them here so the contributions land (it consumes diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index bfae743cff..959ee871d8 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -93,7 +93,7 @@ async function bench(nodes: ToolResultNode[]) { sendSession: vi.fn(), }) ctx.provide('layout', layout) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) slots.install(createSlotRenderer()) slots.register({ @@ -212,7 +212,7 @@ describe('registrant load-order seam', () => { sendSession: vi.fn(), }) ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) slots.register({ name: 'root', children: { diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 7755337093..902c1c7f9f 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -27,7 +27,7 @@ "path": "../ui-layout" }, { - "path": "../i18n" + "path": "../locale" }, { "path": "../../support/invariants" diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 4a9f5e9bd5..a8c3bc0bd6 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-layout -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables). AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face. diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index fd3e691375..17459ee5d2 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -24,7 +24,8 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-client-runtime" + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-theme" ], "platform": "web" }, @@ -36,6 +37,7 @@ "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-ui-theme": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" @@ -43,6 +45,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 6ecb6f6b1d..39fee1fb52 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -4,13 +4,16 @@ * four child slots (declaration = exclusive render authority), seats the * layout store (panel geometry), and wires the panel-action service face. * ctx.layout is the cross-plugin panel-action seam; navigation state lives - * with the runtime sessions service. + * with the runtime sessions service. A second effect seats the theme + * presenter, which projects ctx.theme snapshots onto document.body. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import type { PanelActions } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutService } from './service.ts' +import { ThemePresenter } from './theme-presenter.ts' // Contract surface only (export-convergence rule: cross-package consumers // keep a symbol exported; test-only/package-internal symbols live off /src). @@ -62,7 +65,7 @@ export interface DetailsOwnerProps {} export interface EmptyOwnerProps { children?: never } /** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ -export const inject = ['slots'] +export const inject = ['slots', 'theme'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame @@ -98,4 +101,16 @@ export function apply(ctx: ClientContext): void { void disposeService() } }, 'ui-layout: service + root registration') + + // Theme presentation: pure DOM writes from resolved snapshots — initial + // state through the getter once, then event-driven only; no React path. + ctx.effect(() => { + const presenter = new ThemePresenter() + presenter.apply(ctx.theme.getTheme()) + const off = ctx.on('theme/change', snapshot => { presenter.apply(snapshot) }) + return () => { + off() + presenter.dispose() + } + }, 'ui-layout: theme presenter') } diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts new file mode 100644 index 0000000000..958f2fd93e --- /dev/null +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -0,0 +1,43 @@ +/** + * Global theme DOM applier: projects the resolved ThemeSnapshot onto + * document.body — the `data-ds-dark-theme` palette switch plus the active + * theme's alias-token overrides as inline CSS variables. Pure DOM writes, no + * React involvement; the presenter only ever retracts what it wrote itself, + * so foreign body attributes and inline styles survive apply/dispose. + */ +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' + +/** Body attribute selecting the dark base palette in the token stylesheets. */ +export const DARK_ATTRIBUTE = 'data-ds-dark-theme' + +/** Applies theme snapshots to document.body; one instance per plugin fiber. */ +export class ThemePresenter { + /** Token names this presenter wrote in the last apply (its retraction set). */ + private appliedTokens: string[] = [] + + /** + * Project a snapshot onto the body: switch the palette attribute from + * `active.colorScheme` (never the id — `system` is resolved upstream) and + * replace the previously applied token variables with `active.tokens`. + * @param snapshot - resolved theme snapshot from ctx.theme. + */ + apply(snapshot: ThemeSnapshot): void { + const body = document.body + if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '') + else body.removeAttribute(DARK_ATTRIBUTE) + for (const name of this.appliedTokens) body.style.removeProperty(name) + this.appliedTokens = [] + for (const [name, value] of Object.entries(snapshot.active.tokens)) { + body.style.setProperty(name, value) + this.appliedTokens.push(name) + } + } + + /** Retract everything this presenter wrote: the palette attribute and all applied token variables. */ + dispose(): void { + const body = document.body + body.removeAttribute(DARK_ATTRIBUTE) + for (const name of this.appliedTokens) body.style.removeProperty(name) + this.appliedTokens = [] + } +} diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index bed0987f62..29ad4b3058 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -9,6 +9,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' @@ -16,13 +17,14 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) + await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() return { ctx, slots: ctx.get('slots') as SlotsService } } describe('ui-layout client apply', () => { it('declares its service dependencies', () => { - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'theme']) }) it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => { @@ -53,6 +55,23 @@ describe('ui-layout client apply', () => { expect(actions.toggleSidebar).toHaveBeenCalledOnce() }) + it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => { + const { ctx } = await bench() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + // Initial getter application: jsdom has no matchMedia, system resolves light. + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + const theme = ctx.get('theme') as ThemeService + theme.setTheme('dark') + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + await fiber.dispose() + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + // Listener is off: further theme changes no longer reach the body. + theme.setTheme('light') + theme.setTheme('dark') + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + }) + it('teardown unwinds the service, the root registration, and the child declarations', async () => { const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts new file mode 100644 index 0000000000..ced83a379e --- /dev/null +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// ThemePresenter behavior account: the palette attribute follows +// active.colorScheme only, token variables replace the previous apply's set, +// and dispose retracts everything the presenter wrote. + +import { beforeEach, describe, expect, it } from 'vitest' +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts' + +function snapshot(colorScheme: 'light' | 'dark', tokens: Record = {}): ThemeSnapshot { + // The presenter must key off colorScheme, not the id — keep them distinct. + const active = { id: `${colorScheme}-test`, colorScheme, tokens } + return { preference: colorScheme, active, themes: [active], revision: 1 } +} + +beforeEach(() => { + document.body.removeAttribute(DARK_ATTRIBUTE) + document.body.removeAttribute('style') +}) + +describe('ThemePresenter', () => { + it('light scheme leaves the dark attribute absent', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('light')) + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + }) + + it('dark scheme sets the attribute; switching back to light removes it', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('dark')) + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true) + presenter.apply(snapshot('light')) + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + }) + + it('applies tokens as inline variables and clears the previous set on theme change', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111', '--dsw-alias-fg': '#eee' })) + expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#111') + expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('#eee') + presenter.apply(snapshot('light', { '--dsw-alias-bg': '#fff' })) + expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#fff') + // The old theme's extra variable is gone, not merged. + expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('') + }) + + it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => { + document.body.style.setProperty('--foreign', 'kept') + const presenter = new ThemePresenter() + presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' })) + presenter.dispose() + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('') + expect(document.body.style.getPropertyValue('--foreign')).toBe('kept') + }) +}) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index c82482294b..4401e8e4db 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../ui-slots" }, + { + "path": "../ui-theme" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index b55abe094d..3cabe258bc 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -30,11 +30,13 @@ /* Portal mode: fixed in the viewport, coordinates supplied inline from the * anchor rect (side/align resolved in JS, the in-place offset rules above - * don't apply). */ + * don't apply). Portaled lists must layer above modal overlays (z 1000) — + * an anchor inside a dialog still expects its menu on top. */ .portal { position: fixed; top: auto; left: auto; + z-index: 1100; } /* Open above the anchor (empty-state workspace chip: figma 122:9481). */ diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 80bb3848dd..4c2083bae1 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -583,3 +583,90 @@ export const IconTreeCorner8x10 = ({ size = 10, className }: IconProps) => ( ) + +/** ic_ds_light_outline_16 */ +export const IconLightOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + + + + +) + +/** ic_ds_dark_outline_16 */ +export const IconDarkOutline16 = ({ size = 16, className }: IconProps) => ( + + + +) + +/** ic_ds_followsystem_outline_16 */ +export const IconFollowsystemOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** ic_ds_data_outline_16 */ +export const IconDataOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** ic_ds_List_Pen_outline_16 */ +export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + +) diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 74bf5a9678..281124b8d5 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (43 deepsuite + 7 figma extracts)', () => { - expect(iconNames.length).toBe(50) + it('exports the full P-I set (43 deepsuite + 12 figma extracts)', () => { + expect(iconNames.length).toBe(55) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => { diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md new file mode 100644 index 0000000000..435dd8c1b4 --- /dev/null +++ b/packages/client/ui-settings-general/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-settings-general + +General settings section plugin: registers the `general` entry into `settings.section`. Language (中文/English) and Appearance (Light/Dark/System) are live preferences wired to `ctx.locale` / `ctx.theme`; Permission and Tool Call rows are visual skeletons with no write surface. + +## Model Experience + +None, as the section renders browser preference UI; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json new file mode 100644 index 0000000000..9b3d83e472 --- /dev/null +++ b/packages/client/ui-settings-general/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-general", + "description": "General settings section plugin: Language and Appearance preferences (live), Permission and Tool Call skeleton rows", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-theme" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@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", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css new file mode 100644 index 0000000000..dffafc419d --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -0,0 +1,131 @@ +/* General section rows (figma 501:29983 'Options'): four groups, 16px + * vertical padding each, hairline separator under all but the last. The + * shell's content column owns the outer horizontal padding. */ + +.section { + display: flex; + flex-direction: column; + width: 100%; +} + +/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */ +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */ +.group { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.last { + border-bottom: none; +} + +/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */ +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:disabled { + cursor: default; +} + +.chevron { + flex: none; +} + +/* Cube rows share an 8px gap; cubes stretch to equal height. */ +.cubeRow { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset = + * outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */ +.modeCube { + box-sizing: border-box; + width: 418px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 8px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: transparent; + text-align: left; +} + +/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered + * icon-over-label column, gap 4). */ +.themeCube { + box-sizing: border-box; + width: 276px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 20px 32px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: transparent; + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 + * step has no alias-layer name). */ +.selected { + background: var(--dsw-alias-bg-module-platform); + border-color: var(--dsw-static-neutral-bluish-400); +} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx new file mode 100644 index 0000000000..d2947c0435 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -0,0 +1,118 @@ +/** + * General settings section: Permission and Tool Call skeleton rows (visual + * only, no interaction), live Language and Appearance preference rows wired + * through the injected setLocale/setTheme callbacks and the snapshot-mirror + * store. Figma: Settings > Content > Options (501:29983). + */ +import { useState } from 'react' +import clsx from 'clsx' +import { + IconChevronDownOutline14, IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, + Menu, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { GeneralSectionComponentProps, ThemePreferenceId } from './contract.ts' +import css from './GeneralSection.module.css' + +/** Appearance cube order and icons (figma 501:30015-30017: Light, Dark, System). */ +const THEME_CUBES: readonly { id: ThemePreferenceId; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ + { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, + { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, + { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, +] + +/** + * Render the General section content column. + * @param props - composed slot props (contract.ts). + * @returns the section element tree. + */ +export function GeneralSection(props: GeneralSectionComponentProps) { + const { t, setLocale, setTheme, useStore } = props + const localeActive = useStore(s => s.localeActive) + const localeOptions = useStore(s => s.localeOptions) + const themePreference = useStore(s => s.themePreference) + const [languageOpen, setLanguageOpen] = useState(false) + + const activeLocaleLabel = localeOptions.find(l => l.id === localeActive)?.label ?? localeActive + + return ( +
+ {/* Permission (skeleton): disabled selector pill. */} +
+
+
{t('permission.title')}
+
{t('permission.desc')}
+
+ +
+ + {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} +
+
{t('toolcall.title')}
+
+
+
{t('toolcall.schema.title')}
+
{t('toolcall.schema.desc')}
+
+
+
{t('toolcall.code.title')}
+
{t('toolcall.code.desc')}
+
+
+
+ + {/* Language: selector pill opens the locale menu. */} +
+
+
{t('language.title')}
+
+ { setLanguageOpen(false) }} + items={localeOptions.map(l => ({ id: l.id, label: l.label }))} + selectedId={localeActive} + onSelect={(id) => { + setLocale(id) + setLanguageOpen(false) + }} + align="end" + portal + anchor={( + + )} + /> +
+ + {/* Appearance: three preference cubes; selection follows the persisted + * preference, never the resolved active theme. */} +
+
{t('appearance.title')}
+
+ {THEME_CUBES.map(({ id, labelKey, Icon }) => ( + + ))} +
+
+
+ ) +} diff --git a/packages/client/ui-settings-general/src/client/contract.ts b/packages/client/ui-settings-general/src/client/contract.ts new file mode 100644 index 0000000000..912d925615 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/contract.ts @@ -0,0 +1,66 @@ +/** + * General section component contract: the slot-store state shape, the + * injected business face, and the composed props type. The component imports + * only from here; service snapshot shapes are mirrored as plain rows so the + * presentation layer stays decoupled from the locale/theme packages. + */ +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { createGeneralSettingsStore } from './store.ts' + +/** One selectable locale row projected into the store (id + self-described label). */ +export interface LocaleOptionRow { + /** Locale id (the setLocale argument). */ + id: string + /** Display name in its own language (中文 / English). */ + label: string +} + +/** Theme preference union mirrored from the theme service snapshot. */ +export type ThemePreferenceId = 'light' | 'dark' | 'system' + +/** + * Store state: mirrors of the locale/theme service snapshots, written only by + * the plugin's apply-world change listeners (components have no write path — + * preference writes go through the injected callbacks to the services, and + * the resulting change events flow back into this mirror). + */ +export interface GeneralSettingsState { + /** Active locale id. */ + localeActive: string + /** Selectable locales in display order. */ + localeOptions: LocaleOptionRow[] + /** Locale service revision (re-renders translated copy on dictionary/locale changes); -1 until first sync. */ + localeRevision: number + /** Persisted theme preference (selection state reads this, never the resolved active theme). */ + themePreference: ThemePreferenceId + /** Theme service revision; -1 until first sync. */ + themeRevision: number +} + +/** + * Registrant-private injected share of the General section (assembled in + * apply): the namespace-bound translate function (stable identity — re-render + * on locale change comes from the store revision, not from `t`) and the two + * preference write callbacks. + */ +export interface GeneralSectionInjected { + /** Translate a `settings.general` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the active locale (a registered locale id). */ + setLocale: (id: string) => void + /** Switch the theme preference. */ + setTheme: (id: ThemePreferenceId) => void +} + +/** Store handle type for the props share (type-only; the factory stays internal to apply and tests). */ +export type GeneralSettingsStoreHandle = ReturnType + +/** + * Full component props of the General section: the section owner share + * (empty marker) plus the store share and the injected face. No child slots + * are declared; menu open state is component-local viewing state. + */ +export type GeneralSectionComponentProps = + PropsRuntime<'settings.section'> & PropsStore & GeneralSectionInjected diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts new file mode 100644 index 0000000000..0e59ce2116 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -0,0 +1,111 @@ +/** + * General settings section plugin, browser half. Registers the `general` + * entry into the shell-declared `settings.section` list slot; Language and + * Appearance are live preferences projected from ctx.locale / ctx.theme + * through this entry's slot store. Export discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the locale/theme Context+Events merges and snapshot shapes. +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import type { GeneralSectionInjected } from './contract.ts' +import { createGeneralSettingsStore } from './store.ts' +import { en, zh } from './locales.ts' +import { GeneralSection } from './GeneralSection.tsx' + +export type { + GeneralSectionComponentProps, GeneralSectionInjected, GeneralSettingsState, + GeneralSettingsStoreHandle, LocaleOptionRow, ThemePreferenceId, +} from './contract.ts' + +/** Dictionary namespace owned by this section (also the nav-label reference prefix). */ +const NS = 'settings.general' + +/** + * Required services (cordis fiber inject). The target slot is declared by + * ui-settings' apply, whose activation order relative to this one is NOT + * constrained; registration goes through declaration-aware deferral. + */ +export const inject = ['slots', 'locale', 'theme'] + +/** + * Register the `settings.general` dictionaries and the General section entry + * once the `settings.section` declaration is on the ledger. The slot store + * mirrors the locale/theme snapshots: change listeners attach here in apply, + * write through the bound actions captured at inject time, and the inject + * factory re-syncs from the getters so no event is lost between registration + * and first render (the store's revision guard drops stale duplicates). + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposeZh = ctx.locale.register(NS, 'zh', zh) + const disposeEn = ctx.locale.register(NS, 'en', en) + return () => { + disposeZh() + disposeEn() + } + }, 'ui-settings-general: dictionaries') + + const store = createGeneralSettingsStore() + let bound: BoundActions | undefined + + const syncLocale = (snapshot: LocaleSnapshot): void => { + bound?.syncLocale( + snapshot.active, + snapshot.locales.map(l => ({ id: l.id, label: l.label })), + snapshot.revision, + ) + } + const syncTheme = (snapshot: ThemeSnapshot): void => { + bound?.syncTheme(snapshot.preference, snapshot.revision) + } + ctx.on('locale/change', syncLocale) + ctx.on('theme/change', syncTheme) + + const injected = (actions: BoundActions): GeneralSectionInjected => { + bound = actions + syncLocale(ctx.locale.getLocale()) + syncTheme(ctx.theme.getTheme()) + return { + t: ctx.locale.bind(NS), + setLocale: (id) => { ctx.locale.setLocale(id) }, + setTheme: (id) => { ctx.theme.setTheme(id) }, + } + } + + ctx.effect(() => { + let dispose: (() => void) | undefined + const register = (): void => { + dispose = ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: ctx.locale.bind(NS)('nav'), + store, + inject: injected, + }, GeneralSection) + } + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return + register() + } + // Nav labels are registrant-localized: re-register on locale change so + // the ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { + if (dispose === undefined) return + dispose() + register() + }) + const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) + tryRegister() + return () => { + offLocale() + unsubscribe() + dispose?.() + } + }, 'ui-settings-general: section registration') +} diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts new file mode 100644 index 0000000000..ff10ce5c37 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -0,0 +1,42 @@ +/** + * `settings.general` namespace dictionaries. Skeleton-row technical copy + * (Read only / Schema mode / Code mode and their descriptions) is shared + * verbatim across locales per the Figma design. + */ +import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client' + +const SHARED = { + 'permission.value': 'Read only', + 'toolcall.schema.title': 'Schema mode', + 'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time', + 'toolcall.code.title': 'Code mode', + 'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration', +} satisfies LocaleDict + +/** Simplified Chinese dictionary. */ +export const zh: LocaleDict = { + ...SHARED, + 'nav': '通用设置', + 'permission.title': '权限', + 'permission.desc': '选择默认权限模式', + 'toolcall.title': '工具调用', + 'language.title': '语言', + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', +} + +/** English dictionary. */ +export const en: LocaleDict = { + ...SHARED, + 'nav': 'General', + 'permission.title': 'Permission', + 'permission.desc': 'Choose default permission mode', + 'toolcall.title': 'Tool Call', + 'language.title': 'Language', + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} diff --git a/packages/client/ui-settings-general/src/client/store.ts b/packages/client/ui-settings-general/src/client/store.ts new file mode 100644 index 0000000000..121e3f52a9 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/store.ts @@ -0,0 +1,43 @@ +/** + * General section slot store: locale/theme snapshot mirrors. The plugin + * creates the handle at apply time (identity follows the fiber) and its + * change listeners are the only writers; components read via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import type { GeneralSettingsState, LocaleOptionRow, ThemePreferenceId } from './contract.ts' + +/** Declared action shape used to give the exported factory a stable return type. */ +type GeneralSettingsActions = { + syncLocale: (draft: GeneralSettingsState, active: string, options: LocaleOptionRow[], revision: number) => void + syncTheme: (draft: GeneralSettingsState, preference: ThemePreferenceId, revision: number) => void +} + +/** + * Declares the General section state and write surface. Revisions start at -1 + * so the apply-time initial sync (revision 0) always lands as a change. + * @returns the store handle. + */ +export function createGeneralSettingsStore(): EngineStoreHandle { + return defineStore({ + init: (): GeneralSettingsState => ({ + localeActive: '', + localeOptions: [], + localeRevision: -1, + themePreference: 'system', + themeRevision: -1, + }), + actions: { + syncLocale: (d, active: string, options: LocaleOptionRow[], revision: number) => { + if (revision <= d.localeRevision) return + d.localeActive = active + d.localeOptions = options + d.localeRevision = revision + }, + syncTheme: (d, preference: ThemePreferenceId, revision: number) => { + if (revision <= d.themeRevision) return + d.themePreference = preference + d.themeRevision = revision + }, + }, + }) +} diff --git a/packages/client/ui-settings-general/src/css-modules.d.ts b/packages/client/ui-settings-general/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-general/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts new file mode 100644 index 0000000000..94b9bdf674 --- /dev/null +++ b/packages/client/ui-settings-general/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the general settings plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts new file mode 100644 index 0000000000..a40cc3cc00 --- /dev/null +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-general`. + * @module @deepseek-ai/dsh-client-ui-settings-general/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-general-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a section plugin projecting two service change events + * into its own slot store — it emits no cordis events of its own and owns no + * cross-plugin mutable relation; snapshot/store agreement is asserted by this + * package's behavior specs. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json new file mode 100644 index 0000000000..9b2cc5b838 --- /dev/null +++ b/packages/client/ui-settings-general/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../locale" + }, + { + "path": "../ui-theme" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts new file mode 100644 index 0000000000..bf67c4f10f --- /dev/null +++ b/packages/client/ui-settings-general/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md new file mode 100644 index 0000000000..c96e2843e4 --- /dev/null +++ b/packages/client/ui-settings-models/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-settings-models + +Models settings section plugin: registers the `models` nav entry into `settings.section` with an intentionally empty content column — model management lands in a later phase. + +## Model Experience + +None, as the section renders an empty browser UI column; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Content column is empty by design** — provider list, editing form, and activation flow are deferred until the model-management service exists. diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json new file mode 100644 index 0000000000..da8fa8c18d --- /dev/null +++ b/packages/client/ui-settings-models/package.json @@ -0,0 +1,63 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-models", + "description": "Models settings section plugin: nav entry with an empty content column (model management lands later)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx new file mode 100644 index 0000000000..ee33b916cb --- /dev/null +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -0,0 +1,13 @@ +/** + * Models settings section: an intentionally empty content column — the nav + * entry exists so the section slot composition is visible; model management + * lands in a later phase. + */ + +/** + * Render the (empty) Models section content column. + * @returns null — no content this phase. + */ +export function ModelsSection() { + return null +} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts new file mode 100644 index 0000000000..8ad2ebb750 --- /dev/null +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -0,0 +1,63 @@ +/** + * Models settings section plugin, browser half. Registers the `models` nav + * entry into the shell-declared `settings.section` list slot; the content + * column is intentionally empty until model management lands. Export + * discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ModelsSection } from './ModelsSection.tsx' + +/** + * Required services (cordis fiber inject). The target slot is declared by + * ui-settings' apply, whose activation order relative to this one is NOT + * constrained; registration goes through declaration-aware deferral. + */ +export const inject = ['slots', 'locale'] + +/** + * Register the Models section once the `settings.section` declaration is on + * the ledger. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposers = [ + ctx.locale.register('settings.models', 'zh', { nav: '模型' }), + ctx.locale.register('settings.models', 'en', { nav: 'Models' }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings-models: nav copy dictionaries') + ctx.effect(() => { + let dispose: (() => void) | undefined + const register = (): void => { + dispose = ctx.slots.register({ + name: 'settings.section', + id: 'models', + order: 10, + label: ctx.locale.bind('settings.models')('nav'), + }, ModelsSection) + } + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return + register() + } + // Nav labels are registrant-localized: re-register on locale change so + // the ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { + if (dispose === undefined) return + dispose() + register() + }) + const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) + tryRegister() + return () => { + offLocale() + unsubscribe() + dispose?.() + } + }, 'ui-settings-models: section registration') +} diff --git a/packages/client/ui-settings-models/src/css-modules.d.ts b/packages/client/ui-settings-models/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-models/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-models/src/index.ts b/packages/client/ui-settings-models/src/index.ts new file mode 100644 index 0000000000..da3060fe59 --- /dev/null +++ b/packages/client/ui-settings-models/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the models settings plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-models/src/invariant.ts b/packages/client/ui-settings-models/src/invariant.ts new file mode 100644 index 0000000000..9ffdc668d7 --- /dev/null +++ b/packages/client/ui-settings-models/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-models`. + * @module @deepseek-ai/dsh-client-ui-settings-models/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-models' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-models-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a nav-entry-only section plugin rendering a fixed + * empty content column — it emits no cordis events and owns no cross-plugin + * mutable relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json new file mode 100644 index 0000000000..dde94c20af --- /dev/null +++ b/packages/client/ui-settings-models/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../locale" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-settings-models/tsdown.config.ts b/packages/client/ui-settings-models/tsdown.config.ts new file mode 100644 index 0000000000..7a2688a097 --- /dev/null +++ b/packages/client/ui-settings-models/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md new file mode 100644 index 0000000000..64250c7917 --- /dev/null +++ b/packages/client/ui-settings/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-settings + +Settings shell plugin: the sidebar trigger row and the modal settings panel occupying `sidebar.settings`; declares the `settings.section` list slot that section plugins contribute pages into. The shell projects the section ledger into navigation and renders only the active section (`only` filtering). + +## Model Experience + +None, as the settings shell serves browser UI composition; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Panel is browser-preference scope only** — host-side settings (permission mode, tool-call mode) render as skeletons in the General section; no RPC surface exists yet. diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json new file mode 100644 index 0000000000..8f71d90c8b --- /dev/null +++ b/packages/client/ui-settings/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings", + "description": "Settings shell plugin: sidebar trigger + modal panel occupying sidebar.settings; declares the settings.section list slot", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@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", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css new file mode 100644 index 0000000000..bf557c0a04 --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -0,0 +1,192 @@ +/* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar + foot trigger row + centered 1080x700 modal panel. The trigger reproduces + the former sidebar foot geometry (49px wide row / 36px rail circle); the + panel is a two-column layout — 188px nav rail + content column with a + 54px header and the 24px-padded options area. */ + +/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */ +.trigger { + flex: none; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 49px; + margin: 8px 0 0; + padding: 0 2px 0 6px; + border: none; + border-radius: 12px; + background: transparent; + cursor: pointer; + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-family: inherit; + font-size: 14px; +} + +.trigger:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Rail trigger: the same 36x36 circle box as the other rail controls. */ +.trigger.rail { + width: 36px; + height: 36px; + margin: 18px 0 10px; + justify-content: center; + gap: 0; + padding: 0; + border-radius: 50%; +} + +.triggerLabel { + overflow: hidden; + white-space: nowrap; +} + +/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */ +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.mask { + position: absolute; + inset: 0; + background: var(--dsw-alias-bg-mask-1); +} + +/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow + (figma effects match --dsw-shadow-lv3 exactly). */ +.panel { + position: relative; + z-index: 1; + display: flex; + width: 1080px; + height: 700px; + max-width: calc(100vw - 48px); + max-height: calc(100vh - 48px); + border-radius: 24px; + overflow: hidden; + background: var(--dsw-alias-bg-layer-1); + box-shadow: var(--dsw-shadow-lv3); +} + +/* Nav rail (figma .Setting-nav 501:29958): 188 wide, pad (12,22,12,0), + gap 18, no own fill — the panel white shows through. */ +.nav { + flex: none; + display: flex; + flex-direction: column; + gap: 18px; + width: 188px; + padding: 22px 12px 0; + box-sizing: border-box; +} + +/* Title row (figma 501:29959): 16/500 lh24, 12px side padding. */ +.navTitle { + padding: 0 12px; + font-size: 16px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +/* Cell stack (figma 501:29961): gap 4. */ +.navList { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Nav cell (figma .Setting-nav-cell 501:29962): 164x40, r12, pad + (12,9,16,9), gap 8; label 14/400 lh22; selected fill #EBEEF2. */ +.navCell { + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 9px 16px 9px 12px; + box-sizing: border-box; + border: none; + border-radius: 12px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 14px; + line-height: 22px; + font-weight: 400; + color: var(--dsw-alias-label-primary); + text-align: left; +} + +.navCell:hover { + background: var(--dsw-specific-sidebar-nav-item-hover); +} + +.navCell.active { + background: var(--dsw-specific-sidebar-nav-item-active); +} + +.navIcon { + flex: none; +} + +.navLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Content column (figma Content 501:29980): header + options. */ +.content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* Header (figma .Header 501:29981): h54, pad (10,20,14,8), close right. */ +.header { + flex: none; + display: flex; + align-items: flex-start; + justify-content: flex-end; + height: 54px; + padding: 20px 14px 8px 10px; + box-sizing: border-box; +} + +/* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */ +.close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 28px; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-primary); +} + +.close:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */ +.options { + flex: 1; + min-height: 0; + padding: 0 24px 8px; + overflow-y: auto; +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx new file mode 100644 index 0000000000..3a27acdf6c --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -0,0 +1,125 @@ +/** + * Settings shell root: the sidebar-foot trigger row plus the centered modal + * panel (figma 501:29947, 1080x700) with the section nav rail. Modal open + * state and the active section id are component-local viewing state; the + * section ledger arrives through the injected face (nav labels are + * registrant-localized — the shell owns no locale/theme subscription). + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { + IconCloseOutline16, IconDataOutline16, IconSettingsOutline14, IconSettingsOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { SettingsRootComponentProps } from './contract/slots.ts' +import css from './SettingsRoot.module.css' + +/** Nav glyph by section id; unknown ids fall back to the settings gear. */ +function navIcon(id: string) { + if (id === 'models') return + return +} + +type PanelProps = { + translate: SettingsRootComponentProps['translate'] + rows: ReturnType + renderSlot: SettingsRootComponentProps['renderSlot'] + onClose: () => void +} + +/** + * The modal layer: full-viewport mask + centered panel. Close paths: the + * header button, a mask click, and document-level Escape (mounted only while + * open, so the listener lifetime is the panel's). + */ +function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) { + // Local selection; entries can unmount underneath it, so the render-time + // projection falls back to the first row when the id is gone. + const [activeId, setActiveId] = useState(undefined) + const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKeyDown) + return () => { document.removeEventListener('keydown', onKeyDown) } + }, [onClose]) + + // Baseline focus management: entering the dialog lands on the close button. + const closeButton = useRef(null) + useEffect(() => { closeButton.current?.focus() }, []) + + return ( +
+ + ) +} + +/** + * Render the settings trigger and panel. + * @param props - composed slot props (contract/slots.ts). + * @returns the settings shell element tree. + */ +export function SettingsRoot(props: SettingsRootComponentProps) { + const { wide, translate, subscribeSections, sectionsVersion, sections, renderSlot } = props + const [open, setOpen] = useState(false) + const close = useCallback(() => { setOpen(false) }, []) + + // The ledger tick is the shell's only subscription: sections re-register + // with freshly localized labels on locale change, so the version bump also + // re-renders the shell's own translate()-read chrome copy. + // State = ledger version: same-version notifications dedupe to no render. + const [, setSectionsRev] = useState(() => sectionsVersion()) + useEffect( + () => subscribeSections(() => { setSectionsRev(sectionsVersion()) }), + [subscribeSections, sectionsVersion], + ) + const rows = sections() + + return ( + <> + + {open && } + + ) +} diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts new file mode 100644 index 0000000000..10e4e0ecbe --- /dev/null +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -0,0 +1,62 @@ +/** + * Settings shell slot contract: the shell occupies the sidebar-owned + * `sidebar.settings` hole and declares the `settings.section` list slot that + * section plugins (General, Models, …) contribute pages into. + */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) +// into every program that sees this contract. +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** + * One settings page per list entry. Registrant options carry the nav + * identity: `id` (section key, drives `only` filtering), `order` (nav + * position), `label` (registrant-localized display text — the registrant + * re-registers with fresh text on locale change, so the shell never + * subscribes locale/theme state; the ledger bump doubles as the shell's + * re-render trigger). Sections render inside the panel content column. + */ + 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } + } +} + +/** + * Owner share of a settings section entry. The shell owns modal visibility + * and navigation; sections receive nothing but the render site (their data + * arrives through their own inject faces and stores). + */ +export interface SettingsSectionOwnerProps { + /** Marker field: section owner props are intentionally empty for now. */ + children?: never +} + +/** + * Registrant-private injected share of the settings shell (assembled in + * apply): locale-resolved nav labels come through `translate`. + */ +export type SettingsRootInjected = { + /** + * Resolve a ":" locale reference to the active-locale text — + * shell chrome copy only (trigger/title/close); nav labels arrive already + * localized. Read at render time; the locale-change re-render rides the + * section ledger bump, not a shell-owned subscription. + */ + translate: (ref: string) => string + /** Read the settings.section ledger version (nav invalidation). */ + sectionsVersion: () => number + /** Subscribe to settings.section ledger changes. */ + subscribeSections: (listener: () => void) => () => void + /** Project the settings.section ledger into nav rows (id/order/label). */ + sections: () => readonly { id: string; order: number; label: string }[] +} + +/** + * Full component props of the settings shell root: the sidebar owner share + * (wide/rail state) plus the declared section render share and the injected + * face. No store is registered — modal open state and active section id are + * component-local viewing state. + */ +export type SettingsRootComponentProps = + PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.section'> & SettingsRootInjected diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts new file mode 100644 index 0000000000..d5135f373c --- /dev/null +++ b/packages/client/ui-settings/src/client/index.ts @@ -0,0 +1,66 @@ +/** + * Settings shell plugin, browser half. Occupies the sidebar-owned + * `sidebar.settings` hole with the trigger row + modal panel, declares the + * `settings.section` list slot, and projects that ledger into the panel + * navigation. Export discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context/Events merges (ctx.locale, +// 'locale/change') into this program. +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { SettingsRootInjected } from './contract/slots.ts' +import { SettingsRoot } from './SettingsRoot.tsx' + +export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps } from './contract/slots.ts' + +/** + * Required services (cordis fiber inject). The target slot is declared by + * ui-sidebar's apply, whose activation order relative to this one is NOT + * constrained (dshClient.inject edges are informational); registration goes + * through declaration-aware deferral. + */ +export const inject = ['slots', 'locale'] + +/** + * Register the settings shell into `sidebar.settings` once the declaration is + * on the ledger. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposers = [ + ctx.locale.register('settings', 'zh', { trigger: '设置', title: '设置', close: '关闭' }), + ctx.locale.register('settings', 'en', { trigger: 'Settings', title: 'Settings', close: 'Close' }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings: shell copy dictionaries') + const injected = (): SettingsRootInjected => ({ + translate: (ref) => { + const colon = ref.indexOf(':') + if (colon === -1) return ref + return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1)) + }, + sectionsVersion: () => ctx.slots.getVersion('settings.section'), + subscribeSections: (listener) => ctx.slots.subscribe('settings.section', listener), + sections: () => ctx.slots.entries('settings.section') + .map(e => ({ id: e.options.id ?? '', order: e.options.order ?? 0, label: e.options.label ?? '' })) + .sort((a, b) => a.order - b.order), + }) + ctx.effect(() => { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (ctx.slots.spec('sidebar.settings') === undefined || dispose !== undefined) return + dispose = ctx.slots.register({ + name: 'sidebar.settings', + children: { 'settings.section': { kind: 'list', scope: 'root' } }, + inject: injected, + }, SettingsRoot) + } + const unsubscribe = ctx.slots.subscribe('sidebar.settings', () => { tryRegister() }) + tryRegister() + return () => { + unsubscribe() + dispose?.() + } + }, 'ui-settings: shell registration') +} diff --git a/packages/client/ui-settings/src/css-modules.d.ts b/packages/client/ui-settings/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings/src/index.ts b/packages/client/ui-settings/src/index.ts new file mode 100644 index 0000000000..52fe4c43e6 --- /dev/null +++ b/packages/client/ui-settings/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the settings shell plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings/src/invariant.ts b/packages/client/ui-settings/src/invariant.ts new file mode 100644 index 0000000000..53d7fb066a --- /dev/null +++ b/packages/client/ui-settings/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings`. + * @module @deepseek-ai/dsh-client-ui-settings/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a presentation shell projecting the settings.section + * ledger into navigation — it emits no cordis events and owns no cross-plugin + * mutable relation; slot declaration/registration conflicts already fail loud + * in the slot core at load time. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json new file mode 100644 index 0000000000..db90b908bf --- /dev/null +++ b/packages/client/ui-settings/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-sidebar" + }, + { + "path": "../locale" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-settings/tsdown.config.ts b/packages/client/ui-settings/tsdown.config.ts new file mode 100644 index 0000000000..ba06fdc7c9 --- /dev/null +++ b/packages/client/ui-settings/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index d8c2f61ee0..e42b226904 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -4,7 +4,9 @@ Sidebar plugin: real Host Workspaces in stable Host order, each containing its ` New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar. -`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` child slot, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. +`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. + +The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there. The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly). diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 591aef2330..20431e9d15 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -49,7 +49,7 @@ .railIn .iconButton, .railIn .newSession, .railIn .searchButton, -.railIn .foot { +.railIn .footArea { animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards; } @@ -372,45 +372,11 @@ font-size: 13px; } -/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical - margins fold into the row so the hover pill spans the full 49px. */ -.foot { +/* Foot seat: pure layout — the flex slot pinning the sidebar.settings slot + content to the column bottom. Row visuals belong to the slot occupant + (ui-settings). */ +.footArea { flex: none; - display: flex; - align-items: center; - gap: 8px; - height: 49px; - margin: 8px 0 0; /* + 49px row + root padBottom 6 keeps the old 57px band */ - padding: 0 2px 0 6px; - border-radius: 12px; - cursor: pointer; - overflow: hidden; - color: var(--dsw-alias-label-primary); -} - -.foot:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Rail settings: the same 36x36 circle box as the other rail controls. */ -.collapsed .foot { - width: 36px; - height: 36px; - margin: 18px 0 10px; - justify-content: center; - gap: 0; - padding: 0; - border-radius: 50%; -} - -.footLabel { - max-width: 120px; - overflow: hidden; - white-space: nowrap; -} - -.collapsed .footLabel { - max-width: 0; } @media (prefers-reduced-motion: reduce) { @@ -419,7 +385,7 @@ .railIn .iconButton, .railIn .newSession, .railIn .searchButton, - .railIn .foot { + .railIn .footArea { transition: none; animation: none; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 931eb1ba17..490c3e7b8b 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -12,7 +12,7 @@ import clsx from 'clsx' import { BrandWordmark, FishLogo, IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16, - IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14, + IconProjectAddOutline16, IconSearchOutline16, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' @@ -314,9 +314,10 @@ export function SidebarRoot({ )}
-
- - {wide && Settings} + {/* Foot seat: the flex slot pinning the settings entry to the column + bottom; ui-settings occupies it with the trigger row + panel. */} +
+ {renderSlot('sidebar.settings', { wide })}
) diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 0334ca88c9..bb7c0caf07 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -20,9 +20,24 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * is claiming); ui-workspace registers the picker. */ 'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps } + /** + * The settings seat at the sidebar foot. Declared by this package's + * 'sidebar' entry; ui-settings registers its trigger row + modal panel. + * The sidebar passes only its column state — it holds no settings state. + */ + 'sidebar.settings': { kind: 'single'; scope: 'root'; owner: SidebarSettingsOwnerProps } } } +/** + * Owner share of the sidebar settings seat: the column display state the + * occupant's trigger row must render against (wide row vs rail icon). + */ +export interface SidebarSettingsOwnerProps { + /** Whether the sidebar renders wide content (false = 56px rail). */ + wide: boolean +} + /** * Owner share of the sidebar workspace hole: popover geometry plus the * sidebar's pick semantics. The picked Host Workspace is already real; the @@ -62,8 +77,9 @@ export type SidebarRootInjected = { /** * Full component props: layout owner state/actions plus global useSessions - * and useWorkspaces, the declared Workspace picker render share, and this - * package's injected callback. No store is registered. + * and useWorkspaces, the declared child-slot render shares (Workspace picker + * and settings seat), and this package's injected callback. No store is + * registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace' | 'sidebar.settings'> & SidebarRootInjected diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0a1c8ebb12..ad847bb5a5 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -3,7 +3,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' -export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts' +export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSettingsOwnerProps, SidebarWorkspaceOwnerProps } from './contract/slots.ts' /** Services required by the sidebar plugin. */ export const inject = ['slots', 'layout', 'sessions', 'workspaces'] @@ -20,9 +20,12 @@ export function apply(ctx: ClientContext): void { ctx.effect( () => ctx.slots.register({ name: 'sidebar', - // SidebarRoot owns this picker site; ui-workspace registers the shared - // picker that selects a Host Workspace for a frontend Session Intent. - children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } }, + // SidebarRoot owns these sites; ui-workspace registers the shared + // picker, ui-settings registers the settings trigger + panel. + children: { + 'sidebar.workspace': { kind: 'single', scope: 'root' }, + 'sidebar.settings': { kind: 'single', scope: 'root' }, + }, inject: injectProps, }, SidebarRoot), 'ui-sidebar: slot registration', diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 26adafd793..d4caa4e1be 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -32,16 +32,16 @@ const workspaces: WorkspaceListState = { function mount(sessionState: SessionListState = sessions) { const startSession = vi.fn() const open = vi.fn() - let pickerOwner: unknown + const owners: Record = {} const view = render( { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']} />, ) - return { view, startSession, open, pickerOwner: () => pickerOwner } + return { view, startSession, open, pickerOwner: () => owners['sidebar.workspace'], settingsOwner: () => owners['sidebar.settings'] } } function mountSidebar({ @@ -58,14 +58,14 @@ function mountSidebar({ const startSession = vi.fn() const open = vi.fn() const toggleSidebar = vi.fn() - let pickerOwner: unknown + const owners: Record = {} let current = { sessionState, workspaceState, collapsed, width } const root = () => ( { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']} /> ) const view = render(root()) @@ -73,7 +73,8 @@ function mountSidebar({ startSession, open, toggleSidebar, - pickerOwner: () => pickerOwner, + pickerOwner: () => owners['sidebar.workspace'], + settingsOwner: () => owners['sidebar.settings'], rerender(next: Partial) { current = { ...current, ...next } view.rerender(root()) @@ -246,6 +247,7 @@ describe('SidebarRoot', () => { it('keeps wide content during live collapse, then settles to the rail', () => { vi.useFakeTimers() const b = mountSidebar({ width: 320 }) + expect((b.settingsOwner() as { wide: boolean }).wide).toBe(true) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() b.rerender({ collapsed: true, width: 56 }) @@ -253,6 +255,8 @@ describe('SidebarRoot', () => { act(() => { vi.advanceTimersByTime(150) }) expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() + // The settings seat share tracks the settled column state. + expect((b.settingsOwner() as { wide: boolean }).wide).toBe(false) }) }) diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 2ce5d9b9e6..be0b83a0d8 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -1,10 +1,10 @@ # @deepseek-ai/dsh-client-ui-theme -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers); apply(id) toggles the `body[data-ds-dark-theme]` attribute, so theme switches are pure CSS cascade. Contract: api-contracts v3 §8. +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 (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8. ## Model Experience -None, as the theme service toggles browser CSS; nothing here reaches a model request. +None, as the theme service manages a browser preference; nothing here reaches a model request. #### KV Cache effect @@ -12,6 +12,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No theme-switch control ships in P-I** — the service surface (register/apply/current) is complete but no UI owner mounts a toggle; switching happens programmatically. - **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete. - **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22). diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 4e601914e3..4c6ad92cd4 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", - "description": "Theme plugin: ThemeService (apply = toggle body[data-ds-dark-theme]), --dsw-* token base stylesheets", + "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets", "version": "0.0.1", "private": true, "type": "module", @@ -44,5 +44,9 @@ "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" - ] + ], + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + } } diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index d9f5b70c3b..5ba5d40b35 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -1,71 +1,196 @@ /** - * Browser theme registry over the `--dsw-*` token stylesheets. Theme changes - * update CSS variables and `body[data-ds-dark-theme]` without React renders. + * Browser theme registry over the `--dsw-*` token stylesheets. The service + * owns the 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. */ import type { Context } from 'cordis' /** 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). */ + id: string + /** + * Which base palette this theme builds on. The presenter switches + * `body[data-ds-dark-theme]` from this field — never from the id. + */ + colorScheme: 'light' | 'dark' + /** Alias-layer overrides applied as inline CSS variables over the base palette. */ + tokens: ThemeTokens +} + +/** Immutable theme state published on every change. */ +export interface ThemeSnapshot { + /** The persisted preference (may be `system`). */ + preference: ThemePreference + /** The resolved active theme (`system` resolved via prefers-color-scheme). */ + active: ThemeDefinition + /** Registered themes in registration order. */ + themes: readonly ThemeDefinition[] + /** Monotonic change counter (registry or active changes). */ + revision: number +} + declare module 'cordis' { interface Context { theme: ThemeService } + interface Events { + /** + * Theme state changed (preference switched, registry updated, or the OS + * color scheme changed while the preference is `system`). + * @param snapshot - Current immutable theme snapshot. + * @mode emit + */ + 'theme/change'(snapshot: ThemeSnapshot): void + } } +/** 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({}) }), +]) + /** - * Theme registry and switcher. `light`/`dark` are built in (the base - * stylesheets carry both palettes; the dark palette activates via the - * body[data-ds-dark-theme] attribute). Third-party themes register alias-layer - * overrides applied as inline CSS variables on body, cascading over whichever - * base palette the attribute selects. + * Theme registry and preference owner. `light`/`dark` are built in (the base + * stylesheets carry both palettes); third-party themes register alias-layer + * overrides. Reads go through {@link getTheme}; writes only through + * {@link setTheme}; continuous sync only through the `theme/change` event. + * The service holds the `prefers-color-scheme` media query (environment + * sensing, not presentation) and re-emits when the OS scheme flips while the + * preference is `system`. */ export class ThemeService { - private themes = new Map([['light', {}], ['dark', {}]]) - private appliedTokens: ThemeTokens = {} - private active = 'light' + private readonly ctx: Context + private themes: ThemeDefinition[] = [...BUILTIN_THEMES] + private preference: ThemePreference + private revision = 0 + private snapshot: ThemeSnapshot + private readonly media: MediaQueryList | undefined /** - * Register a theme. Duplicate id throws (single occupant per id; the - * built-in pair counts). - * @param id - theme id. - * @param tokens - alias-layer overrides (variable name to value). - * @returns disposer. Disposing the active theme reverts to `light` so the - * UI never keeps tokens of an unregistered theme. + * @param ctx - owning context (change events are emitted on it; the + * media-query listener is released through ctx.effect on dispose). */ - register(id: string, tokens: ThemeTokens): () => void { - if (this.themes.has(id)) throw new Error(`theme "${id}" is already registered`) - this.themes.set(id, tokens) - return () => { - if (!this.themes.delete(id)) return - if (this.active === id) this.apply('light') + constructor(ctx: Context) { + this.ctx = ctx + this.preference = restorePreference() + this.media = globalThis.matchMedia?.('(prefers-color-scheme: dark)') + this.snapshot = this.buildSnapshot() + if (this.media !== undefined) { + const media = this.media + const onChange = (): void => { + if (this.preference !== 'system') return + this.publish() + } + ctx.effect(() => { + media.addEventListener('change', onChange) + return () => { media.removeEventListener('change', onChange) } + }, 'ui-theme: prefers-color-scheme listener') } } /** - * Activate a theme: toggle body[data-ds-dark-theme] (set only for `dark`) - * and swap the previous theme's inline token overrides for this one's. - * Unregistered id throws. - * @param id - registered theme id. + * Read the current immutable theme snapshot. + * @returns the current snapshot (stable reference until the next change). */ - apply(id: string): void { - const tokens = this.themes.get(id) - if (!tokens) throw new Error(`theme "${id}" is not registered`) - const body = document.body - for (const name of Object.keys(this.appliedTokens)) body.style.removeProperty(name) - if (id === 'dark') body.setAttribute('data-ds-dark-theme', '') - else body.removeAttribute('data-ds-dark-theme') - for (const [name, value] of Object.entries(tokens)) body.style.setProperty(name, value) - this.appliedTokens = tokens - this.active = id + getTheme(): ThemeSnapshot { + return this.snapshot } /** - * Report the active theme id (initially `light`). - * @returns the active theme id. + * Switch the theme preference — the only preference write entry. Persists + * the preference and emits `theme/change`. + * @param id - a registered theme id or `system`; unknown ids throw. */ - current(): string { - return this.active + setTheme(id: string): void { + if (id !== 'system' && !this.themes.some(t => t.id === id)) { + throw new Error(`theme "${id}" is not registered`) + } + if (this.preference === id) return + this.preference = id as ThemePreference + persistPreference(this.preference) + this.publish() + } + + /** + * Register a theme. Duplicate id throws (single occupant per id; the + * built-in pair counts; `system` is a preference, not a registrable id). + * @param definition - theme id, colorScheme, and alias-token overrides. + * @returns disposer. Disposing the theme backing the active preference + * resets the preference to the default so the UI never keeps tokens of an + * unregistered theme. + */ + register(definition: ThemeDefinition): () => void { + if (definition.id === 'system') throw new Error('"system" is a preference, not a registrable theme id') + if (this.themes.some(t => t.id === definition.id)) { + throw new Error(`theme "${definition.id}" is already registered`) + } + this.themes = [...this.themes, definition] + this.publish() + return () => { + if (!this.themes.some(t => t.id === definition.id)) return + this.themes = this.themes.filter(t => t.id !== definition.id) + if (this.preference === definition.id) { + this.preference = DEFAULT_PREFERENCE + persistPreference(this.preference) + } + this.publish() + } + } + + private buildSnapshot(): ThemeSnapshot { + const resolvedId = this.preference === 'system' + ? (this.media?.matches === true ? 'dark' : 'light') + : this.preference + // Both built-ins always exist; a registered preference id resolves or has + // been reset by its disposer, so the lookup cannot miss. + const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]! + return Object.freeze({ + preference: this.preference, + active, + themes: Object.freeze([...this.themes]), + revision: this.revision, + }) + } + + private publish(): void { + this.revision += 1 + this.snapshot = this.buildSnapshot() + this.ctx.emit('theme/change', this.snapshot) + } +} + +/** Read the persisted preference; unknown or unreadable values fall back to the default. */ +function restorePreference(): ThemePreference { + try { + const stored = globalThis.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 { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, preference) + } catch { + // Storage access can throw (privacy mode / quota); the preference simply + // does not survive the session. } } @@ -77,5 +202,5 @@ export const inject: string[] = [] * @param ctx - client cordis context. */ export function apply(ctx: Context): void { - ctx.provide('theme', new ThemeService()) + ctx.provide('theme', new ThemeService(ctx)) } diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index 292304cbaf..4ec3296cd6 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -15,9 +15,10 @@ export const name = 'client-ui-theme-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a token-sheet registry whose apply() flips one body - * attribute — it emits no cordis events; registration/apply/current behavior - * is asserted directly by this package's behavior specs. + * 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. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 126957c5eb..9ab0ab8f0f 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,61 +1,90 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it } from 'vitest' -import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +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' + +const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => { + const ctx = new Context() + const events: ThemeSnapshot[] = [] + ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) + return { ctx, theme: new ThemeService(ctx), events } +} describe('ThemeService', () => { beforeEach(() => { - document.body.removeAttribute('data-ds-dark-theme') - document.body.removeAttribute('style') + localStorage.clear() }) - it('starts on light; apply toggles the dark body attribute both ways', () => { - const theme = new ThemeService() - expect(theme.current()).toBe('light') - theme.apply('dark') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) - expect(theme.current()).toBe('dark') - theme.apply('light') + it('defaults to the system preference resolved against prefers-color-scheme', () => { + const { theme } = make() + const snapshot = theme.getTheme() + expect(snapshot.preference).toBe('system') + // jsdom matchMedia is absent; system resolves to light. + expect(snapshot.active.id).toBe('light') + expect(snapshot.active.colorScheme).toBe('light') + expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark']) + }) + + it('setTheme switches, persists, republishes, and keeps DOM untouched', () => { + const { theme, events } = 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(events).toHaveLength(1) + expect(events[0]).toBe(theme.getTheme()) + // The service never touches presentation state. expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) - expect(theme.current()).toBe('light') + // Same-value set is a no-op (no extra event). + theme.setTheme('dark') + expect(events).toHaveLength(1) }) - it('throws on unregistered apply and duplicate register (built-ins included)', () => { - const theme = new ThemeService() - expect(() => { theme.apply('sepia') }).toThrow('not registered') - expect(() => theme.register('light', {})).toThrow('already registered') - theme.register('sepia', {}) - expect(() => theme.register('sepia', {})).toThrow('already registered') + 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('applies third-party token overrides as body inline vars and swaps them on switch', () => { - const theme = new ThemeService() - theme.register('sepia', { '--dsw-alias-bg-base': 'rgb(1, 2, 3)' }) - theme.apply('sepia') - expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('rgb(1, 2, 3)') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) - theme.apply('dark') - expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + it('throws on unknown setTheme ids, duplicate registration, and the system id', () => { + const { theme } = make() + expect(() => { theme.setTheme('sepia') }).toThrow('not registered') + expect(() => theme.register({ id: 'light', colorScheme: 'light', tokens: {} })).toThrow('already registered') + expect(() => theme.register({ id: 'system', colorScheme: 'light', tokens: {} })).toThrow('preference') }) - it('disposing the active theme reverts to light; disposer is idempotent', () => { - const theme = new ThemeService() - const dispose = theme.register('sepia', { '--dsw-alias-bg-base': 'red' }) - theme.apply('sepia') + it('registered themes join the snapshot; disposing the active one resets to default', () => { + const { theme, events } = 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') + expect(theme.getTheme().active.tokens['--dsw-alias-bg-base']).toBe('red') dispose() - expect(theme.current()).toBe('light') - expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('') - expect(() => { theme.apply('sepia') }).toThrow('not registered') + expect(theme.getTheme().preference).toBe('system') + expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) + expect(localStorage.getItem(STORAGE_KEY)).toBe('system') + // register + set + dispose = three publishes; disposer is idempotent. + expect(events.length).toBe(3) dispose() - expect(theme.current()).toBe('light') + expect(events.length).toBe(3) }) - it('disposing an inactive theme leaves the active selection untouched', () => { - const theme = new ThemeService() - const dispose = theme.register('sepia', {}) - theme.apply('dark') + it('disposing an inactive theme keeps the active preference', () => { + const { theme } = make() + const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: {} }) + theme.setTheme('dark') dispose() - expect(theme.current()).toBe('dark') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + expect(theme.getTheme().preference).toBe('dark') + }) + + it('revision increases monotonically across every publish', () => { + const { theme, events } = make() + theme.setTheme('dark') + theme.setTheme('light') + const dispose = theme.register({ id: 'sepia', colorScheme: 'dark', tokens: {} }) + dispose() + expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) }) diff --git a/packages/client/web/src/boot.tsx b/packages/client/web/src/boot.tsx index bd755a5fc1..61dbbd850e 100644 --- a/packages/client/web/src/boot.tsx +++ b/packages/client/web/src/boot.tsx @@ -21,7 +21,7 @@ * switches to the real UI in one pass. * * Entry creation waits for the whole immediately tier: materialization runs - * synchronous cross-package require edges (e.g. i18n → runtime/client) that + * synchronous cross-package require edges (e.g. locale → runtime/client) that * fiber inject waiting cannot protect — a bundle's factory must be * registered before any dependent entry materializes. Per-row prefetch * failures still resolve silently (the create-side import refetches and diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19dcce27..2410252eb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,9 +125,9 @@ importers: '@deepseek-ai/dsh-client-hmr': specifier: workspace:^ version: link:../../packages/client/hmr - '@deepseek-ai/dsh-client-i18n': + '@deepseek-ai/dsh-client-locale': specifier: workspace:^ - version: link:../../packages/client/i18n + version: link:../../packages/client/locale '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../packages/client/modules @@ -143,6 +143,15 @@ importers: '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../packages/client/ui-question + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../../packages/client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-general': + specifier: workspace:^ + version: link:../../packages/client/ui-settings-general + '@deepseek-ai/dsh-client-ui-settings-models': + specifier: workspace:^ + version: link:../../packages/client/ui-settings-models '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../packages/client/ui-sidebar @@ -740,11 +749,7 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - packages/client/i18n: - dependencies: - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime + packages/client/locale: devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -844,6 +849,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../ui-theme '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -937,6 +945,104 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-settings: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../ui-sidebar + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-general: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../ui-theme + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-models: + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-sidebar: dependencies: clsx: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 20d4195737..da5fe34dc4 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -59,7 +59,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-workspace': { 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.' }, + 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a449a34c4e..81e0fc989c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -115,7 +115,10 @@ "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], "@deepseek-ai/dsh-client-ui-workspace": ["./packages/client/ui-workspace/src"], "@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"], - "@deepseek-ai/dsh-client-i18n": ["./packages/client/i18n/src"], + "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], + "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], + "@deepseek-ai/dsh-client-ui-settings-models": ["./packages/client/ui-settings-models/src"], + "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 17f34b601f..abe80b8b12 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -38,7 +38,10 @@ { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, - { "path": "./packages/client/i18n" }, + { "path": "./packages/client/ui-settings" }, + { "path": "./packages/client/ui-settings-general" }, + { "path": "./packages/client/ui-settings-models" }, + { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } ] From 204a09b70fbfa6ebfd8ea39ac69ce7c0a7a2538e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:30:20 +0800 Subject: [PATCH 10/45] fix(build): reuse TypeScript config host --- scripts/clean.ts | 14 ++------------ scripts/ts-project.ts | 5 +++-- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/scripts/clean.ts b/scripts/clean.ts index 93b9b2519c..3cb3820db7 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -2,20 +2,10 @@ import { lstat, readdir, rm } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' +import { repositoryConfigHost } from './ts-project.ts' const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck']) -const configHost: ts.ParseConfigFileHost = { - useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, - readDirectory: (...args) => ts.sys.readDirectory(...args), - fileExists: fileName => ts.sys.fileExists(fileName), - readFile: fileName => ts.sys.readFile(fileName), - getCurrentDirectory: () => ts.sys.getCurrentDirectory(), - onUnRecoverableConfigFileDiagnostic(diagnostic) { - throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) - }, -} - function isMissing(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } @@ -45,7 +35,7 @@ function repositoryPath(root: string, path: string): string { } function parseConfig(configPath: string): ts.ParsedCommandLine { - const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, repositoryConfigHost) if (!parsed) throw new Error(`clean: cannot parse TypeScript config ${configPath}`) if (parsed.errors.length > 0) { throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) diff --git a/scripts/ts-project.ts b/scripts/ts-project.ts index 930c055d4f..9a0400a39d 100644 --- a/scripts/ts-project.ts +++ b/scripts/ts-project.ts @@ -11,7 +11,8 @@ interface ProjectGraph { options: ts.CompilerOptions } -const configHost: ts.ParseConfigFileHost = { +/** TypeScript config host shared by repository scripts. */ +export const repositoryConfigHost: ts.ParseConfigFileHost = { useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, readDirectory: (...args) => ts.sys.readDirectory(...args), fileExists: fileName => ts.sys.fileExists(fileName), @@ -52,7 +53,7 @@ function loadProjectGraph(projectRoot: string): ProjectGraph { /** Parse one config file and fail loud on any config diagnostic. */ function parseConfig(configPath: string): ts.ParsedCommandLine { - const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, repositoryConfigHost) if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`) if (parsed.errors.length > 0) { throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) From 1317e4809f1cedf404a68a54a629efe08dadfbc5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:30:38 +0800 Subject: [PATCH 11/45] fix(pty): recheck prompt handoff at idle boundary --- packages/pty/pty-local/README.md | 2 +- packages/pty/pty-local/src/session.ts | 7 +++++-- packages/pty/pty-local/tests/session.spec.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index a2b3aec391..cd11509b43 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -6,7 +6,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate until bash ownership is observable or the ordinary silence bound expires. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. +Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate through one final poll after the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown. diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index 4b1faccc94..f05ad19e1d 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -331,8 +331,11 @@ export class LocalPtySession implements PtyBackendSession { // A prompt candidate can race bash's foreground handoff, but an interactive // child also inherits PROMPT_COMMAND. Silence therefore remains the bound // on waiting for shell ownership instead of letting a child marker suppress - // readiness until the absolute timeout. - if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) { + // readiness until the absolute timeout. One final poll lets a foreground + // handoff coincident with that boundary win before the fallback settles. + const idleFor = Date.now() - this.lastOutputAt + const handoffGrace = this.promptSeen ? this.config.pollIntervalMs : 0 + if (startupHasOutput && idleFor >= this.config.idleSilenceMs && idleFor - this.config.idleSilenceMs >= handoffGrace) { this.settleActive('inferred_idle') return } diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 4be21c79a6..3b76c9d71c 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -298,7 +298,7 @@ describe('LocalPtySession readiness and output', () => { void operation.done.then(() => { settled = true }) inspector.pgid = 789 terminal.emitData('\x1b]133;D;0\x07dsh> ') - await vi.advanceTimersByTimeAsync(40) + await vi.advanceTimersByTimeAsync(50) expect(settled).toBe(false) inspector.pgid = 456 From ea8b1178cda98cc945a016d75bd3e4191b35e704 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:02:46 +0800 Subject: [PATCH 12/45] feat(web): session list one-list, hover card, row menus, rename, manual ordering Sidebar session list grows the figma 239-10458 feature set and the workspace/session browsing region moves wholesale into ui-workspace: - Group-by menu (WorkSpace / In one list): flat mode lists every session top-level, strictly newest-first; the choice persists across reloads. - Session rows get a 500ms hover detail card (title / relative time / status line) and a ... menu (Rename / Fork session / Delete session, visual-only for now); workspace headers get ... with Rename (wired) and Delete workspace (visual-only). - workspace.rename RPC: trims, rejects duplicate titles on the create chain (workspace-name-conflict), no-op on same title; modal dialog with client-side duplicate pre-check. - workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted anchor appends): HTML5 drag reorder of root sessions inside a workspace group; order truth stays host-side, the view refreshes from the response/changed frame. - Activity pinning removed: the session/event touchSession chain is gone; workspace accounts are manually owned (new sessions prepend, explicit reordering only). Contracts and tests updated, api catalog regenerated. - ui-sidebar reduced to the column shell (brand, fold state machine, New Session, Settings) exposing one sidebar.workspaces hole with a two-fact owner share {wide, expandSidebar}; ui-workspace owns the whole region (header, search, grouped/flat lists, dialogs, drag) plus the picker via a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and its deferral indirection are gone. - ui-primitives: Menu gains label entries, danger rows, and closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled guard). Hover card and row menu never coexist. --- docs/cordis-catalog/services.md | 11 - docs/event-producer-consumer.md | 2 +- .../client/connection/src/client/fixture.ts | 55 +++ packages/client/connection/tests/fake-api.ts | 6 + .../runtime/src/client/workspaces/manager.ts | 41 +- .../runtime/src/client/workspaces/service.ts | 31 +- packages/client/runtime/tests/fake-api.ts | 9 + .../ui-primitives/src/HoverCard.module.css | 22 + .../client/ui-primitives/src/HoverCard.tsx | 109 +++++ .../client/ui-primitives/src/Menu.module.css | 21 + packages/client/ui-primitives/src/Menu.tsx | 33 +- packages/client/ui-primitives/src/index.ts | 3 +- .../client/ui-sidebar/src/client/Rows.tsx | 143 ------ .../src/client/SidebarRoot.module.css | 186 +------- .../ui-sidebar/src/client/SidebarRoot.tsx | 248 +--------- .../ui-sidebar/src/client/contract/slots.ts | 59 +-- .../client/ui-sidebar/src/client/index.ts | 15 +- .../client/ui-sidebar/tests/apply.spec.tsx | 18 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 274 ++--------- packages/client/ui-workspace/package.json | 3 + .../src/client/WorkspaceBrowser.module.css | 265 +++++++++++ .../src/client/WorkspaceBrowser.tsx | 428 ++++++++++++++++++ .../src/client/WorkspacePicker.tsx | 63 ++- .../ui-workspace/src/client/contract/slots.ts | 63 ++- .../client/ui-workspace/src/client/index.ts | 95 ++-- .../src/client/rows}/Rows.module.css | 53 ++- .../ui-workspace/src/client/rows/Rows.tsx | 285 ++++++++++++ .../client/ui-workspace/src/client/stores.ts | 36 ++ .../src/client/tree.ts | 33 +- .../client/ui-workspace/tests/apply.spec.ts | 68 +-- .../tests/rows.spec.tsx | 4 +- .../tests/tree.spec.ts | 0 .../cordis/tool-cordis/src/api-catalog.ts | 6 +- packages/host/apiproxy/src/api-proxy.ts | 69 ++- packages/host/apiproxy/src/api/rpc-map.ts | 2 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/src/api/workspace.schema.ts | 26 ++ packages/host/apiproxy/src/api/workspace.ts | 28 +- packages/host/apiproxy/src/fetch/client.ts | 8 + packages/host/apiproxy/src/fetch/handler.ts | 4 + .../apiproxy/tests/client-handler.spec.ts | 2 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 12 + packages/workspace/workspace/src/entity.ts | 56 ++- packages/workspace/workspace/src/index.ts | 37 +- packages/workspace/workspace/src/types.ts | 25 +- .../workspace/tests/workspace.spec.ts | 118 +---- pnpm-lock.yaml | 4 + 48 files changed, 1948 insertions(+), 1133 deletions(-) create mode 100644 packages/client/ui-primitives/src/HoverCard.module.css create mode 100644 packages/client/ui-primitives/src/HoverCard.tsx delete mode 100644 packages/client/ui-sidebar/src/client/Rows.tsx create mode 100644 packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css create mode 100644 packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx rename packages/client/{ui-sidebar/src/client => ui-workspace/src/client/rows}/Rows.module.css (79%) create mode 100644 packages/client/ui-workspace/src/client/rows/Rows.tsx create mode 100644 packages/client/ui-workspace/src/client/stores.ts rename packages/client/{ui-sidebar => ui-workspace}/src/client/tree.ts (89%) rename packages/client/{ui-sidebar => ui-workspace}/tests/rows.spec.tsx (97%) rename packages/client/{ui-sidebar => ui-workspace}/tests/tree.spec.ts (100%) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2e30e8602b..474f587230 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1981,15 +1981,6 @@ get(id: WorkspaceId): Workspace | undefined */ list(): Workspace[] -/** - * Move one accounted, cwd-validated session to the front of its workspace. - * Ungrouped sessions and candidates filtered by the header check are - * no-ops. The owning workspace's relative position never changes. - * @param sessionId - Session whose activity was observed. - * @returns resolution after the possible record write. - */ -async touchSession(sessionId: SessionId): Promise - /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -2000,8 +1991,6 @@ async touchSession(sessionId: SessionId): Promise async resolveByPath(path: string): Promise ``` -Types: [SessionId](../core-data-structures/core.md) - Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d9521f7d67..7d1e6b691a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace`](../packages/workspace/workspace), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 53c18dca5c..d6f3fa61de 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -641,6 +641,59 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { emitHost({ type: 'host/workspace-changed', workspace: { ...created } }) return ok(request, { workspace: { ...created }, created: true }) }, + rename: (request) => { + const { workspaceId, title } = request.payload + const workspace = workspaces.find(w => w.workspaceId === workspaceId) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + const trimmed = title.trim() + if (trimmed !== workspace.title) { + if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) { + return err(request, { + code: 'workspace-name-conflict', + message: `workspace name '${trimmed}' is already in use`, + details: { name: trimmed }, + }) + } + workspace.title = trimmed + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + return ok(request, { workspace: { ...workspace } }) + }, + insertSessionBefore: (request) => { + const { workspaceId, sessionId, beforeSessionId } = request.payload + const workspace = workspaces.find(w => w.workspaceId === workspaceId) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + if (!workspace.sessionIds.includes(sessionId) + || (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) { + return err(request, { + code: 'workspace-move-invalid', + message: `session or anchor is not accounted by workspace ${workspaceId}`, + details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } }, + }) + } + const without = workspace.sessionIds.filter(id => id !== sessionId) + const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) + const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] + if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { + workspace.sessionIds = sessionIds + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + return ok(request, { workspace: { ...workspace } }) + }, }, events: { async *mux(_request, signal) { @@ -757,6 +810,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'host.describe': return this.api.host.describe(request) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) + case 'workspace.rename': return this.api.workspace.rename(request) + case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) } } diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index faca82d5c3..eecacc9581 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -77,6 +77,12 @@ export class FakeApiClient implements IApiClient { workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true, }))), + rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + }))), + insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + }))), } /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 6db4e54c79..c512694816 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -1,7 +1,7 @@ /** Workspace baseline, incremental-frame, and unary-action owner. */ import type { - HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView, + HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' @@ -143,6 +143,40 @@ export class WorkspaceManager { return result } + /** + * Rename a Workspace, then publish its returned snapshot without waiting + * for the changed frame. + * @param workspaceId - target workspace. + * @param title - new display title. + * @returns the wire result. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise> { + const { result } = await this.api.workspace.rename({ workspaceId, title }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Move a session within its Workspace's manual order, then publish the + * returned snapshot without waiting for the changed frame. + * @param workspaceId - owning workspace. + * @param sessionId - accounted session to move. + * @param beforeSessionId - accounted anchor to insert before; omitted appends. + * @returns the wire result. + */ + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise> { + const { result } = await this.api.workspace.insertSessionBefore({ + workspaceId, sessionId, + ...beforeSessionId === undefined ? {} : { beforeSessionId }, + }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + /** * Host-frame entry. Non-workspace frames are ignored so the runtime can * fan one host stream out to both object managers. @@ -189,6 +223,11 @@ export class WorkspaceManager { private upsert(view: WorkspaceView, identity?: Workspace): void { this.refreshFrames?.push(view) const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) + // Mutation responses and changed frames race (two carriers, no ordering): + // reject a snapshot strictly older than the installed projection so a + // late unary response cannot roll back a newer frame. + const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view + if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return if (identity !== undefined) { this.items = index === -1 ? [identity, ...this.items] diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 854c53a75f..9768a3fac2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { - IApiClient, RpcError, WorkspaceId, WorkspaceView, + IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' @@ -100,6 +100,35 @@ export class WorkspacesService { return result.value.workspace } + /** + * Rename a Workspace. + * @param workspaceId - target workspace. + * @param title - new display title (trimmed non-empty by the Host). + * @returns the renamed Workspace view. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise { + const result = await this.manager.rename(workspaceId, title) + if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + + /** + * Move a session within its Workspace's manual order (DOM-insertBefore-like). + * @param workspaceId - owning workspace. + * @param sessionId - accounted session to move. + * @param beforeSessionId - accounted anchor to insert before; omitted appends. + * @returns the updated Workspace view. + */ + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise { + const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + /** * Refresh the workspace baseline, reusing an in-flight pull. * @returns completion of the current or newly started workspace baseline pull. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 45efcf9e36..a9fbda4907 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -92,9 +92,18 @@ export class FakeApiClient implements IApiClient { onWorkspaceCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) + onWorkspaceRename: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + readonly workspace: IApiClient['workspace'] = { list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), + rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + insertSessionBefore: (payload: unknown) => + this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), } /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css new file mode 100644 index 0000000000..8d8a52100e --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -0,0 +1,22 @@ +/* Block, not inline-flex: consumers wrap full-width list rows and an + * inline wrapper would shrink them; the card still measures this rect. */ +.root { + position: relative; + display: block; +} + +/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the + * menu card's elevation. Surface is #2C2C2E in both themes (figma value, + * light/dark identical), so a component-level variable, not a theme token. */ +.card { + --dsw-hovercard-bg: #2C2C2E; + position: fixed; + z-index: 100; + box-sizing: border-box; + width: 244px; + padding: 12px 16px; + border-radius: 12px; + background: var(--dsw-hovercard-bg); + box-shadow: var(--dsw-shadow-lv3); + pointer-events: none; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx new file mode 100644 index 0000000000..58e281778e --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -0,0 +1,109 @@ +// HoverCard: delayed hover-preview card portaled to document.body. +// Same portal mechanics as Menu: the wrapper span supplies the anchor rect, +// the card is fixed-positioned at its right edge and repositions on +// scroll/resize while open. Display-only — the card ignores pointer events +// and closes the instant the pointer leaves the anchor (no close delay). + +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import { createPortal } from 'react-dom' +import css from './HoverCard.module.css' + +/** + * Render an anchor with a hover-triggered preview card. + * @param props.anchor - the hover target (rendered in place inside a wrapper span). + * @param props.content - card content (display-only, no pointer interaction). + * @param props.openDelayMs - hover dwell before the card shows (default 500). + * @param props.disabled - suppress opening; turning true closes an open card. + * @returns anchor wrapper with the conditional portaled card. + */ +export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: { + anchor: ReactNode + content: ReactNode + openDelayMs?: number + disabled?: boolean +}) { + const rootRef = useRef(null) + const cardRef = useRef(null) + const timerRef = useRef | null>(null) + const [open, setOpen] = useState(false) + const [pos, setPos] = useState(null) + + const clearTimer = () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + + // Owner disabling mid-hover (menu opened, drag started) closes immediately. + useEffect(() => { + if (!disabled) return + clearTimer() + setOpen(false) + }, [disabled]) + + useEffect(() => clearTimer, []) + + // Fixed-position from the anchor rect before paint; track the anchor while + // open (capture-phase scroll catches nested panes), as in Menu portal mode. + useLayoutEffect(() => { + if (!open) { setPos(null); return } + const place = () => { + const r = rootRef.current?.getBoundingClientRect() ?? null + if (r === null) return + const h = cardRef.current?.offsetHeight ?? 0 + const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top + setPos({ left: r.right + 8, top }) + } + place() + window.addEventListener('scroll', place, true) + window.addEventListener('resize', place) + return () => { + window.removeEventListener('scroll', place, true) + window.removeEventListener('resize', place) + } + }, [open]) + + // The first placement ran before the card mounted (height read 0): once the + // card's real height is measurable, correct the bottom-edge clamp. + useLayoutEffect(() => { + if (!open || pos === null || typeof pos.top !== 'number') return + const h = cardRef.current?.offsetHeight ?? 0 + if (pos.top + h > window.innerHeight - 8) { + const top = window.innerHeight - h - 8 + if (pos.top !== top) setPos({ ...pos, top }) + } + }, [open, pos]) + + const card = open && pos !== null && ( +
+ {content} +
+ ) + + return ( + { + if (disabled) return + clearTimer() + timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs) + }} + onPointerLeave={() => { + clearTimer() + setOpen(false) + }} + // Any press inside the anchor (row click, menu trigger) dismisses the + // card immediately, without waiting for the owner to flip `disabled`. + onPointerDownCapture={() => { + clearTimer() + setOpen(false) + }} + > + {anchor} + {card !== false && createPortal(card, document.body)} + + ) +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index b55abe094d..28c3150335 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -109,6 +109,27 @@ background: transparent; } +/* Destructive row: error text/icon, danger hover fill. */ +.danger { + color: var(--dsw-alias-state-error-primary); +} + +.danger .itemIcon { + color: var(--dsw-alias-state-error-primary); +} + +.danger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +/* Heading row: non-interactive small grey text, padding aligned with items. */ +.label { + padding: 8px 10px; + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} + /* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ .separator { height: 1px; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index de015ee534..9abb6a3bb2 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -4,6 +4,7 @@ // the anchor rect, for anchors inside overflow-clipping containers (sidebar). // The owner controls `open`; outside-click closing uses one document listener // active only while open. Submenus open on hover/focus inside the same root. +// Entries also cover non-interactive `label` headings and `danger` rows. import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' @@ -19,6 +20,8 @@ export interface MenuItem { disabled?: boolean /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode + /** Destructive row: error-colored text/icon and danger hover fill. */ + danger?: boolean /** Nested card opened to the right on hover/focus. */ submenu?: readonly MenuItem[] } @@ -29,13 +32,24 @@ export interface MenuSeparator { id: string } -/** One primary-menu entry: a row or a separator. */ -export type MenuEntry = MenuItem | MenuSeparator +/** Non-interactive heading row above a group of items. */ +export interface MenuLabel { + type: 'label' + id: string + text: string +} + +/** One primary-menu entry: a row, a separator, or a heading label. */ +export type MenuEntry = MenuItem | MenuSeparator | MenuLabel function isSeparator(entry: MenuEntry): entry is MenuSeparator { return 'type' in entry && entry.type === 'separator' } +function isLabel(entry: MenuEntry): entry is MenuLabel { + return 'type' in entry && entry.type === 'label' +} + /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). @@ -50,6 +64,8 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator { * from the anchor rect (repositions on scroll/resize while open). Use when an * ancestor's overflow clipping would crop the in-place list; default false * keeps the pure-CSS in-place behavior. + * @param props.closeOnPointerLeave - close the list when the pointer leaves + * it (default false keeps it open until outside click/Escape/selection). * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the * Menu's own wrapper span. Required when the wrapper isn't itself laid out at @@ -58,7 +74,7 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator { * scroll/resize; return null to skip placement for that frame. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] @@ -68,6 +84,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align align?: 'start' | 'end' side?: 'bottom' | 'top' portal?: boolean + closeOnPointerLeave?: boolean getAnchorRect?: () => DOMRect | null className?: string }) { @@ -135,11 +152,19 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)} style={fixedPos ?? undefined} role="menu" + onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined} + // React portals bubble synthetic events through the REACT tree: without + // this stop, an item click re-fires the anchor row's own onClick + // (open/toggle) after onSelect. + onClick={(e) => { e.stopPropagation() }} > {items.map(entry => { if (isSeparator(entry)) { return
} + if (isLabel(entry)) { + return
{entry.text}
+ } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id return ( @@ -152,7 +177,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align - -
- ) -} - -/** - * The selected "New session" row for a frontend Session Intent targeted to a - * real Workspace. The row disappears when the Intent is replaced or connects. - * @returns the placeholder row element. - */ -export function IntentRowItem() { - return ( -
- - - New session -
- ) -} - -/** - * One session subtree: the node's own 34px row (indent by depth, expand - * twist when it has children, running dot, relative time) plus its visible - * children, recursively — the component tree mirrors the derived tree. - * @param props.node - derived session node. - * @param props.depth - 0 = directly under the group header. - * @param props.currentId - selected session id (row highlight). - * @param props.now - epoch ms for relative-time formatting. - * @param props.onOpen - open a session by id. - * @param props.onToggle - unfold/fold a subtree by id. - * @returns the node's row followed by its children. - */ -export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle }: { - node: SessionNode - depth: number - currentId: string | undefined - now: number - onOpen: (id: SessionNode['id']) => void - onToggle: (id: SessionNode['id']) => void -}) { - const row = node - const selected = node.id === currentId - // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to - // the title): both slots are always reserved so titles align whether or not - // the twist/dot is lit. Extra depth rides the left padding. - const ownRow = ( -
{ onOpen(node.id) }} - > - {row.hasChildren - ? ( - - ) - : } - {row.running && } - {row.title} - {formatRelativeTime(row.updatedAt, now)} -
- ) - return ( - <> - {ownRow} - {node.children.map(child => ( - - ))} - - ) -} diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 591aef2330..729855cdde 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -48,7 +48,6 @@ refresh straight into the collapsed state renders statically. */ .railIn .iconButton, .railIn .newSession, -.railIn .searchButton, .railIn .foot { animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards; } @@ -184,133 +183,9 @@ max-width: 0; } -/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons; - the right-anchored new-workspace button is the row's rail survivor. */ -.sectionHeader { - flex: none; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 4px; - height: 36px; - padding-left: 12px; - margin-bottom: 4px; - box-sizing: border-box; - border-radius: 12px; - overflow: hidden; - color: var(--dsw-alias-label-tertiary); -} - -.collapsed .sectionHeader { - height: 36px; - padding-left: 0; - margin-bottom: 12px; -} - -.sectionLabel { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - line-height: 20px; -} - -/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the - rail's search control. Upstream binds a dedicated design-system variable (light - #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token - pinned to the static scale mirrors it (ruled compliant: indirect via - custom property, upstream-variable equivalent). */ -.search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); - flex: none; - display: flex; - align-items: center; - gap: 8px; - height: 38px; - margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */ - padding: 0 14px; - box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; - background: var(--dsh-search-input-fill); - color: var(--dsw-alias-label-caption); - overflow: hidden; -} - -:global(body[data-ds-dark-theme]) .search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); -} - -.collapsed .search { - height: 36px; - padding: 0; - margin: 0 0 12px; - gap: 0; - border-color: transparent; - background: transparent; -} - -/* The capsule's leading icon, upgraded to the rail's search control. While - expanded it is decorative: pointer-events off so clicks reach the label - (native input focus); collapsed it becomes the hit target. */ -.searchButton { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - border-radius: 50%; - padding: 0; - background: transparent; - pointer-events: none; - color: inherit; -} - -.collapsed .searchButton { - width: 36px; - height: 36px; - pointer-events: auto; - cursor: pointer; - color: var(--dsw-alias-label-primary); -} - -.collapsed .searchButton:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - -.searchInput { - flex: 1; - min-width: 0; - border: none; - outline: none; - background: transparent; - font-size: 14px; - line-height: 20px; - color: var(--dsw-alias-label-primary); -} - -.searchInput::placeholder { - color: var(--dsw-alias-label-tertiary); -} - -.clearButton { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - border-radius: 50%; - padding: 0; - background: transparent; - cursor: pointer; - color: var(--dsw-alias-label-secondary); -} - -/* Tree seat: always mounted so the foot never moves; the tree content inside - is wide-only and clips while the column squeezes. */ -.listArea { +/* Region seat: always mounted so the foot never moves; the browser inside + handles its own wide/rail content. */ +.regionArea { flex: 1; min-height: 0; display: flex; @@ -318,60 +193,6 @@ overflow: hidden; } -/* Relative for the bottom fade overlay. */ -.treeBody { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - position: relative; -} - -/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, - transparent -> sidebar fill so it tracks the theme. */ -.fade { - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 72px; - background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); - pointer-events: none; -} - -/* Tree list: the only scrolling region. Block, not a flex column: as flex - items the 54/34 rows would shrink under content overflow (scrollHeight - collapses onto clientHeight and wheel scrolling dies); block children keep - their design heights and the 4px rhythm rides margins instead of gap. */ -.list { - flex: 1; - min-height: 0; - overflow-y: auto; - padding-bottom: 12px; -} - -/* One workspace section: header row + expanded session run. Rows inside - keep the former flat-list 4px gap as sibling margins; the inter-group - breathing room (figma 133:7661 batch separator, 20px after an expanded - run) rides the NEXT section's top margin so the last group adds none. */ -.groupSection > * + * { - margin-top: 4px; -} - -.groupSection + .groupSection { - margin-top: 4px; -} - -.groupSection:has([aria-expanded='true']) + .groupSection { - margin-top: 20px; -} - -.empty { - padding: 16px 12px; - color: var(--dsw-alias-label-tertiary); - font-size: 13px; -} - /* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical margins fold into the row so the hover pill spans the full 49px. */ .foot { @@ -418,7 +239,6 @@ .fading > *, .railIn .iconButton, .railIn .newSession, - .railIn .searchButton, .railIn .foot { transition: none; animation: none; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 931eb1ba17..bcb03780d5 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -1,170 +1,38 @@ /** - * Collapse is a slide plus crossfade: content freezes at its expanded - * width (inline style) and fades out in place while the sliding column - * (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle - * the wide-only content (brand, labels, input, tree) unmounts, dropping - * the sessions subscription, and the control rows snap to the 56px rail - * (one icon each, same top-down order) fading in as the slide ends. Rail - * search expands and focuses the search box. + * Sidebar shell: column geometry only. Collapse is a slide plus crossfade: + * content freezes at its expanded width (inline style) and fades out in place + * while the sliding column (AppFrame grid tracks) clips it — nothing reflows + * mid-slide. At settle the wide-only content unmounts and the control rows + * snap to the 56px rail (one icon each, same top-down order) fading in as the + * slide ends. The workspace/session browsing region between the New Session + * button and the foot is the `sidebar.workspaces` registrant's; the shell + * hands it the wide flag and an expand request callback. */ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { BrandWordmark, FishLogo, - IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16, - IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14, - Menu, Tooltip, + IconNewChatOutline16, IconPanelLeftOutline16, IconSettingsOutline14, + Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootComponentProps } from './contract/slots.ts' -import { deriveGroups, UNGROUPED_KEY } from './tree.ts' -import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx' import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ const COLLAPSE_SETTLE_MS = 150 -/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ -const EXPAND_SLIDE_MS = 300 - -const GROUP_BY_ITEMS = [ - { id: 'workspace', label: 'Workspace' }, - // Only workspace grouping is implemented. - { id: 'update', label: 'Update', disabled: true }, - { id: 'status', label: 'Status', disabled: true }, -] - -/** Immutable membership toggle for the local expansion arrays. */ -function toggled(list: readonly string[], key: string): string[] { - return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] -} - -/** Group-by strategy menu; own open state so it resets with the wide chrome. */ -function GroupByMenu() { - const [open, setOpen] = useState(false) - return ( - { setOpen(false) }} - items={GROUP_BY_ITEMS} - selectedId="workspace" - onSelect={() => { setOpen(false) }} - align="end" - anchor={( - - )} - /> - ) -} - -type SessionTreeProps = Pick< - SidebarRootComponentProps, - 'useSessions' | 'startSession' | 'open' -> & { - workspaces: readonly WorkspaceView[] - /** Live search filter owned by the root (the query outlives the tree). */ - query: string -} - -/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) { - const list = useSessions((s) => s) - const current = list.current - const [expandedProjects, setExpandedProjects] = useState([]) - const [expandedSessions, setExpandedSessions] = useState([]) - // Re-expand when publication moves the selected intent into a real Workspace. - const intent = list.intent - const intentWorkspaceId = intent?.target.kind === 'workspace' - ? intent.target.workspaceId - : undefined - const currentGroup = current === undefined - ? undefined - : intent?.sessionId === current - ? intentWorkspaceId - : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY - useEffect(() => { - if (current === undefined || currentGroup === undefined) return - setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) - }, [current, currentGroup]) - const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), - [list, workspaces, expandedProjects, expandedSessions, query], - ) - const now = Date.now() - - return ( -
-
- {groups.length === 0 && ( -
{query === '' ? 'No sessions yet' : 'No matches'}
- )} - {groups.map(group => ( - // Group section: header row + expanded session subtree. The - // inter-group breathing room (former flat-list batch separator) - // is the section's own margin (SidebarRoot.module.css). -
- { setExpandedProjects((l) => toggled(l, group.key)) }} - onCreate={() => { - if (group.workspaceId !== undefined) startSession(group.workspaceId) - }} - /> - {group.intentHere && } - {group.sessions.map(node => ( - { setExpandedSessions((l) => toggled(l, id)) }} - /> - ))} -
- ))} -
- -
- ) -} - /** - * Render the sidebar column. + * Render the sidebar column shell. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). * @returns the sidebar element tree. */ export function SidebarRoot({ collapsed, width, - useSessions, - useWorkspaces, startSession, - open, toggleSidebar, renderSlot, }: SidebarRootComponentProps) { - const workspaces = useWorkspaces(state => state.items) - // The query outlives the tree and the input (both wide-only) so collapsing - // does not silently drop an in-progress filter. - const [query, setQuery] = useState('') - const searchInput = useRef(null) - // Section-header + opens the workspace picker (same popover in wide and - // rail states; the hole sits beside the button and opens rightward). - const [wsPickerOpen, setWsPickerOpen] = useState(false) - // Placement anchor for the picker popover: the slot span renders elsewhere - // in the DOM, so the picker positions off this button's rect. - const wsPlusRef = useRef(null) - // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. const [settled, setSettled] = useState(collapsed) @@ -186,19 +54,6 @@ export function SidebarRoot({ const everWide = useRef(!collapsed) if (!collapsed) everWide.current = true - // Rail search = expand + land in the search box: the flag arms before the - // expand toggle; once expanded the input is mounted and takes focus. - const [searchOnExpand, setSearchOnExpand] = useState(false) - useEffect(() => { - if (!collapsed && searchOnExpand) { - const timer = window.setTimeout(() => { - searchInput.current?.focus({ preventScroll: true }) - setSearchOnExpand(false) - }, EXPAND_SLIDE_MS) - return () => { window.clearTimeout(timer) } - } - }, [collapsed, searchOnExpand]) - return (
-
- {wide && Workspaces} - {wide && } - - - - {/* Picker hole beside the + (same site in wide and rail states). */} - {renderSlot('sidebar.workspace', { - open: wsPickerOpen, - anchorRef: wsPlusRef, - onPick: (workspaceId) => { - setWsPickerOpen(false) - startSession(workspaceId) - }, - onClose: () => { setWsPickerOpen(false) }, + {/* The browsing region fills the column between the controls and the + foot in both states; its rail icon column rides the same slot. */} +
+ {renderSlot('sidebar.workspaces', { + wide, + expandSidebar: () => { if (collapsed) toggleSidebar() }, })}
- {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Collapsed: the icon is the rail's search control. */} -
{ if (!collapsed) searchInput.current?.focus() }}> - - - - {wide && ( - { setQuery(e.target.value) }} - /> - )} - {wide && query !== '' && ( - - )} -
- - {/* Always-mounted seat: its flex slot pins the foot to the bottom in - both states while the tree itself is wide-only. */} -
- {wide && ( - - )} -
-
{wide && Settings} diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 0334ca88c9..1fd84312fa 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -1,69 +1,54 @@ /** * Sidebar slot contract: the registrant-side props composition for the - * layout-owned `sidebar` slot and the Workspace picker hole declared here. - * The runtime share combines layout-owned page state and actions with the - * global useSessions and useWorkspaces hooks; the injected share adds the - * runtime navigation actions and sidebar toggle. + * layout-owned `sidebar` slot, plus the workspace-browser hole this shell + * declares. The shell owns column geometry (fold state machine, brand row, + * New Session, Settings); everything between the section header and the list + * bottom is the `sidebar.workspaces` registrant's (ui-workspace). */ -import type { RefObject } from 'react' import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** - * The workspace picker hole in the sidebar section header (anchored at - * the + button). Declared by this package's 'sidebar' entry (declaring - * is claiming); ui-workspace registers the picker. + * The workspace/session browsing region: section header, search, the + * grouped/flat session list, and every workspace dialog. Declared by this + * package's 'sidebar' entry (declaring is claiming); ui-workspace + * registers the browser. */ - 'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps } + 'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps } } } /** - * Owner share of the sidebar workspace hole: popover geometry plus the - * sidebar's pick semantics. The picked Host Workspace is already real; the - * callback starts a frontend Session Intent targeted to it. + * Owner share of the browser hole — the only facts crossing the shell/region + * seam. Business data and actions arrive through the region's own inject. */ -export interface SidebarWorkspaceOwnerProps { - /** Popover visibility (+ button toggle state, host-local). */ - open: boolean - /** - * The + button element — the popover's placement anchor. The picker's - * slot span renders elsewhere in the DOM, so without this the menu - * positions off the zero-size placement span (order-dependent). Optional - * only until the host passes it; absent falls back to in-place placement. - */ - anchorRef?: RefObject - /** Start a frontend Session in a selected or newly created real Workspace. */ - onPick: (workspaceId: WorkspaceId) => void - /** Close the popover (outside click / Escape / post-pick). */ - onClose: () => void +export interface SidebarSectionOwnerProps { + /** Shell fold-state output: wide renders the full browser, rail the icon column. */ + wide: boolean + /** Rail icons request expansion; the browser rides the wide flip for focus. */ + expandSidebar: () => void } /** * Registrant-private injected share (arrives via the register inject - * factory). Host Workspace and Session data use the global framework hooks; - * navigation and panel actions are plain callbacks, and viewing state remains - * component-local. A type alias supplies the implicit index signature required - * by the registry. + * factory). The shell keeps only its own controls: starting a Session from + * the New Session button and toggling the column. */ export type SidebarRootInjected = { /** Start or replace the current frontend Session Intent. */ startSession: (workspaceId?: WorkspaceId, prompt?: string) => void - /** Open a real Session. */ - open: (sessionId: SessionId) => void /** Toggle the sidebar column through the layout service. */ toggleSidebar: () => void } /** - * Full component props: layout owner state/actions plus global useSessions - * and useWorkspaces, the declared Workspace picker render share, and this - * package's injected callback. No store is registered. + * Full component props: layout owner state/actions plus the browser hole's + * render share and this package's injected callbacks. No store is registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces'> & SidebarRootInjected diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0a1c8ebb12..0493ef2279 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,28 +1,27 @@ -/** Registers the sidebar UI into the layout-owned slot. */ +/** Registers the sidebar shell into the layout-owned slot. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' -export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts' +export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps } from './contract/slots.ts' /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces'] +export const inject = ['slots', 'layout', 'workspaces'] -/** Registers the sidebar component and its service callbacks. +/** Registers the sidebar shell and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) }, - open: (sessionId) => { ctx.sessions.open(sessionId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) ctx.effect( () => ctx.slots.register({ name: 'sidebar', - // SidebarRoot owns this picker site; ui-workspace registers the shared - // picker that selects a Host Workspace for a frontend Session Intent. - children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } }, + // The shell owns geometry; ui-workspace registers the whole browsing + // region (header, search, session list, workspace dialogs) here. + children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' } }, inject: injectProps, }, SidebarRoot), 'ui-sidebar: slot registration', diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 5d285bd20c..681454691c 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -1,4 +1,4 @@ -/** Sidebar slot registration and its plain runtime/layout callbacks. */ +/** Sidebar shell slot registration and its plain runtime/layout callbacks. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -9,10 +9,8 @@ async function bench(declare = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const layout = { toggleSidebar: vi.fn() } - const sessions = { open: vi.fn() } const workspaces = { startSession: vi.fn() } ctx.provide('layout', layout) - ctx.provide('sessions', sessions as never) ctx.provide('workspaces', workspaces as never) const slots = ctx.get('slots') as SlotsService if (declare) { @@ -21,25 +19,23 @@ async function bench(declare = true) { () => null, ) } - return { ctx, slots, layout, sessions, workspaces } + return { ctx, slots, layout, workspaces } } describe('ui-sidebar apply', () => { it('declares only the services it uses', () => { - expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces']) + expect(inject).toEqual(['slots', 'layout', 'workspaces']) }) - it('registers the sidebar and declares its Workspace picker hole', async () => { + it('registers the shell and declares the browsing-region hole', async () => { const b = await bench() await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.slots.entries('sidebar')).toHaveLength(1) - expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() - expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar']) + expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) injected.startSession('workspace' as never, 'prompt') expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt') - injected.open('session' as never) - expect(b.sessions.open).toHaveBeenCalledWith('session') injected.toggleSidebar() expect(b.layout.toggleSidebar).toHaveBeenCalledOnce() }) @@ -55,6 +51,6 @@ describe('ui-sidebar apply', () => { await fiber.await() await fiber.dispose() expect(b.slots.entries('sidebar')).toHaveLength(0) - expect(b.slots.spec('sidebar.workspace')).toBeUndefined() + expect(b.slots.spec('sidebar.workspaces')).toBeUndefined() }) }) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 26adafd793..6bdba770eb 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -1,79 +1,42 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { - SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' afterEach(() => { cleanup() vi.useRealTimers() }) -const sid = (id: string) => id as SessionId -const wid = (id: string) => id as WorkspaceId -const hook = (snapshot: T) => (selector: (state: T) => S): S => selector(snapshot) -const workspace: WorkspaceView = { - workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')], - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', -} -const sessions: SessionListState = { - ids: [sid('s1')], - byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } }, - current: undefined, phase: 'ready', - intent: undefined, -} -const workspaces: WorkspaceListState = { - items: [workspace], state: 'idle', phase: 'ready', error: null, - intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId, -} -function mount(sessionState: SessionListState = sessions) { - const startSession = vi.fn() - const open = vi.fn() - let pickerOwner: unknown - const view = render( - { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} - />, - ) - return { view, startSession, open, pickerOwner: () => pickerOwner } -} +// The shell never reads the global hooks itself, but they ride the standard +// props share; stub them as never-called functions. +const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never -function mountSidebar({ - sessionState = sessions, - workspaceState = workspaces, - collapsed = false, - width = 300, -}: { - sessionState?: SessionListState - workspaceState?: WorkspaceListState - collapsed?: boolean - width?: number -} = {}) { +function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) { const startSession = vi.fn() - const open = vi.fn() const toggleSidebar = vi.fn() - let pickerOwner: unknown - let current = { sessionState, workspaceState, collapsed, width } + let regionOwner: SidebarSectionOwnerProps | undefined + let current = { collapsed, width } const root = () => ( { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + useSessions={neverHook} useWorkspaces={neverHook} + startSession={startSession} toggleSidebar={toggleSidebar} + renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => { + regionOwner = owner + return
+ }) as SidebarRootComponentProps['renderSlot']} /> ) const view = render(root()) return { startSession, - open, toggleSidebar, - pickerOwner: () => pickerOwner, + regionOwner: () => { + if (regionOwner === undefined) throw new Error('region owner not rendered') + return regionOwner + }, rerender(next: Partial) { current = { ...current, ...next } view.rerender(root()) @@ -81,181 +44,40 @@ function mountSidebar({ } } -describe('SidebarRoot', () => { - it('renders real Workspaces from useWorkspaces and routes New Session', () => { - const b = mount() - expect(screen.getByText('Project')).toBeTruthy() +describe('SidebarRoot shell', () => { + it('routes New Session and the column toggle', () => { + const b = mountShell() fireEvent.click(screen.getByRole('button', { name: 'New session' })) expect(b.startSession).toHaveBeenCalledWith() - }) - - it('shows a frontend Session under its real Workspace and routes its row plus', () => { - const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const } - const b = mount({ - ...sessions, - current: intent.sessionId, - intent, - }) - expect(screen.getByText('New session')).toBeTruthy() - expect(screen.getByText('2 sessions')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'New session in Project' })) - expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) - }) - - it('forwards Workspace picker selection and closes the picker', () => { - const b = mount() - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void } - expect(owner.open).toBe(true) - owner.onPick(workspace.workspaceId) - expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) - }) - - it('opens a real Session through the owner action', () => { - const b = mount({ ...sessions, current: sid('intent'), intent: { - sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready', - } }) - fireEvent.click(screen.getByText('Project')) - fireEvent.click(screen.getByText('First session')) - expect(b.open).toHaveBeenCalledWith(sid('s1')) - }) - - it('opens, selects, dismisses, and toggles the group-by menu', () => { - mount() - const button = screen.getByRole('button', { name: 'Group by' }) - - fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' })) - expect(screen.queryByRole('menu')).toBeNull() - - fireEvent.click(button) - fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.queryByRole('menu')).toBeNull() - - fireEvent.click(button) - fireEvent.click(button) - expect(screen.queryByRole('menu')).toBeNull() - }) - - it('routes every Workspace picker close path', () => { - const b = mount() - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - const owner = b.pickerOwner() as { open: boolean; onClose(): void } - expect(owner.open).toBe(true) - act(() => { owner.onClose() }) - expect((b.pickerOwner() as { open: boolean }).open).toBe(false) - }) - - it('focuses, filters, and clears search while distinguishing both empty states', () => { - mount() - const input = screen.getByPlaceholderText('Search name, keywords...') - fireEvent.click(input.parentElement!) - expect(document.activeElement).toBe(input) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - - fireEvent.change(input, { target: { value: 'missing' } }) - expect(screen.getByText('No matches')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) - expect(screen.queryByText('No matches')).toBeNull() - - cleanup() - const emptySessions = listState() - const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined } - mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces }) - expect(screen.getByText('No sessions yet')).toBeTruthy() - }) - - it('toggles Workspace and nested Session expansion in both directions', () => { - const parent = sid('parent') - const child = sid('child') - const nestedSessions: SessionListState = { - ...sessions, - ids: [parent, child], - byId: { - [parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 }, - [child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent }, - }, - } - const nestedWorkspace: WorkspaceListState = { - ...workspaces, - items: [{ ...workspace, sessionIds: [parent, child] }], - } - mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace }) - - fireEvent.click(screen.getByText('Project')) - fireEvent.click(screen.getByRole('button', { name: 'Expand' })) - expect(screen.getByText('Child')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) - expect(screen.queryByText('Child')).toBeNull() - fireEvent.click(screen.getByText('Project')) - expect(screen.queryByText('Parent')).toBeNull() - }) - - it('does not start a Session from an Ungrouped row create action', () => { - const loose = sid('loose') - const looseSessions: SessionListState = { - ...listState(), - ids: [loose], - byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } }, - current: loose, - } - const b = mountSidebar({ - sessionState: looseSessions, - workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined }, - }) - fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) - expect(b.startSession).not.toHaveBeenCalled() - }) - - it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => { - const b = mountSidebar() - fireEvent.click(screen.getByText('Project')) - const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] } - b.rerender({ - sessionState: { ...sessions, current: sid('s1') }, - workspaceState: { ...workspaces, items: [other, workspace] }, - }) - expect(screen.getByText('First session')).toBeTruthy() - - b.rerender({ - sessionState: { - ...sessions, - current: sid('draft'), - intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' }, - }, - }) - expect(screen.getByText('Project')).toBeTruthy() - }) - - it('renders the static collapsed rail and expands rail search into focused input', () => { - vi.useFakeTimers() - const b = mountSidebar({ collapsed: true }) - expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' })) - expect(b.toggleSidebar).toHaveBeenCalledOnce() - - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - expect(b.toggleSidebar).toHaveBeenCalledTimes(2) - b.rerender({ collapsed: false }) - const input = screen.getByPlaceholderText('Search name, keywords...') - act(() => { vi.advanceTimersByTime(300) }) - expect(document.activeElement).toBe(input) - }) - - it('keeps wide content during live collapse, then settles to the rail', () => { - vi.useFakeTimers() - const b = mountSidebar({ width: 320 }) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() - b.rerender({ collapsed: true, width: 56 }) - expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy() - act(() => { vi.advanceTimersByTime(150) }) - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + }) + + it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => { + const b = mountShell() + expect(b.regionOwner().wide).toBe(true) + // Expanded: the request is a no-op (no accidental collapse). + b.regionOwner().expandSidebar() + expect(b.toggleSidebar).not.toHaveBeenCalled() + }) + + it('keeps the region mounted through collapse and expands on its request', () => { + vi.useFakeTimers() + const b = mountShell() + b.rerender({ collapsed: true }) + // Wide content survives the crossfade window, then settles into the rail. + expect(b.regionOwner().wide).toBe(true) + vi.advanceTimersByTime(200) + b.rerender({}) + expect(b.regionOwner().wide).toBe(false) + expect(screen.getByTestId('region')).toBeTruthy() + b.regionOwner().expandSidebar() + expect(b.toggleSidebar).toHaveBeenCalledOnce() + }) + + it('renders statically collapsed on a cold start (no crossfade classes)', () => { + const b = mountShell({ collapsed: true }) + expect(b.regionOwner().wide).toBe(false) expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() }) }) - -function listState(): SessionListState { - return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined } -} diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 0a6c82c0cf..c36486d2fd 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -35,6 +35,9 @@ "watch": "tsdown --watch" }, "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css new file mode 100644 index 0000000000..d6375cb698 --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -0,0 +1,265 @@ +/* Workspace browsing region (fills the sidebar shell's hole): section + header, search capsule, and the scrolling session list. Wide/rail + variants ride the shell's fold state through the `wide` owner prop — + rail state renders only the two 36x36 icon controls. */ + +.root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.iconButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +.iconButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Section header: 36px, "Workspaces/Sessions" label + group-by / + new-workspace buttons; the right-anchored new-workspace button is the + row's rail survivor. */ +.sectionHeader { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + height: 36px; + padding-left: 12px; + margin-bottom: 4px; + box-sizing: border-box; + border-radius: 12px; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); +} + +.sectionLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + line-height: 20px; +} + +/* Search input: 38px capsule (figma 133:7649); rail state renders it as the + region's search control. Upstream binds a dedicated design-system variable + (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component + token pinned to the static scale mirrors it. */ +.search { + --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); + flex: none; + display: flex; + align-items: center; + gap: 8px; + height: 38px; + margin: 0 2px 12px; + padding: 0 14px; + box-sizing: border-box; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 24px; + background: var(--dsh-search-input-fill); + color: var(--dsw-alias-label-caption); + overflow: hidden; +} + +:global(body[data-ds-dark-theme]) .search { + --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); +} + +/* The capsule's leading icon: decorative while wide (pointer-events off so + clicks reach the input), the hit target in rail state. */ +.searchButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + pointer-events: none; + color: inherit; +} + +.searchInput { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font-size: 14px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.searchInput::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.clearButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +/* Rail variant (own .rail class from the wide owner prop — the region never + reads the shell's class names): the two icon controls stack as 36x36 + circles matching the shell's rail rhythm. */ +.rail .sectionHeader { + padding-left: 0; + margin-bottom: 12px; +} + +.rail .iconButton { + width: 36px; + height: 36px; + color: var(--dsw-alias-label-primary); +} + +.rail .search { + height: 36px; + padding: 0; + margin: 0 0 12px; + gap: 0; + border-color: transparent; + background: transparent; +} + +.rail .searchButton { + width: 36px; + height: 36px; + pointer-events: auto; + cursor: pointer; + color: var(--dsw-alias-label-primary); +} + +.rail .searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* List seat: always mounted so the shell foot never moves. */ +.listArea { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Relative for the bottom fade overlay. */ +.treeBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} + +/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, + transparent -> sidebar fill so it tracks the theme. */ +.fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 72px; + background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); + pointer-events: none; +} + +/* Wide-only content fades back in on expand remount (mirrors the shell). */ +.wide { + animation: wide-in 200ms var(--ds-ease-in-out); +} + +@keyframes wide-in { + from { opacity: 0; } +} + +/* List: the only scrolling region. Block, not a flex column: as flex items + the 54/34 rows would shrink under content overflow; block children keep + their design heights and the 4px rhythm rides margins instead of gap. */ +.list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 12px; +} + +/* One workspace section: header row + expanded session run. Rows inside + keep the former flat-list 4px gap as sibling margins; the inter-group + breathing room (figma 133:7661 batch separator, 20px after an expanded + run) rides the NEXT section's top margin so the last group adds none. */ +.groupSection > * + * { + margin-top: 4px; +} + +.groupSection + .groupSection { + margin-top: 4px; +} + +.groupSection:has([aria-expanded='true']) + .groupSection { + margin-top: 20px; +} + +.empty { + padding: 16px 12px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; +} + +/* Rename dialog form (same figma dialog family as the create modals). */ +.renameInput { + box-sizing: border-box; + width: 100%; + height: 44px; + padding: 7px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; + outline: none; + background: transparent; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.renameInput:disabled { + color: var(--dsw-alias-label-dimmed); +} + +.renameError { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} + +@media (prefers-reduced-motion: reduce) { + .wide { + animation: none; + } +} diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx new file mode 100644 index 0000000000..e7d0cdc38c --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -0,0 +1,428 @@ +/** + * The workspace/session browsing region filling the sidebar shell's + * `sidebar.workspaces` hole: section header (title + group-by + new + * workspace), search, the grouped tree or flat list, and the workspace + * dialogs. Wide state renders the full browser; rail state renders the two + * region icons (search / new workspace), each requesting shell expansion + * through the owner share. The picker menu and create dialogs live in + * WorkspacePicker (same package — direct composition, no slot between them). + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import clsx from 'clsx' +import { + Button, IconCloseFill14, IconPersonalizationOutline16, + IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceBrowserProps } from './contract/slots.ts' +import type { SessionNode } from './tree.ts' +import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx' +import { WorkspaceCreateFlow } from './WorkspacePicker.tsx' +import css from './WorkspaceBrowser.module.css' + +/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ +const EXPAND_SLIDE_MS = 300 + +const GROUP_BY_ITEMS = [ + { type: 'label' as const, id: 'group-by', text: 'Group by' }, + { id: 'workspace', label: 'WorkSpace' }, + { id: 'flat', label: 'In one list' }, +] + +/** Immutable membership toggle for the local expansion arrays. */ +function toggled(list: readonly string[], key: string): string[] { + return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] +} + +/** Group-by strategy menu; own open state so it resets with the wide chrome. */ +function GroupByMenu({ groupBy, onPick }: { + groupBy: 'workspace' | 'flat' + onPick: (mode: 'workspace' | 'flat') => void +}) { + const [open, setOpen] = useState(false) + return ( + { setOpen(false) }} + items={GROUP_BY_ITEMS} + selectedId={groupBy} + onSelect={(id) => { + if (id === 'workspace' || id === 'flat') onPick(id) + setOpen(false) + }} + align="end" + // Portal: the section header clips overflow, so an in-place list would + // be cut off at the header's bounds. + portal + anchor={( + + )} + /> + ) +} + +/** In-flight root-row drag: source identity plus the current insert marker. */ +interface DragState { + workspaceId: WorkspaceId + sessionId: SessionNode['id'] + /** Row the marker sits on and which half (insert above/below it). */ + over: { id: SessionNode['id']; half: 'before' | 'after' } | null +} + +type SessionTreeProps = Pick< + WorkspaceBrowserProps, + 'useSessions' | 'startSession' | 'open' | 'insertSessionBefore' +> & { + workspaces: readonly WorkspaceView[] + /** Live search filter owned by the browser root (the query outlives the tree). */ + query: string + /** Open the browser-owned rename dialog for a real Workspace group. */ + onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void +} + +/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ +function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) { + const list = useSessions((s) => s) + const current = list.current + const [expandedProjects, setExpandedProjects] = useState([]) + const [expandedSessions, setExpandedSessions] = useState([]) + // Transient drag viewing state (never store-bound; order truth stays Host-side). + const [drag, setDrag] = useState(null) + // Re-expand when publication moves the selected intent into a real Workspace. + const intent = list.intent + const intentWorkspaceId = intent?.target.kind === 'workspace' + ? intent.target.workspaceId + : undefined + const currentGroup = current === undefined + ? undefined + : intent?.sessionId === current + ? intentWorkspaceId + : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) + ?? UNGROUPED_KEY + useEffect(() => { + if (current === undefined || currentGroup === undefined) return + setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) + }, [current, currentGroup]) + const groups = useMemo( + () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), + [list, workspaces, expandedProjects, expandedSessions, query], + ) + const now = Date.now() + + return ( +
+
+ {groups.length === 0 && ( +
{query === '' ? 'No sessions yet' : 'No matches'}
+ )} + {groups.map(group => ( + // Group section: header row + expanded session subtree. The + // inter-group breathing room (former flat-list batch separator) + // is the section's own margin (WorkspaceBrowser.module.css). +
+ { setExpandedProjects((l) => toggled(l, group.key)) }} + onCreate={() => { + if (group.workspaceId !== undefined) startSession(group.workspaceId) + }} + onRename={group.workspaceId === undefined + ? undefined + : () => { + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }} + /> + {group.expanded && group.intentHere && } + {group.sessions.map((node, index) => { + // Draggable: real-workspace group roots outside search. The drag + // never leaves its group — rows of other groups show no markers + // and reject drops (visual movement confined to this section). + const draggable = group.workspaceId !== undefined && query === '' + const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId + const dragProps = !draggable || group.workspaceId === undefined ? undefined : { + start: () => { + setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) + }, + active: sameGroupDrag, + marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, + hover: (half: 'before' | 'after') => { + setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) + }, + drop: (half: 'before' | 'after') => { + if (drag === null) return + const roots = group.sessions + // Anchor = the row the insert line points at ('after' means + // the next root; end-of-list omits the anchor → append). + const anchor = half === 'before' ? node.id : roots[index + 1]?.id + setDrag(null) + if (anchor === drag.sessionId) return + // No-op when the drop lands back on the source position. + const sourceIndex = roots.findIndex(r => r.id === drag.sessionId) + const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => { + console.warn('session reorder rejected:', reason) + }) + }, + end: () => { setDrag(null) }, + } + return ( + { setExpandedSessions((l) => toggled(l, id)) }} + drag={dragProps} + /> + ) + })} +
+ ))} +
+ +
+ ) +} + +/** The flat "In one list" body: every session a top-level row, newest-first. */ +function FlatList({ useSessions, open, query }: Pick) { + const list = useSessions((s) => s) + const rows = useMemo(() => deriveFlat(list, { query }), [list, query]) + const now = Date.now() + // The intent placeholder renders outside search only; it suppresses the + // empty state only while actually rendered (a query hides both). + const intentRow = query === '' && list.intent !== undefined + return ( +
+
+ {rows.length === 0 && !intentRow && ( +
{query === '' ? 'No sessions yet' : 'No matches'}
+ )} + {intentRow && } + {rows.map(node => ( + {}} + flat + /> + ))} +
+ +
+ ) +} + +/** + * Render the browsing region. + * @param props - composed slot props (shell owner share + store + injected actions). + * @returns the region element tree. + */ +export function WorkspaceBrowser({ + wide, + expandSidebar, + useSessions, + useWorkspaces, + useStore, + actions, + startSession, + open, + renameWorkspace, + insertSessionBefore, + createWorkspace, +}: WorkspaceBrowserProps) { + const workspaces = useWorkspaces(state => state.items) + const groupBy = useStore(s => s.groupBy) + // The query outlives the tree and the input (both wide-only) so collapsing + // does not silently drop an in-progress filter. + const [query, setQuery] = useState('') + const searchInput = useRef(null) + // Section-header + opens the picker menu (same popover in wide and rail + // states; the menu anchors on this button). + const [wsPickerOpen, setWsPickerOpen] = useState(false) + const wsPlusRef = useRef(null) + + // Rail search = expand + land in the search box: the flag arms before the + // expand request; once the shell flips wide the input mounts and takes focus. + const [searchOnExpand, setSearchOnExpand] = useState(false) + useEffect(() => { + if (wide && searchOnExpand) { + const timer = window.setTimeout(() => { + searchInput.current?.focus({ preventScroll: true }) + setSearchOnExpand(false) + }, EXPAND_SLIDE_MS) + return () => { window.clearTimeout(timer) } + } + }, [wide, searchOnExpand]) + + // Rename dialog (browser-owned so it outlives row unmounts during collapse). + const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null) + const [renameDraft, setRenameDraft] = useState('') + const [renaming, setRenaming] = useState(false) + const [renameError, setRenameError] = useState(null) + const renameTrimmed = renameDraft.trim() + const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle + && workspaces.some(w => w.title === renameTrimmed) + const renameBlocked = renaming || renameTrimmed === '' + || renameTarget === null || renameTrimmed === renameTarget.currentTitle || renameDuplicate + const closeRename = () => { + if (renaming) return + setRenameTarget(null) + setRenameError(null) + } + const confirmRename = () => { + if (renameBlocked || renameTarget === null) return + setRenaming(true) + setRenameError(null) + renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => { + setRenaming(false) + setRenameTarget(null) + }).catch((reason: unknown) => { + setRenaming(false) + setRenameError(reason instanceof Error ? reason.message : String(reason)) + }) + } + + return ( +
+
+ {wide && ( + + {groupBy === 'flat' ? 'Sessions' : 'Workspaces'} + + )} + {wide && { actions.setGroupBy(mode) }} />} + + + + {/* Picker menu + create dialogs (same package — direct composition). */} + { + setWsPickerOpen(false) + startSession(workspaceId) + }} + onClose={() => { setWsPickerOpen(false) }} + /> +
+ + {/* Expanded: the row is a click-to-focus field (the leading icon is + decorative). Rail: the icon is the region's search control. */} +
{ if (wide) searchInput.current?.focus() }}> + + + + {wide && ( + { setQuery(e.target.value) }} + /> + )} + {wide && query !== '' && ( + + )} +
+ + {/* Always-mounted seat keeps the region's flex slot while the list + itself is wide-only. */} +
+ {wide && (groupBy === 'flat' + ? + : ( + { + setRenameTarget({ workspaceId, currentTitle }) + setRenameDraft(currentTitle) + setRenameError(null) + }} + /> + ))} +
+ + + + + + )} + > + { setRenameDraft(e.target.value); setRenameError(null) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmRename() + } + }} + /> + {renameDuplicate && ( +
A workspace named “{renameTrimmed}” already exists.
+ )} + {renameError !== null &&
{renameError}
} +
+
+ ) +} diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 8f6f483580..2be39875bc 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -1,9 +1,15 @@ -/** Shared Workspace picker for the sidebar and New Session hero. */ +/** + * Workspace pick/create flow. WorkspaceCreateFlow is the reusable core + * (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same + * package) and wrapped by WorkspacePicker for the conversation empty-state + * slot registration. + */ +import type { RefObject } from 'react' import { useCallback, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -13,14 +19,35 @@ const CREATE_NEW = '::create-new' type ModalKind = 'path' | 'create' | null -export function WorkspacePicker({ +/** Core flow props: the owner supplies popover control and pick semantics. */ +export interface WorkspaceCreateFlowProps { + /** Popover visibility (anchor button toggle state, owner-local). */ + open: boolean + /** The anchor button element — the popover's placement anchor. */ + anchorRef?: RefObject | undefined + /** Selector hook over the workspace list (framework standard hook). */ + useWorkspaces: (selector: (state: WorkspaceListState) => S) => S + /** Create or adopt a real Host Workspace. */ + createWorkspace: (input: { name: string } | { path: string }) => Promise + /** A real Workspace was picked or created. */ + onPick: (workspaceId: WorkspaceId) => void + /** Close the popover (outside click / Escape / post-pick). */ + onClose: () => void +} + +/** + * Render the pick menu plus the two create dialogs. + * @param props - owner-controlled flow props. + * @returns menu + dialog elements. + */ +export function WorkspaceCreateFlow({ open, anchorRef, useWorkspaces, + createWorkspace, onPick, onClose, - createWorkspace, -}: WorkspacePickerProps) { +}: WorkspaceCreateFlowProps) { const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items const getAnchorRect = useCallback( @@ -194,3 +221,29 @@ export function WorkspacePicker({ ) } + +/** + * The conversation empty-state registration: adapts the owner share to the + * core flow (all state and semantics live in the flow / the owner). + * @param props - empty-state slot props (owner share + injected creation callback). + * @returns the flow element. + */ +export function WorkspacePicker({ + open, + anchorRef, + useWorkspaces, + onPick, + onClose, + createWorkspace, +}: WorkspacePickerProps) { + return ( + + ) +} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index a3045e5070..121e5d60f1 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -1,30 +1,59 @@ /** - * Shared Workspace picker contract for the sidebar and page-local Session Intent hero - * slots. Each runtime share provides its owner's popover controls plus the - * global useWorkspaces hook; this package adds the injected Host Workspace - * creation callback. + * ui-workspace contracts. Two registrations share this package: + * + * - WorkspaceBrowser fills the sidebar shell's `sidebar.workspaces` hole — + * the whole browsing region (section header, search, grouped/flat session + * list, workspace dialogs). It registers this package's viewing store and + * consumes the shell's two-fact owner share (wide / expandSidebar). + * - WorkspacePicker fills the conversation empty-state hole (menu + + * create dialogs shared with the browser). */ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pull both owner SlotMap merges into programs that resolve the -// picker runtime union below. +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pull the owner SlotMap merges into programs that resolve the +// runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { createWorkspaceViewStore } from '../stores.ts' /** - * Registrant-private injected share. Pick semantics remain in each owner's - * onPick callback; this callback creates only the real Host Workspace. A type - * alias supplies the implicit index signature required by the registry. + * Browser-private injected share (arrives via the register inject factory). + * Data reads use the global framework hooks; these are the Host actions the + * browsing region drives. + */ +export type WorkspaceBrowserInjected = { + /** Start or replace the current frontend Session Intent. */ + startSession: (workspaceId?: WorkspaceId, prompt?: string) => void + /** Open a real Session. */ + open: (sessionId: SessionId) => void + /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */ + renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise + /** + * Reorder a session inside its Workspace account (DOM-insertBefore + * semantics: omitted anchor appends to the end). The view refreshes from + * the Host response/changed frame; failures leave the order unchanged. + */ + insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise + /** Explicitly create or adopt a real Workspace before targeting a Session. */ + createWorkspace: (input: { name: string } | { path: string }) => Promise +} + +/** Full browser props: shell owner share + viewing store + injected actions. */ +export type WorkspaceBrowserProps = + PropsRuntime<'sidebar.workspaces'> + & PropsStore> + & WorkspaceBrowserInjected + +/** + * Picker-private injected share. Pick semantics remain in the owner's onPick + * callback; this callback creates only the real Host Workspace. A type alias + * supplies the implicit index signature required by the registry. */ export type WorkspacePickerInjected = { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace(input: { name: string } | { path: string }): Promise } -/** - * Full picker props: either owner's runtime share, including useWorkspaces, - * plus this package's injected creation callback. - */ +/** Full picker props: the empty-state owner share plus the creation callback. */ export type WorkspacePickerProps = - (PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>) - & WorkspacePickerInjected + PropsRuntime<'conversation.empty.workspace'> & WorkspacePickerInjected diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index aa54587650..50ccb3564c 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -1,54 +1,85 @@ /** - * Shared Workspace picker plugin, browser half. WorkspacePicker registers in - * the sidebar and page-local Session Intent hero slots, reads real Host Workspaces - * through the global useWorkspaces hook, and delegates selection semantics to - * each owner. Its injected share creates a Workspace without creating a - * Session. Export discipline: packages/client/AGENTS.md. + * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills + * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), + * and WorkspacePicker fills the conversation empty-state hole. Both read real + * Host Workspaces through the global useWorkspaces hook. Export discipline: + * packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspacePickerInjected } from './contract/slots.ts' +import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' +import { createWorkspaceViewStore } from './stores.ts' +import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' -export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts' +export type { + WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, +} from './contract/slots.ts' /** - * Required services (cordis fiber inject). The target slot is declared by - * the ui-sidebar apply, whose activation order relative to this one is NOT - * constrained: dshClient.inject edges are informational (loading/prefetch - * metadata, never apply sequencing) and the sidebar provides no waitable - * service. apply therefore registers via declaration-aware deferral instead - * of assuming order. + * Required services (cordis fiber inject). The target slots are declared by + * the ui-sidebar / ui-conversation applies, whose activation order relative + * to this one is NOT constrained: dshClient.inject edges are informational + * (loading/prefetch metadata, never apply sequencing) and neither owner + * provides a waitable service. apply therefore registers via + * declaration-aware deferral instead of assuming order. */ -export const inject = ['slots', 'workspaces'] +export const inject = ['slots', 'sessions', 'workspaces'] /** - * Register WorkspacePicker in both owner slots once their declarations are on - * the ledger. The inject factory returns a plain Workspace creation callback; - * data reads use the framework's global useWorkspaces hook. + * Register the browser and picker once their slot declarations are on the + * ledger. Inject factories return plain callbacks; data reads use the + * framework's global hooks. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const injected = (): WorkspacePickerInjected => ({ + const browserInjected = (): WorkspaceBrowserInjected => ({ + startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) }, + open: (sessionId) => { ctx.sessions.open(sessionId) }, + renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, + insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { + await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + }, createWorkspace: input => ctx.workspaces.create(input), }) - // Declaration-aware registration: the sidebar's declaring apply may - // activate after this one (entry activation order is unconstrained), and a - // register into an undeclared slot throws. Register once the declaration - // is on the ledger; the subscription also re-registers after an HMR - // collapse re-declares the slot (the cascade disposed our entry with it). + const pickerInjected = (): WorkspacePickerInjected => ({ + createWorkspace: input => ctx.workspaces.create(input), + }) + // Declaration-aware registration: each owner's declaring apply may activate + // after this one (entry activation order is unconstrained), and a register + // into an undeclared slot throws. Register once the declaration is on the + // ledger; the subscription also re-registers after an HMR collapse + // re-declares the slot (the cascade disposed our entry with it). ctx.effect(() => { - const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const - const disposers = new Map<(typeof slotNames)[number], () => void>() - const tryRegister = (name: (typeof slotNames)[number]): void => { - if (ctx.slots.spec(name) === undefined) return - if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return - disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker)) + const registrations = [ + { + name: 'sidebar.workspaces' as const, + component: WorkspaceBrowser, + register: () => ctx.slots.register( + { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, + WorkspaceBrowser, + ), + }, + { + name: 'conversation.empty.workspace' as const, + component: WorkspacePicker, + register: () => ctx.slots.register( + { name: 'conversation.empty.workspace', inject: pickerInjected }, + WorkspacePicker, + ), + }, + ] + const disposers = new Map void>() + const tryRegister = (entry: (typeof registrations)[number]): void => { + if (ctx.slots.spec(entry.name) === undefined) return + if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return + disposers.set(entry.name, entry.register()) } - const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) })) - for (const name of slotNames) tryRegister(name) + const unsubscribers = registrations.map(entry => + ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) + for (const entry of registrations) tryRegister(entry) return () => { for (const unsubscribe of unsubscribers) unsubscribe() for (const dispose of disposers.values()) dispose() } - }, 'ui-workspace: picker registrations') + }, 'ui-workspace: browser + picker registrations') } diff --git a/packages/client/ui-sidebar/src/client/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css similarity index 79% rename from packages/client/ui-sidebar/src/client/Rows.module.css rename to packages/client/ui-workspace/src/client/rows/Rows.module.css index f996f03348..7b19284b66 100644 --- a/packages/client/ui-sidebar/src/client/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -150,14 +150,63 @@ } .projectRow:hover .rowActions, -.sessionRow:hover .rowActions { +.sessionRow:hover .rowActions, +.projectRow.menuOpen .rowActions, +.sessionRow.menuOpen .rowActions { display: inline-flex; } -.sessionRow:hover .time { +.sessionRow:hover .time, +.sessionRow.menuOpen .time { display: none; } +/* An open row menu pins the hover affordances (figma: the row keeps its + hover fill while its dropdown is up). */ +.projectRow.menuOpen, +.sessionRow.menuOpen { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Drag reorder insert line (workspace-group roots): 2px accent above or + below the hovered row, drawn with box-shadow so no layout shift. */ +.sessionRow.dropBefore { + box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); +} + +.sessionRow.dropAfter { + box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); +} + +/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ +.hoverContent { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hoverTitle { + font-size: 14px; + line-height: 20px; + color: #FFFFFF; + overflow-wrap: break-word; +} + +.hoverTime { + font-size: 12px; + line-height: 16px; + color: #CFD3D6; +} + +.hoverStatus { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + line-height: 20px; + color: #ADB2B8; +} + .iconButton { flex: none; display: inline-flex; diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx new file mode 100644 index 0000000000..5a3f923be0 --- /dev/null +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -0,0 +1,285 @@ +/** + * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — + * all data and callbacks arrive via props. Hover swaps (folder->chevron, + * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only + * except workspace Rename; the session hover card is suppressed while a menu + * is open. + */ +import { useState } from 'react' +import clsx from 'clsx' +import { + HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, + IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { GroupNode, SessionNode } from '../tree.ts' +import { formatRelativeTime } from '../tree.ts' +import css from './Rows.module.css' + +/** Indent step per tree level: one 16px slot (figma session cell). */ +const INDENT_STEP = 16 + +const SESSION_MENU_ITEMS = [ + { id: 'rename', label: 'Rename', icon: }, + { id: 'fork', label: 'Fork session', icon: }, + { id: 'delete', label: 'Delete session', icon: , danger: true }, +] + +const WORKSPACE_MENU_ITEMS = [ + { id: 'rename', label: 'Rename', icon: }, + { id: 'delete', label: 'Delete workspace', icon: , danger: true }, +] + +/** + * Project (workspace) header row: 54px, folder + title + session count; + * hover reveals the chevron and create button. `containsCurrent` arrives on + * the node (derivation fact, no renderer scan). + * @param props.group - derived group node. + * @param props.onToggle - expand/collapse the group. + * @param props.onCreate - start a frontend Session inside this Workspace. + * @returns the row element. + */ +export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { + group: GroupNode + onToggle: () => void + onCreate: () => void + /** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */ + onRename?: (() => void) | undefined +}) { + const row = group + const active = group.expanded && group.containsCurrent + const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}` + const [menuOpen, setMenuOpen] = useState(false) + return ( +
+ + {row.expanded ? : } + + + + + + {row.label} + {count} + + + {onRename !== undefined && ( + { setMenuOpen(false) }} + items={WORKSPACE_MENU_ITEMS} + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename() + // Delete is visual-only for now. + }} + portal + closeOnPointerLeave + anchor={( + + )} + /> + )} + + +
+ ) +} + +/** + * The selected "New session" row for a frontend Session Intent targeted to a + * real Workspace. The row disappears when the Intent is replaced or connects. + * @param props.flat - flat-list variant: no twist slot (figma flat cell), so + * only the status slot indents the title. + * @returns the placeholder row element. + */ +export function IntentRowItem({ flat = false }: { flat?: boolean } = {}) { + return ( +
+ {!flat && } + + New session +
+ ) +} + +/** + * One session subtree: the node's own 34px row (indent by depth, expand + * twist when it has children, running dot, relative time) plus its visible + * children, recursively — the component tree mirrors the derived tree. + * @param props.node - derived session node. + * @param props.depth - 0 = directly under the group header. + * @param props.currentId - selected session id (row highlight). + * @param props.now - epoch ms for relative-time formatting. + * @param props.onOpen - open a session by id. + * @param props.onToggle - unfold/fold a subtree by id. + * @returns the node's row followed by its children. + */ +/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */ +function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) { + return ( +
+
{node.title}
+
{`${formatRelativeTime(node.updatedAt, now)} ago`}
+
+ + {node.running ? 'Running' : 'Idle'} +
+
+ ) +} + +/** + * Root-row drag wiring supplied by the group owner (workspace groups only). + * `drop` reports the half of the row the pointer released on: 'before' + * inserts above this row, 'after' below it (the owner resolves the anchor). + */ +export interface RowDragProps { + /** Start dragging this row. */ + start: () => void + /** A drag from the same group is in flight (rows show insert markers). */ + active: boolean + /** Current marker on this row: insert line above, below, or none. */ + marker: 'before' | 'after' | null + /** Report the hovered half while a same-group drag passes over this row. */ + hover: (half: 'before' | 'after') => void + drop: (half: 'before' | 'after') => void + end: () => void +} + +/** Pointer-position half of a row (insert line above or below). */ +function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + +export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: { + node: SessionNode + depth: number + currentId: string | undefined + now: number + onOpen: (id: SessionNode['id']) => void + onToggle: (id: SessionNode['id']) => void + /** Present only on draggable rows (workspace-group roots outside search). */ + drag?: RowDragProps | undefined + /** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */ + flat?: boolean +}) { + const row = node + const selected = node.id === currentId + const [menuOpen, setMenuOpen] = useState(false) + // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to + // the title): both slots are always reserved so titles align whether or not + // the twist/dot is lit. Extra depth rides the left padding. + const ownRow = ( +
{ onOpen(node.id) }} + draggable={drag !== undefined} + onDragStart={drag === undefined + ? undefined + : (e) => { + e.dataTransfer.effectAllowed = 'move' + drag.start() + }} + onDragEnd={drag?.end} + onDragOver={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + drag.hover(rowHalf(e)) + }} + onDrop={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + drag.drop(rowHalf(e)) + }} + > + {row.hasChildren && !flat + ? ( + + ) + : null} + {row.running && } + {row.title} + {formatRelativeTime(row.updatedAt, now)} + + { setMenuOpen(false) }} + items={SESSION_MENU_ITEMS} + onSelect={() => { setMenuOpen(false) }} // Visual-only for now. + portal + closeOnPointerLeave + anchor={( + + )} + /> + +
+ ) + return ( + <> + } + disabled={menuOpen || drag?.active === true} + /> + {node.children.map(child => ( + + ))} + + ) +} diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts new file mode 100644 index 0000000000..ed89d80d9e --- /dev/null +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -0,0 +1,36 @@ +/** + * The workspace browser's viewing store: the session-list grouping mode, + * persisted across reloads. Module level exports the factory only (a + * module-level handle would pin the store identity across plugin reloads); + * register() receives the factory and the browser derives its PropsStore + * share from the return type. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' + +/** Session-list grouping mode: workspace sections or one flat recency list. */ +export type WorkspaceGroupBy = 'workspace' | 'flat' + +/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */ +type WorkspaceViewState = { groupBy: WorkspaceGroupBy } + +/** + * Annotation twin of the actions literal below (the export needs a declared + * return type); drift fails assignability at the defineStore call. + */ +type WorkspaceViewActions = { + setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void +} + +/** + * Create the workspace browser viewing store handle. + * @returns the store handle (spec + type + identity + factory in one). + */ +export function createWorkspaceViewStore(): EngineStoreHandle { + return defineStore({ + init: (): WorkspaceViewState => ({ groupBy: 'workspace' }), + persist: 'dsh.workspace.view', + actions: { + setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, + }, + }) +} diff --git a/packages/client/ui-sidebar/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts similarity index 89% rename from packages/client/ui-sidebar/src/client/tree.ts rename to packages/client/ui-workspace/src/client/tree.ts index 64fda519e6..39a479f3b3 100644 --- a/packages/client/ui-sidebar/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -1,5 +1,5 @@ /** - * Derives the sidebar tree from Host Workspace order and membership. + * Derives the workspace browser tree from Host Workspace order and membership. * Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render. */ import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' @@ -223,11 +223,11 @@ function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { } /** - * Derive the nested sidebar group structure. + * Derive the nested workspace browser group structure. * * Normal mode: every group shows; sessions populate under expanded groups, * descending only into expanded sessions. A frontend Session Intent targeting - * a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query, + * a real Workspace marks that group `intentHere` (rendered only while the group is expanded; expansion stays viewer-owned). Search mode (non-blank query, * case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups * without a display-title or label hit are dropped, a label-only hit keeps @@ -263,7 +263,9 @@ export function deriveGroups( && g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId const intentHere = q === '' && hasIntent if (q === '') { - const expanded = intentHere || expandedProjects.has(g.key) + // The intent never forces expansion — the viewer auto-expands the + // target group once (current-group effect); the toggle stays live. + const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, workspaceId: g.workspaceId, @@ -294,6 +296,29 @@ export function deriveGroups( return groups } +/** + * Derive the flat session list ("In one list" mode): every session — fork + * children included — as a top-level row, strictly newest-first. No grouping, + * no parent/child adjacency; rows reuse SessionNode with children always + * empty so the renderer stays branch-free. Search mode filters by + * case-insensitive display-title substring. + * @param list - sessions list snapshot. + * @param view - the search query (expansion state does not apply). + * @returns flat rows in render order. + */ +export function deriveFlat(list: SessionListState, view: Pick): SessionNode[] { + const q = view.query.trim().toLowerCase() + const rows: SessionSummary[] = [] + for (const id of list.ids) { + const s = list.byId[id] + if (s === undefined) continue + if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue + rows.push(s) + } + rows.sort(byRecency) + return rows.map(s => sessionNode(s, [], false, false)) +} + /** * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). * @param updatedAt - epoch ms of the session's last activity. diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 9352caa0b4..6e1c7a3a3f 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -2,7 +2,8 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' -import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' async function bench() { @@ -13,32 +14,33 @@ async function bench() { path: 'name' in input ? `/projects/${input.name}` : input.path, title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) - ctx.provide('workspaces', { create }) - return { ctx, slots: ctx.get('slots') as SlotsService, create } + const startSession = vi.fn() + const rename = vi.fn(async () => ({})) + const insertSessionBefore = vi.fn(async () => ({})) + const open = vi.fn() + ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never) + ctx.provide('sessions', { open } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open } } -function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void { - return slots.register( - { name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never, - () => null, - ) -} +type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace' -function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected { - const entry = slots.entries(name)[0]! - return (entry.inject as () => WorkspacePickerInjected)() +/** Declare one or both holes with a single root registration ('root' is a single slot). */ +function declare(slots: SlotsService, ...names: HoleName[]): () => void { + const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }])) + return slots.register({ name: 'root', children } as never, () => null) } describe('ui-workspace apply', () => { - it('declares the independent Workspace service', () => { - expect(inject).toEqual(['slots', 'workspaces']) + it('declares the services it drives', () => { + expect(inject).toEqual(['slots', 'sessions', 'workspaces']) }) - it('registers the shared picker for declarations that arrive before or after apply', async () => { + it('registers browser and picker for declarations arriving before or after apply', async () => { const before = await bench() - declare(before.slots, 'sidebar.workspace') + declare(before.slots, 'sidebar.workspaces') await before.ctx.plugin({ inject: [...inject], apply }).await() - expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker) + expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser) const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() @@ -47,23 +49,35 @@ describe('ui-workspace apply', () => { expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker) }) - it('routes name and path creation to WorkspacesService', async () => { + it('routes browser actions and picker creation to the services', async () => { const b = await bench() - declare(b.slots, 'sidebar.workspace') + declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace') await b.ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(b.slots, 'sidebar.workspace') - await injected.createWorkspace({ name: 'project' }) - await injected.createWorkspace({ path: '/tmp/project' }) - expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' }) - expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' }) + + const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() + browser.startSession('ws' as never, 'prompt') + expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt') + browser.open('session' as never) + expect(b.open).toHaveBeenCalledWith('session') + await browser.renameWorkspace('ws' as never, 'renamed') + expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') + await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never) + expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') + await browser.createWorkspace({ name: 'project' }) + expect(b.create).toHaveBeenCalledWith({ name: 'project' }) + + const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)() + await picker.createWorkspace({ path: '/tmp/project' }) + expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) }) - it('unregisters picker entries on teardown', async () => { + it('unregisters both entries on teardown', async () => { const b = await bench() - declare(b.slots, 'sidebar.workspace') + declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace') const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() await fiber.dispose() - expect(b.slots.entries('sidebar.workspace')).toHaveLength(0) + expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0) + expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0) }) }) diff --git a/packages/client/ui-sidebar/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx similarity index 97% rename from packages/client/ui-sidebar/tests/rows.spec.tsx rename to packages/client/ui-workspace/tests/rows.spec.tsx index 468ce550d9..fdc5c43b8e 100644 --- a/packages/client/ui-sidebar/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' -import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' import type { GroupNode, SessionNode } from '../src/client/tree.ts' afterEach(cleanup) @@ -10,7 +10,7 @@ afterEach(cleanup) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId -describe('sidebar rows', () => { +describe('workspace browser rows', () => { it('renders an active Workspace and keeps its create action separate from toggling', () => { const onToggle = vi.fn() const onCreate = vi.fn() diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/tree.spec.ts rename to packages/client/ui-workspace/tests/tree.spec.ts diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2b76d407f7..200e0811ce 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -934,10 +934,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'list(): Workspace[]', jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */', }, - { - signature: 'async touchSession(sessionId: SessionId): Promise', - jsDoc: '/**\n * Move one accounted, cwd-validated session to the front of its workspace.\n * Ungrouped sessions and candidates filtered by the header check are\n * no-ops. The owning workspace\'s relative position never changes.\n * @param sessionId - Session whose activity was observed.\n * @returns resolution after the possible record write.\n */', - }, { signature: 'async resolveByPath(path: string): Promise', jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', @@ -2498,7 +2494,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Workspace', - declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise;\n attachSession(sessionId: SessionId): Promise;\n detachSession(sessionId: SessionId): Promise;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', + declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise;\n attachSession(sessionId: SessionId): Promise;\n insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise;\n detachSession(sessionId: SessionId): Promise;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', }, ] diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 201bc555a9..d50dd000e1 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -14,7 +14,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { - workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError, + workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, + WorkspaceMoveInvalidError, WorkspaceNameConflictError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' @@ -680,6 +681,72 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, + async rename(request) { + const { payload } = request + const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${payload.workspaceId}" not found`, + details: { workspaceId: payload.workspaceId }, + }) + } + const title = payload.title.trim() + // Uniqueness AND the same-title no-op both ride the create chain so + // they observe the state left by earlier queued renames — checked + // up front, a queued A→A could report success while an earlier A→B + // still lands afterwards. + const operation = workspaceCreationChain.then(async () => { + if (title === workspace.title) return + if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) { + throw new WorkspaceNameConflictError(title) + } + await workspace.setTitle(title) + }) + workspaceCreationChain = operation.then(() => undefined, () => undefined) + try { + await operation + } catch (error: unknown) { + if (error instanceof WorkspaceNameConflictError) { + return err(request, { + code: 'workspace-name-conflict', + message: error.message, + details: { name: error.workspaceName }, + }) + } + throw error + } + return ok(request, { workspace: workspaceView(workspace) }) + }, + + async insertSessionBefore(request) { + const { payload } = request + const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${payload.workspaceId}" not found`, + details: { workspaceId: payload.workspaceId }, + }) + } + try { + await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId) + } catch (error: unknown) { + // Only the entity's unaccounted-id rejection is the business code; + // storage/durability failures propagate as internal errors. + if (!(error instanceof WorkspaceMoveInvalidError)) throw error + return err(request, { + code: 'workspace-move-invalid', + message: error.message, + details: { + workspaceId: payload.workspaceId, + sessionId: payload.sessionId, + ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId }, + }, + }) + } + return ok(request, { workspace: workspaceView(workspace) }) + }, }, host: { diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 1f0f9acef4..68b6289858 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -19,6 +19,8 @@ export interface RpcMethodMap { 'host.describe': HostApi['describe'] 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] + 'workspace.rename': WorkspaceApi['rename'] + 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 3b290e18c3..d83ae2ce98 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -40,6 +40,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }), z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), + z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index dbcc975d04..ad06c42fbe 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -37,6 +37,7 @@ export interface RpcErrorDetailsMap { 'workspace-not-found': { workspaceId: string } 'workspace-invalid-path': { path: string } 'workspace-name-conflict': { name: string } + 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } 'agent-busy': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index a193fb0c57..47c3ae6d59 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -44,3 +44,29 @@ export const workspaceCreateValueSchema = z.object({ workspace: workspaceViewSchema, created: z.boolean(), }) satisfies z.ZodType>> + +/** workspace.rename request payload: the new title must be non-blank. */ +export const workspaceRenameRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + title: z.string(), +}).refine( + payload => payload.title.trim() !== '', + { message: 'workspace.rename requires a non-blank title' }, +) satisfies z.ZodType>> + +/** workspace.rename response value. */ +export const workspaceRenameValueSchema = z.object({ + workspace: workspaceViewSchema, +}) satisfies z.ZodType>> + +/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ +export const workspaceInsertSessionBeforeRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + sessionId: sessionIdSchema, + beforeSessionId: sessionIdSchema.optional(), +}) satisfies z.ZodType>> + +/** workspace.insertSessionBefore response value. */ +export const workspaceInsertSessionBeforeValueSchema = z.object({ + workspace: workspaceViewSchema, +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 86c20e2ff5..6ec636126b 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -24,7 +24,10 @@ export interface WorkspaceView { path: string /** Unique display title (defaults to the path basename at create). */ title: string - /** Sessions accounted under this workspace, newest-first for display. */ + /** + * Sessions accounted under this workspace, in manually owned order + * (attach prepends, insertSessionBefore reorders; activity never does). + */ sessionIds: SessionId[] /** ISO-8601 creation instant. */ createdAt: string @@ -52,4 +55,27 @@ export interface WorkspaceApi { */ create(request: RpcRequest<{ path?: string; name?: string }>): Promise> + + /** + * Renames a workspace. `title` is trimmed and must be non-empty + * (schema-enforced). An unknown id fails with `workspace-not-found`; a + * title equal to another workspace's fails with `workspace-name-conflict`. + * Renaming to the current title is a no-op success (no durable write). + */ + rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>): + Promise> + + /** + * Moves an accounted session within its workspace's manual order, + * DOM-insertBefore-like: with `beforeSessionId` the session is inserted + * before that anchor; omitted appends to the end. An unknown workspace + * fails with `workspace-not-found`; a session or anchor not accounted by + * the workspace fails with `workspace-move-invalid`. A move to the current + * position is a no-op success. + */ + insertSessionBefore(request: RpcRequest<{ + workspaceId: WorkspaceId + sessionId: SessionId + beforeSessionId?: SessionId + }>): Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 81749fc219..91c4405ace 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -23,7 +23,9 @@ import { } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, + workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, + workspaceRenameValueSchema, } from '../api/workspace.schema.ts' /** @@ -55,6 +57,8 @@ export interface IApiClient { workspace: { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> + rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> + insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -77,6 +81,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.list', payload, signal), create: (payload, signal) => this.callUnary('workspace.create', payload, signal), + rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), + insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), } readonly events: IApiClient['events'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index e876d664b2..91762810e8 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -24,7 +24,9 @@ import { import { hostDescribeRequestSchema } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, + workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' /** @@ -50,6 +52,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, + 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, + 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 7dec5980eb..25b095b99f 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -37,6 +37,8 @@ function scriptedApi(overrides: { workspace: { list: r => ok(r, { items: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), + rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index ede6acb9ac..e4b1d33e87 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -52,6 +52,18 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } }, } }, + async rename(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, + } + }, + async insertSessionBefore(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, + } + }, }, events: { mux: (_request, signal) => stream(muxFrames, signal), diff --git a/packages/workspace/workspace/src/entity.ts b/packages/workspace/workspace/src/entity.ts index 7f213db0b8..1df3eacb3d 100644 --- a/packages/workspace/workspace/src/entity.ts +++ b/packages/workspace/workspace/src/entity.ts @@ -15,6 +15,17 @@ import type { WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId } from './types.ts' import { realpathNormalize } from './paths.ts' +/** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */ +export class WorkspaceMoveInvalidError extends Error { + /** + * @param message - Which id was unaccounted and where. + */ + constructor(message: string) { + super(message) + this.name = 'WorkspaceMoveInvalidError' + } +} + /** * The registry-owned machinery an entity mutates through. Entities never see * the registry itself — only the open table, the canonical session-path @@ -137,30 +148,27 @@ export class WorkspaceEntity implements Workspace { : { ...record, sessionIds: [sessionId, ...record.sessionIds] }) } - /** - * Test the durable candidate account without applying header projection. - * @param sessionId - Candidate session id. - * @returns whether this workspace's stored account contains the id. - */ - hasSession(sessionId: SessionId): boolean { - return this.record.sessionIds.includes(sessionId) - } - - /** - * Move one validated accounted session to the front without touching peers. - * @param sessionId - Accounted session whose activity was observed. - */ - async touchSession(sessionId: SessionId): Promise { - if ( - this.host.sessionPath(sessionId) !== this.record.path - || this.record.sessionIds[0] === sessionId - ) return - await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId - ? record - : { - ...record, - sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)], - }) + async insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise { + await this.mutate((record) => { + if (!record.sessionIds.includes(sessionId)) { + throw new WorkspaceMoveInvalidError( + `cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`, + ) + } + if (beforeSessionId !== undefined && !record.sessionIds.includes(beforeSessionId)) { + throw new WorkspaceMoveInvalidError( + `cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': ` + + 'the anchor session is not accounted', + ) + } + if (beforeSessionId === sessionId) return record + const without = record.sessionIds.filter(id => id !== sessionId) + const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) + const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] + return sessionIds.every((id, index) => id === record.sessionIds[index]) + ? record + : { ...record, sessionIds } + }) } async detachSession(sessionId: SessionId): Promise { diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index c20c2143c6..0f849e7374 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -14,6 +14,8 @@ import type {} from '@deepseek-ai/dsh-session-persistence' import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain' import { WorkspaceEntity } from './entity.ts' import type { WorkspaceEntityHost } from './entity.ts' + +export { WorkspaceMoveInvalidError } from './entity.ts' import { realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' @@ -47,6 +49,7 @@ export class WorkspaceNameConflictError extends Error { } } + declare module 'cordis' { interface Context { workspace: WorkspaceRegistry @@ -82,7 +85,6 @@ export class WorkspaceRegistry extends Service { private readonly headers = new Map() private readonly sessionPaths = new Map() private readonly invalidSessionPaths = new Map() - private readonly pendingTouches = new Map>() private operationTail: Promise = Promise.resolve() private readonly host: WorkspaceEntityHost = { @@ -120,13 +122,6 @@ export class WorkspaceRegistry extends Service { this.validateStoredState(this.requireState()) this.rebuildEntities() this.reportFilteredCandidates() - // Session activity is authoritative even when no RPC/SSE consumer is - // connected. This service-owned listener is disposed with the registry. - this.ctx.on('session/event', (session) => { - void this.touchSession(session.id).catch((error: unknown) => { - this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`) - }) - }) } /** @@ -173,32 +168,6 @@ export class WorkspaceRegistry extends Service { }) } - /** - * Move one accounted, cwd-validated session to the front of its workspace. - * Ungrouped sessions and candidates filtered by the header check are - * no-ops. The owning workspace's relative position never changes. - * @param sessionId - Session whose activity was observed. - * @returns resolution after the possible record write. - */ - async touchSession(sessionId: SessionId): Promise { - const pending = this.pendingTouches.get(sessionId) - if (pending !== undefined) { - await pending - return - } - for (const entity of this.entities.values()) { - if (!entity.hasSession(sessionId)) continue - const touch = entity.touchSession(sessionId) - this.pendingTouches.set(sessionId, touch) - try { - await touch - } finally { - this.pendingTouches.delete(sessionId) - } - return - } - } - /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index ca254ca2cb..09d37213cc 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -41,10 +41,12 @@ export interface Workspace { readonly updatedAt: string /** - * Header-validated sessions in newest-first display order. The durable - * candidate account is filtered synchronously: missing headers, invalid - * cwd values, and canonical cwd mismatches are never returned. A subsequent - * workspace mutation prunes those filtered candidates durably. + * Header-validated sessions in manually owned order: a new session is + * prepended at attach, explicit reordering goes through + * `insertSessionBefore`, and activity never reorders. The durable candidate + * account is filtered synchronously: missing headers, invalid cwd values, + * and canonical cwd mismatches are never returned. A subsequent workspace + * mutation prunes those filtered candidates durably. */ readonly sessionIds: readonly SessionId[] @@ -57,8 +59,7 @@ export interface Workspace { /** * Prepend a session to this workspace's candidate account. An already - * accounted id resolves without writing; activity-driven reordering uses - * `WorkspaceRegistry.touchSession` instead. A new id's live or persisted + * accounted id resolves without writing. A new id's live or persisted * header cwd must resolve to an existing directory equal to {@link path}; * unknown ids, missing or invalid cwd values, and mismatches reject without * writing. @@ -67,6 +68,18 @@ export interface Workspace { */ attachSession(sessionId: SessionId): Promise + /** + * Move an accounted session within the manual order, DOM-insertBefore-like: + * with an anchor the session lands before it, without one it appends to the + * end. Only the moved id changes position. A session or anchor absent from + * the account rejects without writing; a move to the current position + * resolves without writing (decided on the domain write chain). + * @param sessionId - The accounted session to move. + * @param beforeSessionId - Accounted anchor to insert before; omitted appends. + * @returns resolution after durability. + */ + insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise + /** * Remove a session from this workspace's account. Idempotent: an id not on * the account resolves without writing (decided on the domain write chain, diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index cde0b35098..bdefc2ec82 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,6 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import { WorkspaceEntity } from '../src/entity.ts' import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' @@ -434,13 +433,12 @@ describe('WorkspaceRegistry create and lookup', () => { }) describe('Workspace session ordering', () => { - it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => { + it('prepends new attaches and keeps repeat attach idempotent', async () => { const dir = await makeDir('attach-order') const result = await harness() result.setSessions([ header('s1', dir, 1), header('s2', dir, 2), - header('ungrouped', dir, 3), ]) const workspace = await result.registry.create(dir) await workspace.attachSession(SessionId('s1')) @@ -448,59 +446,7 @@ describe('Workspace session ordering', () => { expect(workspace.sessionIds).toEqual(['s2', 's1']) await workspace.attachSession(SessionId('s1')) expect(workspace.sessionIds).toEqual(['s2', 's1']) - - const beforeTouch = result.changes.length - await Promise.all([ - result.registry.touchSession(SessionId('s1')), - result.registry.touchSession(SessionId('s1')), - ]) - expect(workspace.sessionIds).toEqual(['s1', 's2']) - expect(result.changes).toHaveLength(beforeTouch + 1) - await result.registry.touchSession(SessionId('s1')) - expect(result.changes).toHaveLength(beforeTouch + 1) - await result.registry.touchSession(SessionId('ungrouped')) - expect(result.changes).toHaveLength(beforeTouch + 1) - expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2']) - }) - - it('does not resurrect a session detached before its queued touch', async () => { - const dir = await makeDir('detach-touch-race') - const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] }) - const workspace = await result.registry.create(dir) - await workspace.attachSession(SessionId('s1')) - await workspace.attachSession(SessionId('s2')) - await Promise.all([ - workspace.detachSession(SessionId('s1')), - result.registry.touchSession(SessionId('s1')), - ]) - const written = result.changes.length - await workspace.detachSession(SessionId('absent')) - expect(result.changes).toHaveLength(written) - expect(workspace.sessionIds).toEqual(['s2']) - }) - - it('does not reinsert a candidate absent at the durable touch slot', async () => { - const dir = await makeDir('stale-touch') - const id = WorkspaceId('00000000-0000-4000-8000-000000000030') - let durable = record(dir, ['s2', 's1']) - const table = { - update: async ( - _id: WorkspaceId, - update: (current: WorkspaceRecord) => WorkspaceRecord, - ): Promise => { - durable = { ...durable, sessionIds: [SessionId('s2')] } - durable = update(durable) - return durable - }, - } - const entity = new WorkspaceEntity({ - table: () => table as never, - sessionPath: () => dir, - readSessionHeader: async () => header('s1', dir), - rememberSessionPath: () => {}, - }, id, record(dir, ['s2', 's1'])) - await entity.touchSession(SessionId('s1')) - expect(durable.sessionIds).toEqual(['s2']) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1']) }) it('validates a lazy live session without requiring it in persistence.list()', async () => { @@ -546,66 +492,6 @@ describe('Workspace session ordering', () => { expect(workspace.sessionIds).toEqual(['s1']) }) - it('keeps workspace order stable while touch order survives reload', async () => { - const older = await makeDir('stable-older') - const newer = await makeDir('stable-newer') - const sessions = [ - header('old-1', older, 100), - header('old-2', older, 200), - header('new-1', newer, 300), - ] - const pool = new MemoryMediaPool() - const first = await harness({ pool, sessions }) - const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id) - const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)! - expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1']) - await first.registry.touchSession(SessionId('old-1')) - expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2']) - expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) - await first.fiber.dispose() - - const reloaded = await harness({ pool, sessions }) - expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) - expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds) - .toEqual(['old-1', 'old-2']) - }) - - it('persists activity order from session/event without any stream consumer', async () => { - const dir = await makeDir('event-touch') - const result = await harness({ sessionStore: true }) - const workspace = await result.registry.create(dir) - const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } }) - result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } }) - await workspace.attachSession(SessionId('event-first')) - await workspace.attachSession(SessionId('event-second')) - expect(workspace.sessionIds).toEqual(['event-second', 'event-first']) - - first.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) }) - expect(storedRecord(result.pool, workspace.id).sessionIds) - .toEqual(['event-first', 'event-second']) - }) - - it('contains a background activity write failure at the service listener', async () => { - const dir = await makeDir('event-touch-failure') - const result = await harness({ sessionStore: true }) - const workspace = await result.registry.create(dir) - const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } }) - result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } }) - await workspace.attachSession(SessionId('failed-first')) - await workspace.attachSession(SessionId('failed-second')) - const warn = vi.spyOn(result.ctx.logger, 'warn') - result.pool.failNextWrites = 1 - first.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) }) - expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first']) - }) }) describe('header-validated membership projection', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19dcce27..7301079cd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1014,6 +1014,10 @@ importers: version: 18.3.1 packages/client/ui-workspace: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ From b1b180e098aef9e39e3c2e889dca7dcd48fea6c1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:57:21 +0800 Subject: [PATCH 13/45] =?UTF-8?q?fix(gui):=20CI=20gate=20repairs=20?= =?UTF-8?q?=E2=80=94=20docs,=20coverage,=20HMR=20re-registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate the module-graph/config-catalog/event-graph docs for the locale rename and new packages; allowlist the three settings READMEs; complete the RFC code block and add its English pair. Cover the ThemeService media-query paths (stubbed matchMedia) and mark the unreachable registry fallback. General section re-registration now judges presence on the slot ledger instead of a local disposer, which went stale when an HMR collapse removed the entry. --- ...-25-client-settings-locale-theme.i18n.yaml | 6 + ...2026-07-25-client-settings-locale-theme.md | 112 ++++++++++++++++++ ...6-07-25-client-settings-locale-theme.zh.md | 13 ++ docs/config-catalog.md | 5 +- docs/event-producer-consumer.md | 2 + docs/module-graph.md | 26 +++- .../ui-settings-general/src/client/index.ts | 20 ++-- packages/client/ui-theme/src/client/index.ts | 1 + packages/client/ui-theme/tests/theme.spec.ts | 51 +++++++- .../verify-package-readme-model-experience.ts | 3 + 10 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml new file mode 100644 index 0000000000..54fb6ee9ee --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 +2026-07-25-client-settings-locale-theme.md: 25e22c728ea509613c4d7f6cdfcd09faf4685760 +2026-07-25-client-settings-locale-theme.zh.md: 9c2457b254aa826291d4c5a4e115e9ba2d391974 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md new file mode 100644 index 0000000000..25e22c728e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -0,0 +1,112 @@ +# Agent Note: Client Settings, Locale, and Theme layering + +Status: proposed + +English | [中文](2026-07-25-client-settings-locale-theme.zh.md) + +## Problem + +The browser client's existing Settings is written directly inside the Sidebar, and language and theme are applied by component-local state mutating the DOM directly. As a result Settings cannot be extended by independent plugins, preference state has no stable cross-plugin service contract, and the theme registry carries both state and presentation responsibilities. + +## Proposal + +The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares the `settings.section` list slot. Each section is contributed by an independent plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. + +The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind. + +`@deepseek-ai/dsh-client-locale` provides `ctx.locale`; `ui-theme` provides `ctx.theme`. Both services read through a getter, write through a setter, and publish immutable snapshots via typed Cordis change events; each service persists its own preference (storing only the id, with bad values falling back to the default). + +General's apply layer subscribes to `locale/change` and `theme/change` and projects the snapshots into the Zustand store declared by that section. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services. + +The theme preference has three states — `light`, `dark`, `system` — defaulting to `system` (when no persisted preference exists or the value is bad). Resolving system belongs to the theme domain: ThemeService holds the `prefers-color-scheme` matchMedia listener (environment sensing, not DOM presentation) and re-emits the snapshot when the preference is system and the system color scheme changes; the snapshot carries both `preference` and the resolved `active` definition. + +The theme service never touches the DOM. `ui-layout` reads the Theme getter initially and then subscribes to `theme/change`; the presenter owned by Layout updates `body[data-ds-dark-theme]` and the theme tokens according to `active`. The presenter has no notion of system — it consumes only resolved results. + +### First-phase section scope + +| section | Plugin | First-phase content | +|---|---|---| +| General | `ui-settings-general` | Language (Selector dropdown) and Appearance (Light/Dark/System three cubes) genuinely switch; Permission and Tool Call are visual skeletons only, with no write operations | +| Models | `ui-settings-models` | Navigation item only; the content area is empty | +| Plugin | no package | Not built this phase, and the navigation does not show the item (an external-link entry with no target never renders; once a later plugin registers the section it appears automatically) | + +The first phase localizes only the copy inside the Settings overlay (the General rows plus the navigation); copy on other pages is untouched. + +### Slot topology + +```text +root +└─ sidebar + └─ sidebar.settings single/root + └─ ui-settings + └─ settings.section list/root + ├─ general ui-settings-general + └─ models ui-settings-models +``` + +Section contributions use declaration-aware deferral and do not depend on the client manifest's apply order. + +### Service contracts + +```ts +type ThemePreference = 'light' | 'dark' | 'system' + +interface ThemeDefinition { + id: string + colorScheme: 'light' | 'dark' + tokens: Record +} + +interface ThemeSnapshot { + preference: ThemePreference + active: ThemeDefinition // system 已解析为具体 light/dark 定义 + themes: readonly ThemeDefinition[] + revision: number +} + +interface LocaleDefinition { + id: 'zh' | 'en' + label: string +} + +interface LocaleSnapshot { + active: 'zh' | 'en' + locales: readonly LocaleDefinition[] + revision: number +} + +interface Events { + /** @param snapshot - Current locale registry snapshot. @mode emit */ + 'locale/change'(snapshot: LocaleSnapshot): void + /** @param snapshot - Current theme registry snapshot. @mode emit */ + 'theme/change'(snapshot: ThemeSnapshot): void +} +``` + +Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the only write entry points, and an unknown id fails. + +## Alternatives considered + +**Having the app shell subscribe to preferences centrally and re-render the root slot tree.** A language or theme change only needs to update the actual consumers; a whole-tree refresh amplifies the blast radius and wires business preferences into the shell. + +**The theme service mutating the DOM directly.** The registry service would then depend on the presentation environment, with unclear lifecycle and global-style ownership; Layout already owns the page-root presentation boundary. + +**Resolving system in the Layout presenter.** The presenter would need its own matchMedia subscription and would pick the concrete definition out of the themes list, forcing the presentation layer to understand preference semantics; resolving on the service side gives every consumer the same resolved snapshot. + +**Settings importing and enumerating the sections.** Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin. + +**Injecting the Locale/Theme snapshots into React directly.** Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding. + +## Acceptance criteria + +- The Settings shell depends only on the slot ledger, never on any section implementation. +- Locale and Theme writes go only through the setters; ongoing synchronization goes only through the change events. +- The General store initializes from the getters and is thereafter updated by the two events with local re-renders. +- Layout applies the theme snapshot on its own and the theme service never accesses the DOM; no system branch appears in the presenter. +- 中文/English and Light/Dark/System switch and are restored after a refresh; with the preference on system, a system color-scheme change takes effect immediately. +- Models has only a navigation item and an empty content area; the Permission and Tool Call skeletons perform no writes. +- The overlay closes via the close button, a mask click, and ESC. + +## Risks + +The apply order of slot declarations and contributions is not fixed, so every new section must keep declaration-aware registration and idempotence guards. Service events may fire before a section's first render, so both the General store's init and the controller attach must align to the current snapshot from the getters. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index d3c61794b9..9c2457b254 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -2,6 +2,8 @@ Status: proposed +[English](2026-07-25-client-settings-locale-theme.md) | 中文 + ## Problem 浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。 @@ -49,6 +51,12 @@ section contribution 使用 declaration-aware deferral,不依赖 client manife ```ts type ThemePreference = 'light' | 'dark' | 'system' +interface ThemeDefinition { + id: string + colorScheme: 'light' | 'dark' + tokens: Record +} + interface ThemeSnapshot { preference: ThemePreference active: ThemeDefinition // system 已解析为具体 light/dark 定义 @@ -56,6 +64,11 @@ interface ThemeSnapshot { revision: number } +interface LocaleDefinition { + id: 'zh' | 'en' + label: string +} + interface LocaleSnapshot { active: 'zh' | 'en' locales: readonly LocaleDefinition[] diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6c7627aca4..ede904e1a5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2017,12 +2017,15 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) -- `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts)) +- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d9521f7d67..2c1e85fb3c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -63,6 +63,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/dispatch` | - | [`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/ui/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), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | +| `locale/change` | `locale` (`emit`) | `ui-settings-general`, `ui-settings-models` | | `slots/changed` | `runtime` (`emit`) | - | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-settings-general` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 9e8b3adf8a..c47bd92d83 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -136,13 +136,16 @@ flowchart TD subgraph group_client["packages/client"] pkg_client_connection["client-connection"] pkg_client_hmr["client-hmr"] - pkg_client_i18n["client-i18n"] + pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_runtime["client-runtime"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] + pkg_client_ui_settings["client-ui-settings"] + pkg_client_ui_settings_general["client-ui-settings-general"] + pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_theme["client-ui-theme"] @@ -225,7 +228,7 @@ flowchart TD pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_loader_smoke --> pkg_invariants - pkg_client_i18n --> pkg_invariants + pkg_client_locale --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants @@ -253,7 +256,19 @@ flowchart TD pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_settings_models --> pkg_client_runtime + pkg_client_ui_settings_models --> pkg_client_ui_slots + pkg_client_ui_settings_models --> pkg_invariants pkg_client_ui_sidebar --> pkg_client_runtime pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots @@ -798,7 +813,7 @@ flowchart TD | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | -| [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) | +| [`client-locale`](../packages/client/locale) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | @@ -817,7 +832,10 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `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-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`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-settings-general`](../packages/client/ui-settings-general) | `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-settings-models`](../packages/client/ui-settings-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `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-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 0e59ce2116..f8f8a73304 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -79,7 +79,14 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { let dispose: (() => void) | undefined - const register = (): void => { + // Presence is judged on the ledger, not on the local disposer: an HMR + // collapse of the declaring entry removes this entry from the slot core + // while `dispose` stays set (the stale disposer is a no-op), so a local + // guard would block the re-registration when the declaration returns. + const registered = (): boolean => + ctx.slots.entries('settings.section').some(e => e.component === GeneralSection) + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined || registered()) return dispose = ctx.slots.register({ name: 'settings.section', id: 'general', @@ -89,16 +96,13 @@ export function apply(ctx: ClientContext): void { inject: injected, }, GeneralSection) } - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return - register() - } // Nav labels are registrant-localized: re-register on locale change so // the ledger carries fresh text (the version bump re-renders the shell). const offLocale = ctx.on('locale/change', () => { - if (dispose === undefined) return - dispose() - register() + if (!registered()) return + dispose?.() + dispose = undefined + tryRegister() }) const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) tryRegister() diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 5ba5d40b35..b8a75c4bed 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -157,6 +157,7 @@ export class ThemeService { : this.preference // Both built-ins always exist; a registered preference id resolves or has // been reset by its disposer, so the lookup cannot miss. + /* v8 ignore next -- the ?? arm needs a registry without light/dark, which register()/dispose() cannot produce */ const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]! return Object.freeze({ preference: this.preference, diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 9ab0ab8f0f..fdac111e73 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, 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' @@ -87,4 +87,53 @@ describe('ThemeService', () => { dispose() expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) + + describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { + type Listener = () => void + const stubMedia = (initialMatches: boolean) => { + const listeners = new Set() + const media = { + matches: initialMatches, + addEventListener: (_: 'change', fn: Listener) => { listeners.add(fn) }, + removeEventListener: (_: 'change', fn: Listener) => { listeners.delete(fn) }, + flip() { + this.matches = !this.matches + for (const fn of listeners) fn() + }, + listenerCount: () => listeners.size, + } + vi.stubGlobal('matchMedia', () => media) + return media + } + + afterEach(() => { vi.unstubAllGlobals() }) + + it('system resolves against the media query and follows OS flips', () => { + const media = stubMedia(true) + const { theme, events } = make() + expect(theme.getTheme().preference).toBe('system') + expect(theme.getTheme().active.id).toBe('dark') + media.flip() + expect(theme.getTheme().active.id).toBe('light') + expect(events).toHaveLength(1) + }) + + it('OS flips do not republish while a concrete preference is set', () => { + const media = stubMedia(false) + const { theme, events } = make() + theme.setTheme('light') + expect(events).toHaveLength(1) + media.flip() + expect(events).toHaveLength(1) + expect(theme.getTheme().active.id).toBe('light') + }) + + it('context dispose releases the media listener', async () => { + const media = stubMedia(false) + const { ctx } = make() + expect(media.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(media.listenerCount()).toBe(0) + }) + }) }) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index da5fe34dc4..cb8ebb6a48 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -59,6 +59,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-workspace': { 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/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, From c5cc348816bb27536ca1ba34928c33d771b3c8dc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:10:23 +0800 Subject: [PATCH 14/45] test(gui): settings package suites; ledger-judged re-registration Full per-file coverage for the three settings packages: invariant companions, store mirroring with revision guards, behavior-shaped section/shell specs (props-fed, real store engine), and apply-level suites on a real Context + SlotCore covering declaration-aware deferral and HMR collapse recovery. All three registrants now judge presence on the slot ledger instead of a local disposer, which went stale when a parent redeclaration cascade removed the entry (ds-review-bot finding); the locale re-register path keeps the same idempotence. --- .../ui-settings-general/src/client/index.ts | 6 +- .../ui-settings-general/tests/apply.spec.ts | 139 ++++++++++++++++ .../tests/general-section.spec.tsx | 112 +++++++++++++ .../tests/invariant.spec.ts | 18 ++ .../ui-settings-general/tests/store.spec.ts | 56 +++++++ .../ui-settings-models/src/client/index.ts | 20 ++- .../ui-settings-models/tests/apply.spec.ts | 95 +++++++++++ .../tests/invariant.spec.ts | 23 +++ .../client/ui-settings/src/client/index.ts | 14 +- .../client/ui-settings/tests/apply.spec.ts | 120 ++++++++++++++ .../ui-settings/tests/invariant.spec.ts | 18 ++ .../ui-settings/tests/settings-root.spec.tsx | 155 ++++++++++++++++++ 12 files changed, 764 insertions(+), 12 deletions(-) create mode 100644 packages/client/ui-settings-general/tests/apply.spec.ts create mode 100644 packages/client/ui-settings-general/tests/general-section.spec.tsx create mode 100644 packages/client/ui-settings-general/tests/invariant.spec.ts create mode 100644 packages/client/ui-settings-general/tests/store.spec.ts create mode 100644 packages/client/ui-settings-models/tests/apply.spec.ts create mode 100644 packages/client/ui-settings-models/tests/invariant.spec.ts create mode 100644 packages/client/ui-settings/tests/apply.spec.ts create mode 100644 packages/client/ui-settings/tests/invariant.spec.ts create mode 100644 packages/client/ui-settings/tests/settings-root.spec.tsx diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index f8f8a73304..52655be7fa 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -98,9 +98,11 @@ export function apply(ctx: ClientContext): void { } // Nav labels are registrant-localized: re-register on locale change so // the ledger carries fresh text (the version bump re-renders the shell). + // The ledger check mirrors tryRegister: after an HMR collapse `dispose` + // stays set while the entry is gone — relabeling then must stay quiet. const offLocale = ctx.on('locale/change', () => { - if (!registered()) return - dispose?.() + if (dispose === undefined || !registered()) return + dispose() dispose = undefined tryRegister() }) diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts new file mode 100644 index 0000000000..8654275831 --- /dev/null +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -0,0 +1,139 @@ +/** apply wiring: dictionary registration, declaration-aware section entry, + * snapshot projection into the slot store, locale-driven relabeling, and + * recovery after an HMR collapse of the declaring entry. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import { GeneralSection } from '../src/client/GeneralSection.tsx' +import type { createGeneralSettingsStore } from '../src/client/store.ts' + +const NS = 'settings.general' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + const theme = new ThemeService(ctx) + ctx.provide('locale', locale) + ctx.provide('theme', theme) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, theme } +} + +/** Stand in for the settings shell: declare the section list slot from root. */ +function declareSection(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries('settings.section')[0]! + const handle = entry.store as ReturnType + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => GeneralSectionInjected)(instance.actions) + return { entry, instance, face } +} + +describe('ui-settings-general apply', () => { + it('declares the slot, locale, and theme services', () => { + expect(inject).toEqual(['slots', 'locale', 'theme']) + }) + + it('registers dictionaries and the section entry for declarations before or after apply', async () => { + const before = await bench() + declareSection(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const entry = before.slots.entries('settings.section')[0]! + expect(entry.component).toBe(GeneralSection) + expect(entry.options).toMatchObject({ id: 'general', order: 0, label: '通用设置' }) + expect(before.locale.bind(NS)('nav')).toBe('通用设置') + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries('settings.section')).toHaveLength(0) + declareSection(after.slots) + await Promise.resolve() + expect(after.slots.entries('settings.section')[0]!.component).toBe(GeneralSection) + // Teardown without a live registration exercises the undefined-disposer arm. + await fiber.dispose() + expect(after.slots.entries('settings.section')).toHaveLength(0) + }) + + it('projects service snapshots into the store and routes face writes back', async () => { + const b = await bench() + declareSection(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + // Events ahead of any inject hit the unbound-actions arm without a store. + b.theme.setTheme('dark') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: both mirrors are current. + expect(instance.getSnapshot().localeActive).toBe('zh') + expect(instance.getSnapshot().localeOptions.map(l => l.id)).toEqual(['zh', 'en']) + expect(instance.getSnapshot().themePreference).toBe('dark') + expect(face.t('nav')).toBe('通用设置') + + face.setLocale('en') + expect(b.locale.getLocale().active).toBe('en') + expect(instance.getSnapshot().localeActive).toBe('en') + expect(face.t('nav')).toBe('General') + + face.setTheme('system') + expect(b.theme.getTheme().preference).toBe('system') + expect(instance.getSnapshot().themePreference).toBe('system') + }) + + it('re-registers with a fresh ledger label when the locale changes', async () => { + const b = await bench() + declareSection(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置') + b.locale.setLocale('en') + const entry = b.slots.entries('settings.section')[0]! + expect(entry.options.label).toBe('General') + expect(entry.component).toBe(GeneralSection) + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareSection(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries('settings.section')).toHaveLength(0) + + // A locale change inside the collapsed window must stay quiet. + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')).toHaveLength(0) + + // Redeclaration restores the entry — with the current locale's label. + declareSection(b.slots) + await Promise.resolve() + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(GeneralSection) + expect(entry.options.label).toBe('General') + }) + + it('removes the entry and the dictionaries on teardown', async () => { + const b = await bench() + declareSection(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + // Dictionary disposal: translation falls back to the bare key. + expect(b.locale.bind(NS)('nav')).toBe('nav') + }) +}) diff --git a/packages/client/ui-settings-general/tests/general-section.spec.tsx b/packages/client/ui-settings-general/tests/general-section.spec.tsx new file mode 100644 index 0000000000..e82ed0b1fc --- /dev/null +++ b/packages/client/ui-settings-general/tests/general-section.spec.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +/** GeneralSection behavior: skeleton rows stay inert, Language menu drives + * setLocale, Appearance cubes follow the preference and drive setTheme. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { createGeneralSettingsStore } from '../src/client/store.ts' +import { en } from '../src/client/locales.ts' +import type { GeneralSectionComponentProps } from '../src/client/contract.ts' + +afterEach(cleanup) + +const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +/** Empty global standard-kit hooks (the section reads neither). */ +function emptySessions() { + const store = createSnapshotStore( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(init?: { active?: string; preference?: 'light' | 'dark' | 'system' }) { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createGeneralSettingsStore().create() + store.actions.syncLocale(init?.active ?? 'en', LOCALES, 0) + store.actions.syncTheme(init?.preference ?? 'system', 0) + const setLocale = vi.fn() + const setTheme = vi.fn() + const props: GeneralSectionComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => en[key] ?? key, + setLocale, + setTheme, + } + render() + return { store, setLocale, setTheme } +} + +const pressed = (name: RegExp): string | null => + screen.getByRole('button', { name }).getAttribute('aria-pressed') + +describe('GeneralSection', () => { + it('renders the four groups with skeleton rows inert', () => { + const b = mount() + // Permission: disabled selector showing the fixed value. + const permission = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement + expect(permission.disabled).toBe(true) + fireEvent.click(permission) + // Tool Call: both mode cubes render as plain text, no buttons. + expect(screen.getByText('Schema mode')).toBeDefined() + expect(screen.getByText('Code mode')).toBeDefined() + expect(screen.queryByRole('button', { name: /Schema mode/ })).toBeNull() + expect(b.setLocale).not.toHaveBeenCalled() + expect(b.setTheme).not.toHaveBeenCalled() + }) + + it('opens the language menu, selects a locale, and closes', () => { + const b = mount({ active: 'en' }) + const trigger = screen.getByRole('button', { name: /English/ }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('menuitem', { name: '中文' })) + expect(b.setLocale).toHaveBeenCalledWith('zh') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + }) + + it('closes the language menu on outside pointerdown without selecting', () => { + const b = mount({ active: 'en' }) + const trigger = screen.getByRole('button', { name: /English/ }) + fireEvent.click(trigger) + expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + expect(b.setLocale).not.toHaveBeenCalled() + }) + + it('reflects a store locale change in the trigger label (unknown id falls back to the id)', () => { + const b = mount({ active: 'en' }) + act(() => { b.store.actions.syncLocale('zh', LOCALES, 1) }) + expect(screen.getByRole('button', { name: /中文/ })).toBeDefined() + act(() => { b.store.actions.syncLocale('fr', LOCALES, 2) }) + expect(screen.getByRole('button', { name: /fr/ })).toBeDefined() + }) + + it('marks the appearance cube matching the preference and switches on click', () => { + const b = mount({ preference: 'dark' }) + expect(pressed(/Dark/)).toBe('true') + expect(pressed(/Light/)).toBe('false') + expect(pressed(/System/)).toBe('false') + fireEvent.click(screen.getByRole('button', { name: /Light/ })) + expect(b.setTheme).toHaveBeenCalledWith('light') + // Selection follows the store mirror, not the click echo. + act(() => { b.store.actions.syncTheme('light', 1) }) + expect(pressed(/Light/)).toBe('true') + expect(pressed(/Dark/)).toBe('false') + }) +}) diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts new file mode 100644 index 0000000000..7b0527c0ff --- /dev/null +++ b/packages/client/ui-settings-general/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-settings-general/tests/store.spec.ts b/packages/client/ui-settings-general/tests/store.spec.ts new file mode 100644 index 0000000000..b291418471 --- /dev/null +++ b/packages/client/ui-settings-general/tests/store.spec.ts @@ -0,0 +1,56 @@ +/** General settings store: snapshot-mirror actions and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createGeneralSettingsStore } from '../src/client/store.ts' + +const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +describe('createGeneralSettingsStore', () => { + it('init shape: empty mirrors with revisions at -1', () => { + const store = createGeneralSettingsStore().create() + expect(store.getSnapshot()).toEqual({ + localeActive: '', + localeOptions: [], + localeRevision: -1, + themePreference: 'system', + themeRevision: -1, + }) + }) + + it('syncLocale mirrors the snapshot and advances the revision', () => { + const store = createGeneralSettingsStore().create() + store.actions.syncLocale('zh', LOCALES, 0) + expect(store.getSnapshot().localeActive).toBe('zh') + expect(store.getSnapshot().localeOptions).toEqual(LOCALES) + expect(store.getSnapshot().localeRevision).toBe(0) + + store.actions.syncLocale('en', LOCALES, 1) + expect(store.getSnapshot().localeActive).toBe('en') + expect(store.getSnapshot().localeRevision).toBe(1) + }) + + it('syncLocale revision guard drops stale and duplicate writes', () => { + const store = createGeneralSettingsStore().create() + store.actions.syncLocale('en', LOCALES, 5) + // Stale (lower) and duplicate (equal) revisions leave the mirror intact. + store.actions.syncLocale('zh', LOCALES, 4) + store.actions.syncLocale('zh', LOCALES, 5) + expect(store.getSnapshot().localeActive).toBe('en') + expect(store.getSnapshot().localeRevision).toBe(5) + }) + + it('syncTheme mirrors the preference and guards its revision independently', () => { + const store = createGeneralSettingsStore().create() + store.actions.syncTheme('dark', 0) + expect(store.getSnapshot().themePreference).toBe('dark') + expect(store.getSnapshot().themeRevision).toBe(0) + + store.actions.syncTheme('light', 2) + expect(store.getSnapshot().themePreference).toBe('light') + + // Stale theme write is dropped; the locale revision axis is untouched. + store.actions.syncTheme('system', 1) + expect(store.getSnapshot().themePreference).toBe('light') + expect(store.getSnapshot().themeRevision).toBe(2) + expect(store.getSnapshot().localeRevision).toBe(-1) + }) +}) diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 8ad2ebb750..c35946fcc8 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -31,9 +31,15 @@ export function apply(ctx: ClientContext): void { ] return () => { for (const dispose of disposers) dispose() } }, 'ui-settings-models: nav copy dictionaries') + // Declaration-aware registration; the LEDGER is the has-registered judge + // (not a local flag): after an HMR collapse re-declares the slot, the + // cascade already removed our entry, and a stale disposer must not block + // the re-registration. ctx.effect(() => { let dispose: (() => void) | undefined - const register = (): void => { + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined) return + if (ctx.slots.entries('settings.section').some(e => e.component === ModelsSection)) return dispose = ctx.slots.register({ name: 'settings.section', id: 'models', @@ -41,16 +47,14 @@ export function apply(ctx: ClientContext): void { label: ctx.locale.bind('settings.models')('nav'), }, ModelsSection) } - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return - register() - } // Nav labels are registrant-localized: re-register on locale change so // the ledger carries fresh text (the version bump re-renders the shell). + // Dispose-then-requery: after an HMR collapse the disposer is stale and + // the ledger/spec re-check keeps this path an idempotent no-op. const offLocale = ctx.on('locale/change', () => { - if (dispose === undefined) return - dispose() - register() + dispose?.() + dispose = undefined + tryRegister() }) const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) tryRegister() diff --git a/packages/client/ui-settings-models/tests/apply.spec.ts b/packages/client/ui-settings-models/tests/apply.spec.ts new file mode 100644 index 0000000000..b7aa3cf2ab --- /dev/null +++ b/packages/client/ui-settings-models/tests/apply.spec.ts @@ -0,0 +1,95 @@ +/** Models section registration: declaration-aware deferral, locale re-registration, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-models/client' +import { ModelsSection } from '../src/client/ModelsSection.tsx' + +async function bench() { + 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 } +} + +function declare(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +describe('ui-settings-models apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('registers the models nav entry for declarations before or after apply', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const entry = before.slots.entries('settings.section')[0]! + expect(entry.component).toBe(ModelsSection) + expect(entry.options).toEqual({ id: 'models', order: 10, label: '模型' }) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + expect(after.slots.entries('settings.section')).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries('settings.section')).toHaveLength(1) + }) + + it('re-registers with fresh label text on locale change', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models') + b.locale.setLocale('zh') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('模型') + }) + + it('locale change while the slot is undeclared stays a no-op', async () => { + const b = await bench() + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')).toHaveLength(0) + b.locale.setLocale('zh') + }) + + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + // Declarer unload: the cascade removes our entry while our local + // disposer variable goes stale. + redeclare() + expect(b.slots.entries('settings.section')).toHaveLength(0) + declare(b.slots) + await Promise.resolve() + expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection) + // The locale path also recovers through the same ledger re-check. + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models') + b.locale.setLocale('zh') + }) + + it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.locale.bind('settings.models')('nav')).toBe('模型') + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + // The (ns, locale) seats are free again — the dictionary disposers ran. + expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow() + expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow() + }) +}) diff --git a/packages/client/ui-settings-models/tests/invariant.spec.ts b/packages/client/ui-settings-models/tests/invariant.spec.ts new file mode 100644 index 0000000000..65c7c1094a --- /dev/null +++ b/packages/client/ui-settings-models/tests/invariant.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { ModelsSection } from '../src/client/ModelsSection.tsx' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(ModelsInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-models') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) + + it('the section content column is intentionally empty this phase', () => { + expect(ModelsSection()).toBeNull() + }) +}) diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index d5135f373c..688436c25b 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -43,13 +43,23 @@ export function apply(ctx: ClientContext): void { sectionsVersion: () => ctx.slots.getVersion('settings.section'), subscribeSections: (listener) => ctx.slots.subscribe('settings.section', listener), sections: () => ctx.slots.entries('settings.section') - .map(e => ({ id: e.options.id ?? '', order: e.options.order ?? 0, label: e.options.label ?? '' })) + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + label: e.options.label ?? '', + })) .sort((a, b) => a.order - b.order), }) + // Declaration-aware registration; the LEDGER is the has-registered judge + // (not a local flag): after an HMR collapse re-declares the slot, the + // cascade already removed our entry, and a stale disposer must not block + // the re-registration. ctx.effect(() => { let dispose: (() => void) | undefined const tryRegister = (): void => { - if (ctx.slots.spec('sidebar.settings') === undefined || dispose !== undefined) return + if (ctx.slots.spec('sidebar.settings') === undefined) return + if (ctx.slots.entries('sidebar.settings').some(e => e.component === SettingsRoot)) return dispose = ctx.slots.register({ name: 'sidebar.settings', children: { 'settings.section': { kind: 'list', scope: 'root' } }, diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts new file mode 100644 index 0000000000..a94406e4ce --- /dev/null +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -0,0 +1,120 @@ +/** Settings shell registration: declaration-aware deferral, the injected face, and HMR recovery. */ +import { Context } from 'cordis' +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 { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsRoot } from '../src/client/SettingsRoot.tsx' + +async function bench() { + 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 } +} + +function declare(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never, + () => null, + ) +} + +function injectedOf(slots: SlotsService): SettingsRootInjected { + const entry = slots.entries('sidebar.settings')[0]! + return (entry.inject as () => SettingsRootInjected)() +} + +describe('ui-settings apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('registers the shell for declarations that arrive before or after apply', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + expect(before.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' }) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + expect(after.slots.entries('sidebar.settings')).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries('sidebar.settings')).toHaveLength(1) + }) + + it('registers the zh/en shell dictionaries and disposes them with the fiber', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.locale.bind('settings')('title')).toBe('设置') + b.locale.setLocale('en') + expect(b.locale.bind('settings')('close')).toBe('Close') + await fiber.dispose() + // The (ns, locale) seats are free again — the dictionary disposers ran. + expect(() => b.locale.register('settings', 'zh', {})).not.toThrow() + expect(() => b.locale.register('settings', 'en', {})).not.toThrow() + }) + + it('exposes translate over ":" refs with literal echo for plain text', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected = injectedOf(b.slots) + expect(injected.translate('settings:title')).toBe('设置') + expect(injected.translate('no colon ref')).toBe('no colon ref') + }) + + it('projects the section ledger into ordered nav rows with option defaults', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected = injectedOf(b.slots) + expect(injected.sections()).toEqual([]) + b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) + b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) + expect(injected.sections()).toEqual([ + { id: 'a', order: 0, label: '' }, + { id: 'z', order: 20, label: 'Z' }, + ]) + expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) + const listener = vi.fn() + const off = injected.subscribeSections(listener) + b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) + await Promise.resolve() + expect(listener).toHaveBeenCalled() + off() + }) + + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('sidebar.settings')).toHaveLength(1) + // Declarer unload: the cascade removes our entry and the slot spec while + // our local disposer variable goes stale. + redeclare() + expect(b.slots.entries('sidebar.settings')).toHaveLength(0) + declare(b.slots) + await Promise.resolve() + expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + expect(b.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' }) + }) + + it('unregisters the shell and collapses settings.section on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await fiber.dispose() + expect(b.slots.entries('sidebar.settings')).toHaveLength(0) + expect(b.slots.spec('settings.section')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-settings/tests/invariant.spec.ts b/packages/client/ui-settings/tests/invariant.spec.ts new file mode 100644 index 0000000000..c3474d5bdd --- /dev/null +++ b/packages/client/ui-settings/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as SettingsInvariant from '@deepseek-ai/dsh-client-ui-settings/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(SettingsInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx new file mode 100644 index 0000000000..ab56365075 --- /dev/null +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts' +import { SettingsRoot } from '../src/client/SettingsRoot.tsx' + +afterEach(cleanup) + +const DICT: Record = { + 'settings:trigger': 'Settings', + 'settings:title': 'Settings', + 'settings:close': 'Close', +} + +type Row = { id: string; order: number; label: string } + +function mount({ + wide = true, + rows = [ + { id: 'general', order: 0, label: 'General' }, + { id: 'models', order: 10, label: 'Models' }, + ], +}: { wide?: boolean; rows?: Row[] } = {}) { + // Mutable row store standing in for the ledger; bump() plays a change. + let current = rows + let version = 0 + const listeners = new Set<() => void>() + const renderSlot = vi.fn( + ((_key: string, _owner: unknown, opts?: { only?: string }) => +
) as SettingsRootComponentProps['renderSlot'], + ) + // Global standard kit stubs: the shell consumes neither hook. + const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never + const props: SettingsRootComponentProps = { + useSessions: unusedHook, + useWorkspaces: unusedHook, + wide, + translate: (ref) => DICT[ref] ?? ref, + sectionsVersion: () => version, + subscribeSections: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + sections: () => current, + renderSlot, + } + const view = render() + const bump = (next: Row[]) => { + act(() => { + current = next + version += 1 + for (const fn of [...listeners]) fn() + }) + } + return { view, renderSlot, bump, listeners } +} + +function openPanel() { + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) +} + +describe('SettingsRoot trigger', () => { + it('renders the wide row with the label and opens the dialog', () => { + mount() + const trigger = screen.getByRole('button', { name: 'Settings' }) + expect(trigger.textContent).toContain('Settings') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(trigger) + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy() + }) + + it('drops the label in the rail state', () => { + mount({ wide: false }) + expect(screen.getByRole('button', { name: 'Settings' }).textContent).toBe('') + }) +}) + +describe('SettingsPanel close paths', () => { + it('closes via the header button', () => { + mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('closes via a mask click', () => { + mount() + openPanel() + const dialog = screen.getByRole('dialog') + fireEvent.click(dialog.parentElement!.firstElementChild!) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('closes via document-level Escape and unhooks the listener with the panel', () => { + mount() + openPanel() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + // Ignored while closed (listener removed with the panel) and non-Escape + // keys are ignored while open. + fireEvent.keyDown(document, { key: 'Escape' }) + openPanel() + fireEvent.keyDown(document, { key: 'Enter' }) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + + it('lands focus on the close button when the dialog opens', () => { + mount() + openPanel() + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' })) + }) +}) + +describe('SettingsPanel navigation', () => { + it('projects rows, marks the first active, and renders only that section', () => { + mount() + openPanel() + expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') + expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull() + expect(screen.getByTestId('section-general')).toBeTruthy() + }) + + it('switches the rendered section on nav click', () => { + mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Models' })) + expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true') + expect(screen.getByTestId('section-models')).toBeTruthy() + expect(screen.queryByTestId('section-general')).toBeNull() + }) + + it('falls back to the first row when the active entry unregisters', () => { + const { bump } = mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Models' })) + bump([{ id: 'general', order: 0, label: 'General' }]) + expect(screen.queryByRole('button', { name: 'Models' })).toBeNull() + expect(screen.getByTestId('section-general')).toBeTruthy() + }) + + it('renders an empty content column when the ledger is empty', () => { + const { renderSlot } = mount({ rows: [] }) + openPanel() + expect(screen.getByRole('dialog')).toBeTruthy() + expect(renderSlot).not.toHaveBeenCalled() + }) + + it('drops the ledger subscription on unmount', () => { + const { view, listeners } = mount() + expect(listeners.size).toBe(1) + view.unmount() + expect(listeners.size).toBe(0) + }) +}) From f1b877e3c61ded6f94a1a07bbdb95555297af63f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:11:17 +0800 Subject: [PATCH 15/45] fix(web): review-bot round one + regenerate doc catalogs - rename same-title no-op moves inside the serialized creation chain - insertSessionBefore maps only the typed WorkspaceMoveInvalidError to workspace-move-invalid; storage failures stay internal - workspace upsert rejects snapshots older than the installed projection - flat-mode empty state shows when the query hides the intent row - intent row no longer forces group expansion; header twist stays live - group-by menu rides a portal; menu clicks stop propagating to the row - intent row uses the same single-slot indent in both list modes - regenerate cordis api/catalog + doc graphs --- ...ssion-list-browsing-and-manual-order.zh.md | 60 +++++++++++++++++++ docs/cordis-catalog/services.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 2 +- .../ui-workspace/src/client/rows/Rows.tsx | 7 +-- 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md new file mode 100644 index 0000000000..e897903f83 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -0,0 +1,60 @@ +# Agent Note: Session List Browsing and Manual Workspace Order + +Status: implemented + +[English](2026-07-25-session-list-browsing-and-manual-order.md) | 中文 + +## Problem + +[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.zh.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 + +两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。 + +## Decision + +### 平铺视图与浏览态 + +group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所有 session(含 fork 子)一律作为顶层行,严格按 `updatedAt` 新→旧排序,不保持父子相邻;Intent 占位行渲染在列表首行。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 + +### 行交互 + +- session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。 +- session 行 … 菜单:Rename / Fork session / Delete session,本期纯视觉;workspace 组头 … 菜单:Rename(已接线)/ Delete workspace(纯视觉)。菜单鼠标移出即关。 +- 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。 + +### workspace.rename + +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 host 的 workspace 创建串行链内求值(与 create 共链,并发 create/rename 不能穿插出重名或乱序假成功),冲突回 `workspace-name-conflict`。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 + +### 手动排序:insertSessionBefore 取代活动置顶 + +`session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新 session attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:锚给了插锚前,缺省 append 到末尾)。实体只对不在账的 session/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。 + +UI 为组内 root 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子随父不单独拖)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。 + +### 壳/区域切分 + +ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/新建图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspaceCreateFlow`(区域内直接组件组合)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。 + +## Alternatives considered + +**保留活动置顶、拖拽仅作临时调整** —— 手动序在下一次 session 活动即被打乱,形同虚设;两种排序权威并存无法向用户解释。也考虑过「拖过一次即冻结该 workspace 的活动置顶」的折中,状态多一档、语义更难讲,直接删除更干净。 + +**排序报文用数字下标** —— `{ index }` 在拖拽窗口期会漂移:host 前插新 session(如 Intent 材料化)后同一下标指向别的行。锚点式 insertBefore 对前插与过滤投影天然免疫。 + +**drop 后乐观重排** —— client 先行重排需失败回滚,对象层多一块纠缠态;本地/局域网往返毫秒级,等 host 响应的简单方案肉眼无感。顺序权威单一化(完全信 host)后,前端永不发明顺序。 + +**rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。 + +**平铺模式保持父子相邻成组** —— 与「严格按时间」矛盾(子新于兄则插不进相邻位),且平铺本意就是取消层级;拉平并禁用平铺下的拖拽(无持久化载体)更一致。 + +## Consequences + +- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。 +- 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 +- 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 +- session 菜单三项与 workspace Delete 的功能接线、状态枚举扩 wire,留待后续迭代。 + +## Testing + +包级用例覆盖派生(deriveGroups/deriveFlat)、行组件、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用;交付验收另以 playwright(chromium headless)过 12 项清单(分组默认、平铺切换与持久化、hover 卡出现与抑制、双菜单、rename 全链、拖拽落盘),并对真 host 直打 wire 验证 rename 成功/重名拒绝/`workspace-move-invalid` 三径。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 474f587230..387ff39c3e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1991,7 +1991,7 @@ list(): Workspace[] async resolveByPath(path: string): Promise ``` -Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index e7d0cdc38c..57de2cbaf5 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -209,7 +209,7 @@ function FlatList({ useSessions, open, query }: Pick{query === '' ? 'No sessions yet' : 'No matches'}
)} - {intentRow && } + {intentRow && } {rows.map(node => ( - {!flat && } New session
From 8cc46e602504fc562427b1614545d5dcf27c290c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:16:39 +0800 Subject: [PATCH 16/45] docs(notes): export settings note contract types Both sides of the bilingual pair compile in one doc-typecheck program; module-scoped (exported) declarations keep the shared identifiers from colliding across the pair. --- ...2026-07-25-client-settings-locale-theme.i18n.yaml | 4 ++-- .../2026-07-25-client-settings-locale-theme.md | 12 ++++++------ .../2026-07-25-client-settings-locale-theme.zh.md | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index 54fb6ee9ee..8583642403 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 -2026-07-25-client-settings-locale-theme.md: 25e22c728ea509613c4d7f6cdfcd09faf4685760 -2026-07-25-client-settings-locale-theme.zh.md: 9c2457b254aa826291d4c5a4e115e9ba2d391974 +2026-07-25-client-settings-locale-theme.md: e1245a9e1fac82fb0feb84af7a59945b17fa1daf +2026-07-25-client-settings-locale-theme.zh.md: 195b2e5ffa3556dd1b8bf2dd6ec8ae84115fbdb2 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index 25e22c728e..e1245a9e1f 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -49,33 +49,33 @@ Section contributions use declaration-aware deferral and do not depend on the cl ### Service contracts ```ts -type ThemePreference = 'light' | 'dark' | 'system' +export type ThemePreference = 'light' | 'dark' | 'system' -interface ThemeDefinition { +export interface ThemeDefinition { id: string colorScheme: 'light' | 'dark' tokens: Record } -interface ThemeSnapshot { +export interface ThemeSnapshot { preference: ThemePreference active: ThemeDefinition // system 已解析为具体 light/dark 定义 themes: readonly ThemeDefinition[] revision: number } -interface LocaleDefinition { +export interface LocaleDefinition { id: 'zh' | 'en' label: string } -interface LocaleSnapshot { +export interface LocaleSnapshot { active: 'zh' | 'en' locales: readonly LocaleDefinition[] revision: number } -interface Events { +export interface Events { /** @param snapshot - Current locale registry snapshot. @mode emit */ 'locale/change'(snapshot: LocaleSnapshot): void /** @param snapshot - Current theme registry snapshot. @mode emit */ diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index 9c2457b254..195b2e5ffa 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -49,33 +49,33 @@ section contribution 使用 declaration-aware deferral,不依赖 client manife ### Service contracts ```ts -type ThemePreference = 'light' | 'dark' | 'system' +export type ThemePreference = 'light' | 'dark' | 'system' -interface ThemeDefinition { +export interface ThemeDefinition { id: string colorScheme: 'light' | 'dark' tokens: Record } -interface ThemeSnapshot { +export interface ThemeSnapshot { preference: ThemePreference active: ThemeDefinition // system 已解析为具体 light/dark 定义 themes: readonly ThemeDefinition[] revision: number } -interface LocaleDefinition { +export interface LocaleDefinition { id: 'zh' | 'en' label: string } -interface LocaleSnapshot { +export interface LocaleSnapshot { active: 'zh' | 'en' locales: readonly LocaleDefinition[] revision: number } -interface Events { +export interface Events { /** @param snapshot - Current locale registry snapshot. @mode emit */ 'locale/change'(snapshot: LocaleSnapshot): void /** @param snapshot - Current theme registry snapshot. @mode emit */ From ba5c1368710709b5b524f1de3a544bd93c281953 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:30:14 +0800 Subject: [PATCH 17/45] docs(notes): record the session-list browsing and manual-order decisions --- ...n-list-browsing-and-manual-order.i18n.yaml | 6 ++ ...-session-list-browsing-and-manual-order.md | 60 +++++++++++++++++++ ...ssion-list-browsing-and-manual-order.zh.md | 2 +- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml new file mode 100644 index 0000000000..590d9227b8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.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 +2026-07-25-session-list-browsing-and-manual-order.md: 586995bf459aeaee88672863977f7acf2a7061a3 +2026-07-25-session-list-browsing-and-manual-order.zh.md: 432d5167a57d30bc04a0b4faf213e4341f07bd2f diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md new file mode 100644 index 0000000000..586995bf45 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -0,0 +1,60 @@ +# Agent Note: Session List Browsing and Manual Workspace Order + +Status: implemented + +English | [中文](2026-07-25-session-list-browsing-and-manual-order.zh.md) + +## Problem + +[Workspace UI Complete Product Flow](2026-07-25-workspace-ui-product-flow.md) shipped the first form of the grouped session list and explicitly scoped out operations such as Rename and drag ordering. The design file (figma 239-10458 and its companion screens) has since filled in those interactions: the list must switch to an ungrouped flat view, session rows need a hover detail card and an action menu, workspaces need renaming, and sessions need manual ordering inside their group. + +Two existing mechanisms stood in the way. First, the host durably promoted the active session to the front of its workspace account on every `session/event` (activity pinning), so any manual order would be scrambled by the next activity — two ordering authorities cannot coexist. Second, the browsing area was split across two packages: ui-sidebar owned the list, search, and header rows while ui-workspace only borrowed a picker slot for its popover; every new workspace-domain dialog required cross-package wiring, and ownership grew more twisted with each one. + +## Decision + +### Flat view and viewing state + +The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders every session (fork children included) as a top-level row, strictly newest-first by `updatedAt`, with no parent/child adjacency; the Intent placeholder renders as the first row. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. + +### Row interactions + +- Session rows show a detail card after a 500ms hover dwell (full title / relative time / status line; the status line has only running/idle until the wire grows a status field). The card and the row menu are mutually exclusive: no card while a menu is open or a drag is in flight. +- Session-row … menu: Rename / Fork session / Delete session, visual-only this iteration; workspace-header … menu: Rename (wired) / Delete workspace (visual-only). Menus close when the pointer leaves them. +- Supporting primitives: `Menu` gains label entries, danger rows, and `closeOnPointerLeave`; a new `HoverCard` (portaled placement, open delay, disabled guard). + +### workspace.rename + +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the host's serialized workspace-creation chain (shared with create, so concurrent create/rename cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. + +### Manual order: insertSessionBefore replaces activity pinning + +The `session/event` → `touchSession` activity-pinning chain is deleted wholesale; the workspace account order is now manually owned — new sessions prepend at attach, and explicit reordering goes through `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })` (DOM insertBefore semantics: with an anchor it inserts before it, omitted appends to the end). The entity throws a typed `WorkspaceMoveInvalidError` only for unaccounted session/anchor ids; the handler maps exactly that to the business code `workspace-move-invalid`, while storage failures stay internal. + +The UI is HTML5 drag on root rows inside a group (workspace grouping only, outside search; fork children ride with their parent and are not draggable). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame. + +### Shell/region split + +ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/new-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspaceCreateFlow` (composed directly inside the region) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it. + +## Alternatives considered + +**Keep activity pinning; treat drag as a transient adjustment** — the manual order would be scrambled by the next session activity, making it a fiction; two coexisting ordering authorities cannot be explained to the user. A middle ground — freeze pinning per workspace after the first drag — adds a state tier with murkier semantics; deleting outright is cleaner. + +**Numeric index in the reorder payload** — `{ index }` drifts during the drag window: after the host prepends a new session (e.g. Intent materialization) the same index points at a different row. Anchor-style insertBefore is naturally immune to prepends and filtered projections. + +**Optimistic reordering on drop** — client-first reordering needs failure rollback, one more entangled state in the object layer; local/LAN round-trips are millisecond-scale, so waiting for the host response is imperceptible. With a single order authority (trust the host completely), the frontend never invents an order. + +**Keep the rename dialog in ui-sidebar (smallest change)** — that is the problem itself: workspace-domain dialogs scattered in a borrowed slot, with each addition (the Delete confirmation is coming) repeating the cross-package wiring. Review first considered moving only the rename modal; the ruling was to give the whole browsing region to ui-workspace and leave the shell geometry-only. + +**Keep parent/child adjacency in flat mode** — contradicts strict recency (a child newer than its parent's sibling cannot slot adjacently), and the flat view's purpose is dropping the hierarchy; flattening fully and disabling drag in flat mode (no persistence carrier) is more consistent. + +## Consequences + +- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. +- The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. +- Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. +- Wiring the three session-menu items and workspace Delete, and growing the wire status enum, remain future iterations. + +## Testing + +Package-level suites cover the derivations (deriveGroups/deriveFlat), row components, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application; delivery acceptance additionally runs a 12-item playwright (chromium headless) checklist (grouped default, flat switch and persistence, hover-card appearance and suppression, both menus, the full rename chain, drag persistence) and drives the real host over the wire for rename success / duplicate rejection / `workspace-move-invalid`. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index e897903f83..432d5167a5 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.zh.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 +[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。 From 2ee4cda0667d990525938dd559c90fcd52e35f3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:30:46 +0800 Subject: [PATCH 18/45] fix(client): lint conformance for the preference services Replace optional chains on always-present globals with the repo's typeof guards (store.ts precedent), drop the non-null assertion by failing loud on an impossible registry miss, and fix two arrow-parens slips; cover the no-localStorage boot path in both service suites. --- packages/client/locale/src/client/index.ts | 7 +++++-- packages/client/locale/tests/locale.spec.ts | 14 +++++++++++++- packages/client/ui-layout/src/client/index.ts | 2 +- packages/client/ui-settings/src/client/index.ts | 2 +- packages/client/ui-theme/src/client/index.ts | 15 ++++++++++----- packages/client/ui-theme/tests/theme.spec.ts | 12 ++++++++++++ 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index e01d69ea25..35f29a0e52 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -161,8 +161,10 @@ export class LocaleService { /** Read the persisted locale id; unknown or unreadable values fall back to zh. */ function restorePreference(): LocaleId { + // Non-browser runs (node e2e booting the client tree) have no localStorage. + if (typeof localStorage === 'undefined') return FALLBACK_LOCALE try { - const stored = globalThis.localStorage?.getItem(STORAGE_KEY) + const stored = localStorage.getItem(STORAGE_KEY) if (stored === 'zh' || stored === 'en') return stored } catch { // Storage access can throw (privacy mode); the default below covers it. @@ -172,8 +174,9 @@ function restorePreference(): LocaleId { /** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ function persistPreference(id: LocaleId): void { + if (typeof localStorage === 'undefined') return try { - globalThis.localStorage?.setItem(STORAGE_KEY, id) + localStorage.setItem(STORAGE_KEY, id) } catch { // Storage access can throw (privacy mode / quota); the preference simply // does not survive the session. diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 750c2452e8..3f9efaed19 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it } from 'vitest' +import { 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' @@ -80,6 +80,18 @@ describe('LocaleService', () => { expect(make().svc.getLocale().active).toBe('zh') }) + it('runs without localStorage (node boots): defaults on read, no-op on write', () => { + vi.stubGlobal('localStorage', undefined) + try { + const { svc } = make() + expect(svc.getLocale().active).toBe('zh') + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + } finally { + vi.unstubAllGlobals() + } + }) + it('exposes the two shipped locales with self-described labels', () => { const { svc } = make() expect(svc.getLocale().locales).toEqual([ diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 39fee1fb52..7474cd69c3 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -107,7 +107,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { const presenter = new ThemePresenter() presenter.apply(ctx.theme.getTheme()) - const off = ctx.on('theme/change', snapshot => { presenter.apply(snapshot) }) + const off = ctx.on('theme/change', (snapshot) => { presenter.apply(snapshot) }) return () => { off() presenter.dispose() diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 688436c25b..3613476ede 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -41,7 +41,7 @@ export function apply(ctx: ClientContext): void { return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1)) }, sectionsVersion: () => ctx.slots.getVersion('settings.section'), - subscribeSections: (listener) => ctx.slots.subscribe('settings.section', listener), + subscribeSections: listener => ctx.slots.subscribe('settings.section', listener), sections: () => ctx.slots.entries('settings.section') .map(e => ({ /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index b8a75c4bed..5c7ca3fe12 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -87,7 +87,8 @@ export class ThemeService { constructor(ctx: Context) { this.ctx = ctx this.preference = restorePreference() - this.media = globalThis.matchMedia?.('(prefers-color-scheme: dark)') + // 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() if (this.media !== undefined) { const media = this.media @@ -157,8 +158,9 @@ export class ThemeService { : this.preference // Both built-ins always exist; a registered preference id resolves or has // been reset by its disposer, so the lookup cannot miss. - /* v8 ignore next -- the ?? arm needs a registry without light/dark, which register()/dispose() cannot produce */ - const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]! + const active = this.themes.find(t => t.id === resolvedId) + /* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */ + if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`) return Object.freeze({ preference: this.preference, active, @@ -176,8 +178,10 @@ 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 = globalThis.localStorage?.getItem(STORAGE_KEY) + 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. @@ -187,8 +191,9 @@ function restorePreference(): ThemePreference { /** Persist the preference; storage failures are non-fatal (preference resets next boot). */ function persistPreference(preference: ThemePreference): void { + if (typeof localStorage === 'undefined') return try { - globalThis.localStorage?.setItem(STORAGE_KEY, preference) + localStorage.setItem(STORAGE_KEY, preference) } catch { // Storage access can throw (privacy mode / quota); the preference simply // does not survive the session. diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index fdac111e73..c853c9fd67 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -88,6 +88,18 @@ 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() + } + }) + describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { type Listener = () => void const stubMedia = (initialMatches: boolean) => { From 9fc8a616a9acc9ca303fdadb41313c7f6b1e0454 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:48:36 +0800 Subject: [PATCH 19/45] test(web): close the per-file coverage gate for the session-list surfaces New and touched sources reach the CI per-file 100% thresholds: HoverCard (timers, placement clamp, disabled guard), Menu label/danger/pointer-leave branches, WorkspaceBrowser (mode switch, search, rail icons, rename dialog, drag), rows and tree derivations, the workspace fixture stubs, the rename/ insertSessionBefore wire rows, and the entity move semantics. HoverCard's position state narrows to {left, top} (equivalent refactor, no behavior change). --- .../client/connection/tests/fixture.spec.ts | 63 +++ .../client/ui-primitives/src/HoverCard.tsx | 19 +- .../client/ui-primitives/tests/atoms.spec.tsx | 45 ++ .../ui-primitives/tests/hover-card.spec.tsx | 148 ++++++ .../src/client/WorkspaceBrowser.tsx | 5 + .../client/ui-workspace/tests/rows.spec.tsx | 177 ++++++- .../client/ui-workspace/tests/tree.spec.ts | 42 +- .../tests/workspace-browser.spec.tsx | 458 ++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 13 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 29 +- .../workspace/tests/workspace.spec.ts | 52 +- 11 files changed, 1037 insertions(+), 14 deletions(-) create mode 100644 packages/client/ui-primitives/tests/hover-card.spec.tsx create mode 100644 packages/client/ui-workspace/tests/workspace-browser.spec.tsx diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 16fa4b4ed6..0baabaf617 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -311,6 +311,60 @@ describe('createFixtureApi', () => { expect(rootPath.result.value.workspace.title).toBe('/') }) + it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + if (seen.length >= 2) abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + + await api.workspace.create(req({ name: 'occupied' })) + const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) + expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) + + const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' })) + if (!noop.result.ok) throw new Error('no-op rename failed') + expect(noop.result.value.workspace.title).toBe('fixture') + + const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' })) + if (!renamed.result.ok) throw new Error('rename failed') + expect(renamed.result.value.workspace.title).toBe('renamed') + await consuming + // Only the create and the effective rename emit frames; the no-op stays silent. + expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed']) + }) + + it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => { + const api = createFixtureApi() + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') })) + expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } }) + const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') })) + expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) + + const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') })) + if (!moved.result.ok) throw new Error('move failed') + expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta']) + const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!appended.result.ok) throw new Error('append failed') + expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + const before = appended.result.value.workspace.updatedAt + const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!noop.result.ok) throw new Error('no-op move failed') + expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + expect(noop.result.value.workspace.updatedAt).toBe(before) + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() @@ -558,6 +612,15 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const workspace = await client.workspace.create({ name: 'via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') + const wsid = workspace.result.value.workspace.workspaceId + const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' }) + if (!renamed.result.ok) throw new Error('workspace rename failed') + expect(renamed.result.value.workspace.title).toBe('via-client-2') + const attached = await client.sessions.create({ workspaceId: wsid }) + if (!attached.result.ok) throw new Error('attached create failed') + const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) + if (!moved.result.ok) throw new Error('workspace move failed') + expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) }) it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 58e281778e..1720a0b79c 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -5,7 +5,7 @@ // and closes the instant the pointer leaves the anchor (no close delay). import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode } from 'react' +import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import css from './HoverCard.module.css' @@ -27,7 +27,7 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false const cardRef = useRef(null) const timerRef = useRef | null>(null) const [open, setOpen] = useState(false) - const [pos, setPos] = useState(null) + const [pos, setPos] = useState<{ left: number; top: number } | null>(null) const clearTimer = () => { if (timerRef.current !== null) { @@ -50,8 +50,10 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false useLayoutEffect(() => { if (!open) { setPos(null); return } const place = () => { - const r = rootRef.current?.getBoundingClientRect() ?? null - if (r === null) return + const wrapper = rootRef.current + /* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */ + if (wrapper === null) return + const r = wrapper.getBoundingClientRect() const h = cardRef.current?.offsetHeight ?? 0 const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top setPos({ left: r.right + 8, top }) @@ -66,13 +68,14 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }, [open]) // The first placement ran before the card mounted (height read 0): once the - // card's real height is measurable, correct the bottom-edge clamp. + // card's real height is measurable, correct the bottom-edge clamp. The + // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || typeof pos.top !== 'number') return + if (!open || pos === null) return + /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - 8) { - const top = window.innerHeight - h - 8 - if (pos.top !== top) setPos({ ...pos, top }) + setPos({ left: pos.left, top: window.innerHeight - h - 8 }) } }, [open, pos]) diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 440f4ba6ef..9e298d057a 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -136,6 +136,51 @@ describe('Menu', () => { expect(screen.getByRole('separator')).toBeDefined() }) + it('renders a non-interactive heading label and a danger row', () => { + const onSelect = vi.fn() + render( + trigger} + items={[ + { type: 'label', id: 'h', text: 'Group by' }, + { id: 'del', label: 'Delete', danger: true }, + ]} + onSelect={onSelect} + onClose={() => {}} + />) + const heading = screen.getByText('Group by') + expect(heading.getAttribute('role')).toBe('presentation') + // The heading is not a menu item — only the danger row is interactive. + expect(screen.getAllByRole('menuitem')).toHaveLength(1) + const danger = screen.getByRole('menuitem', { name: 'Delete' }) + expect(danger.className).toMatch(/danger/) + fireEvent.click(danger) + expect(onSelect).toHaveBeenCalledWith('del') + }) + + it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => { + const onClose = vi.fn() + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + rerender( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => { + const rowClick = vi.fn() + render( +
+ trigger} items={items} onSelect={() => {}} onClose={() => {}} /> +
) + fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) + expect(rowClick).not.toHaveBeenCalled() + }) + it('opens a submenu on hover and selects a nested item', () => { const onSelect = vi.fn() render( diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx new file mode 100644 index 0000000000..c7a95f49fb --- /dev/null +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { vi.useRealTimers() }) + +/** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */ +function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void { + const wrapper = anchor.parentElement as HTMLElement + wrapper.getBoundingClientRect = () => ({ + top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34, + width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}), + } as DOMRect) +} + +function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { + const view = render( + row} content={
card body
} {...props} />, + ) + const anchor = screen.getByText('row') + stubAnchorRect(anchor, { top: 40, right: 200 }) + return { view, anchor, wrapper: anchor.parentElement as HTMLElement } +} + +describe('HoverCard', () => { + it('opens after the dwell delay, positioned right of the anchor', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.parentElement).toBe(document.body) + expect(card.style.left).toBe('208px') + expect(card.style.top).toBe('40px') + }) + + it('honors a custom openDelayMs', () => { + const { wrapper } = mount({ openDelayMs: 50 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(50) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('pointerleave before the delay cancels the pending open', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('a press inside the anchor dismisses the card without waiting for disabled', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerDown(screen.getByText('row')) + expect(screen.queryByText('card body')).toBeNull() + // The pending timer is also cleared: no reopen after the dwell. + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('disabled suppresses opening entirely', () => { + const { wrapper } = mount({ disabled: true }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('flipping disabled true closes an open card', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(row} content={
card body
} disabled />) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('corrects the bottom-edge clamp once the mounted card height is measurable', () => { + // First placement reads height 0 (card not yet mounted) and keeps the + // anchor top; the post-mount correction re-clamps with the real height. + window.innerHeight = 300 + const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')! + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 120 }) + try { + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + // 300 - 120 - 8 = 172, instead of the anchor top 280. + expect(card.style.top).toBe('172px') + } finally { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight) + } + }) + + it('clamps inside placement itself when the card is already measured (resize path)', () => { + window.innerHeight = 300 + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + Object.defineProperty(card, 'offsetHeight', { value: 120 }) + act(() => { fireEvent.resize(window) }) + expect(card.style.top).toBe('172px') + }) + + it('repositions on capture-phase scroll while open and stops listening after close', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + stubAnchorRect(screen.getByText('row'), { top: 90, right: 300 }) + act(() => { fireEvent.scroll(document) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.left).toBe('308px') + expect(card.style.top).toBe('90px') + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('unmount clears a pending open timer', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + view.unmount() + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) +}) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 57de2cbaf5..d051df2fdb 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -48,6 +48,7 @@ function GroupByMenu({ groupBy, onPick }: { items={GROUP_BY_ITEMS} selectedId={groupBy} onSelect={(id) => { + /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ if (id === 'workspace' || id === 'flat') onPick(id) setOpen(false) }} @@ -137,6 +138,7 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen onRename={group.workspaceId === undefined ? undefined : () => { + /* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */ if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) }} /> @@ -154,9 +156,11 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen active: sameGroupDrag, marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, hover: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) }, drop: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ if (drag === null) return const roots = group.sessions // Anchor = the row the insert line points at ('after' means @@ -218,6 +222,7 @@ function FlatList({ useSessions, open, query }: Pick {}} flat /> diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index fdc5c43b8e..b90fb9a601 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { RowDragProps } from '../src/client/rows/Rows.tsx' import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' import type { GroupNode, SessionNode } from '../src/client/tree.ts' @@ -10,6 +11,32 @@ afterEach(cleanup) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId +/** Half detection reads the row rect; jsdom rects are all-zero by default. */ +function stubRect(row: HTMLElement): void { + row.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) +} + +function dragProps(overrides: Partial = {}): RowDragProps { + return { + start: vi.fn(), active: false, marker: null, + hover: vi.fn(), drop: vi.fn(), end: vi.fn(), + ...overrides, + } +} + +const dataTransfer = { effectAllowed: '', dropEffect: '' } + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } }) + fireEvent(row, event) +} + describe('workspace browser rows', () => { it('renders an active Workspace and keeps its create action separate from toggling', () => { const onToggle = vi.fn() @@ -73,4 +100,152 @@ describe('workspace browser rows', () => { expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false') expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px') }) + + it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { + const onRename = vi.fn() + const onToggle = vi.fn() + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render() + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + // Opening the menu neither toggles the group nor renames yet. + expect(onToggle).not.toHaveBeenCalled() + expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + expect(onRename).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + // Delete stays visual-only: selecting it just closes the menu. + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onRename).toHaveBeenCalledOnce() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('ungrouped bucket renders no workspace menu', () => { + const group: GroupNode = { + key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render() + expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull() + }) + + it('session row menu opens without opening the session and closes on selection', () => { + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'One', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render() + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + expect(onOpen).not.toHaveBeenCalled() + expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/) + fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onOpen).not.toHaveBeenCalled() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('flat variant renders no twist even for a parent and ignores toggling', () => { + const node: SessionNode = { + id: sid('p'), title: 'Parent', children: [], hasChildren: true, + expanded: false, running: false, updatedAt: 0, + } + render() + expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull() + }) + + it('shows the hover card after the dwell and suppresses it while the row menu is open', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, + expanded: false, running: true, updatedAt: 0, + } + render() + const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + // Card body: full title + relative time + running status. + expect(screen.getAllByText('Hovered')).toHaveLength(2) + expect(screen.getByText('1min ago')).toBeTruthy() + expect(screen.getByText('Running')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + // Menu open (disabled=true) suppresses the card for the same hover. + fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' })) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('1min ago')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('idle hover card shows the Idle status line', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('Idle')).toBeTruthy() + expect(screen.getByText('now ago')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { + const node: SessionNode = { + id: sid('s1'), title: 'Drag me', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + const inactive = dragProps() + const { rerender } = render( + , + ) + const row = screen.getByRole('treeitem') + stubRect(row) + expect(row.getAttribute('draggable')).toBe('true') + fireEvent.dragStart(row, { dataTransfer }) + expect(inactive.start).toHaveBeenCalledOnce() + // Inactive drag: hover and drop are rejected. + fireEvent.dragOver(row, { dataTransfer }) + fireEvent.drop(row, { dataTransfer }) + expect(inactive.hover).not.toHaveBeenCalled() + expect(inactive.drop).not.toHaveBeenCalled() + fireEvent.dragEnd(row) + expect(inactive.end).toHaveBeenCalledOnce() + + const active = dragProps({ active: true, marker: 'before' }) + rerender( + , + ) + stubRect(screen.getByRole('treeitem')) + // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). + fireDrag(screen.getByRole('treeitem'), 'dragOver', 105) + expect(active.hover).toHaveBeenCalledWith('before') + fireDrag(screen.getByRole('treeitem'), 'dragOver', 130) + expect(active.hover).toHaveBeenCalledWith('after') + fireDrag(screen.getByRole('treeitem'), 'drop', 130) + expect(active.drop).toHaveBeenCalledWith('after') + + const after = dragProps({ active: true, marker: 'after' }) + rerender( + , + ) + expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) + }) }) diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index d114d43dea..b314e14571 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId @@ -52,6 +53,12 @@ describe('deriveGroups', () => { expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false) }) + it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const } + const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view()) + expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false })) + }) + it('search filters real Sessions and omits the Intent placeholder', () => { const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const } const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match')) @@ -135,6 +142,39 @@ describe('deriveGroups', () => { }) }) +describe('deriveFlat', () => { + it('flattens every session — fork children included — newest-first with id tiebreak', () => { + const parent = summary('parent', 10) + const child = { ...summary('child', 30), parentId: parent.id } + const tieB = summary('tie-b', 20) + const tieA = summary('tie-a', 20) + const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' }) + expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) + // Rows are branch-free: no children, no expansion. + expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true) + }) + + it('search filters by case-insensitive display-title substring', () => { + const hit = { ...summary('hit', 2), displayTitle: 'Needle row' } + const miss = { ...summary('miss', 1), displayTitle: 'Other' } + expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')]) + }) + + it('tolerates ids whose summary has not landed yet', () => { + const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } + expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')]) + }) +}) + +describe('createWorkspaceViewStore', () => { + it('defaults to workspace grouping; setGroupBy is the sole mutation', () => { + const store = createWorkspaceViewStore().create() + expect(store.getSnapshot().groupBy).toBe('workspace') + store.actions.setGroupBy('flat') + expect(store.getSnapshot().groupBy).toBe('flat') + }) +}) + describe('projectLabel', () => { it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => { expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx new file mode 100644 index 0000000000..3e592c3168 --- /dev/null +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -0,0 +1,458 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { + SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' +import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' + +afterEach(cleanup) +beforeEach(() => { localStorage.clear() }) + +const sid = (id: string) => id as SessionId +const wid = (id: string) => id as WorkspaceId +const summary = (id: string, updatedAt: number, overrides: Partial = {}): SessionSummary => ({ + id: sid(id), displayTitle: id, running: false, updatedAt, ...overrides, +}) +const sessionState = (items: readonly SessionSummary[], overrides: Partial = {}): SessionListState => ({ + ids: items.map(item => item.id), + byId: Object.fromEntries(items.map(item => [item.id, item])), + current: undefined, + phase: 'ready', + intent: undefined, + ...overrides, +}) +const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ + workspaceId: wid(id), path: `/projects/${id}`, title, + sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +}) +const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ + items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + recentWorkspaceId: items[0]?.workspaceId, +}) +const hook = (snapshot: T) => (selector: (state: T) => S): S => selector(snapshot) + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } }) + fireEvent(row, event) +} + +function mount(overrides: Partial = {}) { + const store = createWorkspaceViewStore().create() + const props: WorkspaceBrowserProps = { + wide: true, + expandSidebar: vi.fn(), + useSessions: hook(sessionState([])), + useWorkspaces: hook(workspaceState([])), + useStore: bindSnapshotSelector(store), + actions: store.actions, + startSession: vi.fn(), + open: vi.fn(), + renameWorkspace: vi.fn(async () => {}), + insertSessionBefore: vi.fn(async () => {}), + createWorkspace: vi.fn(async () => workspace('created', [])), + ...overrides, + } + const view = render() + return { view, props, store } +} + +/** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */ +function rerender(b: ReturnType, overrides: Partial) { + Object.assign(b.props, overrides) + b.view.rerender() +} + +describe('WorkspaceBrowser', () => { + it('renders the grouped tree by default and switches to the flat list via Group by', () => { + const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])), + }) + expect(screen.getByText('Workspaces')).toBeTruthy() + expect(screen.getByText('alpha')).toBeTruthy() + // Sessions hidden while their group is folded. + expect(screen.queryByText('alpha-s')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label + fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' })) + // Store-driven flip: title changes, rows flatten newest-first, headers gone. + expect(b.store.getSnapshot().groupBy).toBe('flat') + expect(screen.getByText('Sessions')).toBeTruthy() + expect(screen.queryByText('alpha')).toBeNull() + expect(screen.getByText('alpha-s')).toBeTruthy() + expect(screen.getByText('beta-s')).toBeTruthy() + + // Back to workspace grouping through the same menu. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' })) + expect(b.store.getSnapshot().groupBy).toBe('workspace') + expect(screen.getByText('Workspaces')).toBeTruthy() + + // Escape closes the menu without picking. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + expect(b.store.getSnapshot().groupBy).toBe('workspace') + }) + + it('expands a group on click and opens a session row', () => { + const open = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + open, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha-s')) + expect(open).toHaveBeenCalledWith(sid('alpha-s')) + // Collapse hides the row again. + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('alpha-s')).toBeNull() + }) + + it('unfolds a session subtree through the row twist', () => { + const parent = summary('parent-s', 2) + const child = { ...summary('child-s', 1), parentId: parent.id } + mount({ + useSessions: hook(sessionState([parent, child])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('child-s')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Expand' })) + expect(screen.getByText('child-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) + expect(screen.queryByText('child-s')).toBeNull() + }) + + it('auto-expands the selected session group and starts a session from the group +', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + startSession, + }) + // The current-group effect expanded the owning group without a click. + expect(screen.getByText('alpha-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' })) + expect(startSession).toHaveBeenCalledWith(wid('alpha')) + }) + + it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + startSession, + }) + // The loose session's group is UNGROUPED_KEY: expanded by the effect. + expect(screen.getByText('loose')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) + expect(startSession).not.toHaveBeenCalled() + }) + + it('keeps an already-expanded group when the selection moves within it', () => { + const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') }) + const b = mount({ + useSessions: hook(first), + useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])), + }) + expect(screen.getByText('a')).toBeTruthy() + // Selection hop inside the same group: the effect re-runs and leaves the + // expansion list unchanged (no duplicate key, group still open). + rerender(b, { useSessions: hook({ ...first, current: sid('b') }) }) + expect(screen.getByText('b')).toBeTruthy() + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('b')).toBeNull() + }) + + it('renders the intent placeholder in both modes', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const } + const sessions = sessionState([], { intent, current: sid('intent') }) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + }) + // Grouped: the current-group effect expands the target group. + expect(screen.getByText('New session')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('New session')).toBeTruthy() + }) + + it('searches across groups, clears via the clear button, and shows the empty states', () => { + const sessions = sessionState([ + summary('needle-row', 2, { displayTitle: 'Needle row' }), + summary('other-row', 1, { displayTitle: 'Other row' }), + ]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), + }) + const input = screen.getByPlaceholderText('Search name, keywords...') + fireEvent.change(input, { target: { value: 'needle' } }) + // Search forces matches visible without expansion state. + expect(screen.getByText('Needle row')).toBeTruthy() + expect(screen.queryByText('Other row')).toBeNull() + fireEvent.change(input, { target: { value: 'zzz' } }) + expect(screen.getByText('No matches')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + expect(input.value).toBe('') + // Clicking the field row focuses the input (wide mode). + fireEvent.click(input.parentElement as HTMLElement) + expect(document.activeElement).toBe(input) + }) + + it('shows the no-sessions empty state in both modes', () => { + const b = mount() + expect(screen.getByText('No sessions yet')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('No sessions yet')).toBeTruthy() + // Flat search misses show No matches. + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } }) + expect(screen.getByText('No matches')).toBeTruthy() + }) + + it('rail state renders icon controls that request expansion', () => { + vi.useFakeTimers() + try { + const expandSidebar = vi.fn() + const b = mount({ wide: false, expandSidebar }) + // No wide chrome in rail state. + expect(screen.queryByText('Workspaces')).toBeNull() + expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + // The wide flip mounts the input and focuses it after the slide. + rerender(b, { wide: true }) + const input = screen.getByPlaceholderText('Search name, keywords...') + act(() => { vi.advanceTimersByTime(300) }) + expect(document.activeElement).toBe(input) + // Wide search button is decorative (tabIndex -1, no expand call). + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => { + const expandSidebar = vi.fn() + const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + rerender(b, { wide: true }) + // The picker menu is open (anchored on the +); picking starts a session. + fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' })) + expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha')) + expect(screen.queryByRole('menu')).toBeNull() + // Wide toggle: open and close without expand requests. + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.getByRole('menu')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(expandSidebar).toHaveBeenCalledTimes(1) + + // Escape closes the picker through its own onClose. + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header + const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement] + three.getBoundingClientRect = () => ({ + top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + // Drop on the top half of "three": insert one before three. + fireDrag(three, 'dragOver', 205) + fireDrag(three, 'drop', 205) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three')) + + // Dropping right back onto its own position is a no-op — top half + // (anchor = itself) and bottom half (anchor = the next root) alike. + fireEvent.dragStart(one, { dataTransfer }) + one.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(one, 'drop', 130) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + }) + + it('still sends the reorder when the dragged row left the group mid-drag', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } }) + // The host dropped "one" from the workspace account while the drag is in + // flight: the source index is gone but the drop still resolves its anchor. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) }) + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + fireDrag(two, 'drop', 155) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two')) + }) + + it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireEvent.dragEnd(one) + // The drag ended: rows no longer accept drops. + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).not.toHaveBeenCalled() + + // Bottom half of the last row: append (anchor omitted). + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'dragOver', 180) + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) + }) + + it('logs and keeps the order when the reorder call rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') }) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'drop', 180) + await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) }) + } finally { + warn.mockRestore() + } + }) + + it('renames a workspace through the row menu dialog', async () => { + let resolveRename!: () => void + const renameWorkspace = vi.fn(() => new Promise((resolve) => { resolveRename = resolve })) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText('Workspace name') + expect(input.value).toBe('Alpha') + // Unchanged and blank names stay blocked. + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: ' ' } }) + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + // A duplicate of another workspace's title shows the inline conflict. + fireEvent.change(input, { target: { value: ' Beta ' } }) + expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.') + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: 'Gamma' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma') + // While renaming: input disabled, close blocked, Enter ignored. + expect(input.disabled).toBe(true) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.getByRole('dialog')).toBeTruthy() + await act(async () => { resolveRename() }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('rename via Enter, failure surfaces the error, Cancel closes', async () => { + const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText('Workspace name') + // Enter with a blocked draft (unchanged) does nothing. + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).not.toHaveBeenCalled() + fireEvent.change(input, { target: { value: 'Renamed' } }) + fireEvent.keyDown(input, { key: 'a' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed') + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') }) + // The dialog stays for retry; typing clears the error; Cancel closes. + fireEvent.change(input, { target: { value: 'Renamed2' } }) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('reports non-Error rename failures as text', async () => { + const renameWorkspace = vi.fn(async () => { throw 'denied' }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + }) + + it('search hides drag affordances (rows are not draggable during search)', () => { + const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), + }) + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } }) + const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement + expect(row.getAttribute('draggable')).toBe('false') + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25b095b99f..a81aadd94b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -68,6 +68,19 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } }) }) + it('routes workspace rename and insertSessionBefore through the wire', async () => { + const api = scriptedApi() + const c = client(api) + const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) + expect(renamed.result.ok).toBe(true) + const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) + expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) + const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) + expect(anchored.result.ok).toBe(true) + const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) + expect(appended.result.ok).toBe(true) + }) + it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ sessions: { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index d0f2ddd128..ebb7931e6e 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { RpcId } from '../src/api/rpc.ts' +import { RpcId, transportError } from '../src/api/rpc.ts' import { clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema, @@ -13,8 +13,10 @@ import { } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { - workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema, - workspaceListValueSchema, workspaceViewSchema, + workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, + workspaceListRequestSchema, workspaceListValueSchema, + workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' @@ -30,6 +32,13 @@ describe('RpcId', () => { }) }) +describe('transportError', () => { + it('folds Error and non-Error throws into the internal error branch', () => { + expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } }) + expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } }) + }) +}) + describe('rpcErrorSchema', () => { it('accepts every code branch with its required details', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') @@ -40,6 +49,7 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found') expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict') + expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -154,6 +164,19 @@ describe('workspace domain schemas', () => { expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) + it('rename requires a non-blank title (both refine arms)', () => { + expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new') + expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/) + expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + + it('insertSessionBefore accepts an anchored and an anchorless move', () => { + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() + expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() + expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + }) describe('events frame schemas', () => { diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index bdefc2ec82..8ce70cc7d5 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts' +import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -449,6 +449,56 @@ describe('Workspace session ordering', () => { expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1']) }) + it('moves one id before an anchor or to the end, durably', async () => { + const dir = await makeDir('insert-before') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2), header('s3', dir, 3)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + await workspace.attachSession(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s3', 's2', 's1']) + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s2')) + expect(workspace.sessionIds).toEqual(['s3', 's1', 's2']) + await workspace.insertSessionBefore(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s1', 's2', 's3']) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2', 's3']) + }) + + it('treats self-anchored and already-in-place moves as no-ops without writing', async () => { + const dir = await makeDir('insert-noop') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + const written = result.changes.length + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s2'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s1')) + await workspace.detachSession(SessionId('absent')) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s2', 's1']) + }) + + it('rejects moves naming an unaccounted session or anchor', async () => { + const dir = await makeDir('insert-invalid') + const result = await harness() + result.setSessions([header('s1', dir, 1)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + const written = result.changes.length + + await expect(workspace.insertSessionBefore(SessionId('ghost'))) + .rejects.toBeInstanceOf(WorkspaceMoveInvalidError) + await expect(workspace.insertSessionBefore(SessionId('s1'), SessionId('ghost'))) + .rejects.toThrow(/anchor session is not accounted/) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s1']) + }) + it('validates a lazy live session without requiring it in persistence.list()', async () => { const dir = await makeDir('live') const result = await harness({ sessions: [], liveSessions: [header('live', dir, 1)] }) From d147673dfdcdf393ccf6a62ed0549a291831b18d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:58:53 +0800 Subject: [PATCH 20/45] refactor(apiproxy): share the workspace-not-found response The rename/insertSessionBefore lookups tripped the cross-file clone gate; one helper owns the error row now. --- packages/host/apiproxy/src/api-proxy.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d50dd000e1..c7d3cb7ef9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -299,6 +299,15 @@ class SessionCwdConflict extends Error { /** Host failed before the registry could adopt a name-created directory. */ class WorkspaceDirectoryCreationError extends Error {} +/** Shared workspace-not-found error response of the workspace.* mutation rows. */ +function workspaceNotFound(request: RpcRequest, workspaceId: string): RpcResponse { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${workspaceId}" not found`, + details: { workspaceId }, + }) +} + /** Wire projection of one workspace entity (the workspace.* value row). */ function workspaceView(workspace: Workspace): WorkspaceView { return { @@ -684,13 +693,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async rename(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${payload.workspaceId}" not found`, - details: { workspaceId: payload.workspaceId }, - }) - } + if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) const title = payload.title.trim() // Uniqueness AND the same-title no-op both ride the create chain so // they observe the state left by earlier queued renames — checked @@ -722,13 +725,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${payload.workspaceId}" not found`, - details: { workspaceId: payload.workspaceId }, - }) - } + if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) try { await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId) } catch (error: unknown) { From 4ae86001a5cdd6f6a03a34909a49f417151d9836 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:03:05 +0800 Subject: [PATCH 21/45] style(web): wrap a long tree.ts doc line --- packages/client/ui-workspace/src/client/tree.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 39a479f3b3..7de4d14e8d 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -227,7 +227,8 @@ function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { * * Normal mode: every group shows; sessions populate under expanded groups, * descending only into expanded sessions. A frontend Session Intent targeting - * a real Workspace marks that group `intentHere` (rendered only while the group is expanded; expansion stays viewer-owned). Search mode (non-blank query, + * a real Workspace marks that group `intentHere` (rendered only while the + * group is expanded; expansion stays viewer-owned). Search mode (non-blank query, * case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups * without a display-title or label hit are dropped, a label-only hit keeps From ad419065a0a71a49f801028d66cd0afbdd56df36 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:10:59 +0800 Subject: [PATCH 22/45] test(web): stop pre-clicking the fixture group in the title snapshot The Intent's current-group effect already expands the target workspace; with intent no longer forcing expansion, the header click collapsed it. --- apps/web/tests/session-title.snapshot.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index c1616bb724..532c6866da 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -94,10 +94,10 @@ it('projects initial and revised durable titles through the built nine-plugin fi }) const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) - const projectCount = await within(tree).findByText('4 sessions') - const projectRow = projectCount.closest('[role="treeitem"]') - if (projectRow === null) throw new Error('fixture project row missing') - fireEvent.click(projectRow) + // The fixture Intent selects the workspace, so the current-group effect + // already expanded it; clicking the header would now collapse (the twist + // stays live since intent stopped forcing expansion). + await within(tree).findByText('4 sessions') const initialLabel = 'Fixture 历史会话' const initialRowLabel = await screen.findByText(initialLabel) From 50cf90bf6ef6f817afc5a6e6fbd60c4096046750 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 02:21:58 +0800 Subject: [PATCH 23/45] fix(web): flush the intent draft into the list snapshot in the same tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero composer renders the frontend Session Intent's retained prompt from the sessions list snapshot, but updateIntent only reached that snapshot through the intent watch's microtask-deferred markDirty. React therefore rolled the controlled textarea back during the change tick, which corrupted IME composition (Pinyin "nihao" committed fragments like "nnini hni hani hao你好") and jumped the caret on plain typing. SessionManager.updateIntent now calls notifyNow after updatePendingPrompt, per the Notifier channel rule for direct echoes of user gestures. The workspace-flow snapshot helper asserts the same-tick echo instead of waiting for it, and a runtime unit test pins the contract at the manager seam. --- ...7-26-intent-draft-same-tick-echo.i18n.yaml | 6 ++++ .../2026-07-26-intent-draft-same-tick-echo.md | 27 ++++++++++++++++++ ...26-07-26-intent-draft-same-tick-echo.zh.md | 27 ++++++++++++++++++ apps/web/tests/workspace-flow.snapshot.ts | 18 ++++++------ .../runtime/src/client/sessions/manager.ts | 9 +++++- .../runtime/tests/session-intents.spec.ts | 28 +++++++++++++++++++ 6 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml new file mode 100644 index 0000000000..390f118a55 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.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 +2026-07-26-intent-draft-same-tick-echo.md: 1a4fdb48c0434bd37d7771dddb640720e1b610e6 +2026-07-26-intent-draft-same-tick-echo.zh.md: 9ecdf7154f5014de242021f99d2e51959c2a3169 diff --git a/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md new file mode 100644 index 0000000000..1a4fdb48c0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md @@ -0,0 +1,27 @@ +# Agent Note: Intent draft echoes in the same tick + +Status: implemented + +English | [中文](2026-07-26-intent-draft-same-tick-echo.zh.md) + +## Problem + +The hero composer ("Let's start building") is a controlled textarea whose value is the frontend Session Intent's retained prompt, read from the sessions **list** snapshot (`EmptyState` binds `intent.prompt` via `useSessions`). Typing routed through `SessionManager.updateIntent → Session.updatePendingPrompt`, which flushes the **Session's own** notifier synchronously — but the list snapshot the composer actually renders from only heard about the change through the intent watch subscription in `startIntent`, which calls `markDirty()`, a microtask-deferred flush. + +A deferred echo violates the controlled-input contract documented on the Notifier (see the [web client architecture note](../architecture/2026-07-19-gui-web-client-architecture.md)): React compares the DOM value against the still-stale snapshot during the same tick as `onChange` and rolls the textarea back. With plain typing this shows as caret jumps; with an IME it corrupts input — every composition update gets rolled back and re-applied against a stale value, so typing Pinyin "nihao" commits fragments like "nnini hni hani hao你好". The resident composer (`ConversationRoot`) was not affected: its draft lives in the chat store (sync flush) or comes from `updateSessionPrompt`, which reads the Session snapshot directly rather than the list projection. + +## Decision + +`SessionManager.updateIntent` calls `this.notifier.notifyNow()` after `updatePendingPrompt`, flushing the list snapshot in the same tick as the change event. This matches the Notifier's channel rule: a direct echo of a user gesture whose controlled input renders from this snapshot uses `notifyNow`; the intent watch keeps `markDirty` for every other (async) intent transition. + +## Alternatives considered + +**Change the intent watch callback in `startIntent` to `notifyNow`.** Wrong channel for that seam: the watch also fires on frame-driven Session changes (publication, send phases), and the architecture note bans `notifyNow` for frame-driven sources because it collapses batching. + +**Have `EmptyState` read the prompt from the Session snapshot instead of the list.** Restructures the slot contract (EmptyState is deliberately bound to the standard `useSessions` feed and has no session scope yet — the frontend Session is page-local) for no gain over flushing the projection it already reads. + +**Suppress the rollback in `InputBar` with local uncontrolled state.** Hides the symptom, forfeits the single-source-of-truth draft (the retained prompt must survive workspace retargeting and send/retry), and leaves every other list-snapshot-controlled input exposed. + +## Consequences + +Typing in the hero composer, IME composition included, echoes synchronously. `updateIntent` on a no-intent state stays a no-op with no notification. The web workspace-flow snapshot's composer helper now asserts the same-tick echo instead of waiting for it, so a regression to a deferred echo fails the keyless snapshot gate; a runtime unit test pins the same contract at the manager seam. diff --git a/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md new file mode 100644 index 0000000000..9ecdf7154f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Intent draft echoes in the same tick + +Status: implemented + +[English](2026-07-26-intent-draft-same-tick-echo.md) | 中文 + +## Problem + +hero composer(「Let's start building」)是一个受控(controlled)的 textarea,它的值取自前端 Session Intent 保留下来的提示词,读自会话**列表**快照(`EmptyState` 通过 `useSessions` 绑定 `intent.prompt`)。输入经由 `SessionManager.updateIntent → Session.updatePendingPrompt`,后者会同步刷新 **Session 自身的** notifier——但 composer 实际渲染所依据的那份列表快照,只能通过 `startIntent` 中的 intent watch 订阅得知这次变更,而该订阅调用的是 `markDirty()`,即一次延迟到微任务的刷新。 + +延迟的回显违反了 Notifier 上所记录的受控输入契约(见 [web 客户端架构笔记](../architecture/2026-07-19-gui-web-client-architecture.md)):React 在与 `onChange` 相同的 tick 内,把 DOM 值与仍然陈旧的快照相比对,随后把 textarea 回滚。普通输入时,这表现为光标跳动;使用输入法(IME)时,它会损坏输入——每一次 composition 更新都会被回滚,并针对陈旧的值重新应用,因此输入拼音「nihao」会提交出类似「nnini hni hani hao你好」这样的片段。resident composer(`ConversationRoot`)不受影响:它的草稿存放在 chat store 中(同步刷新),或来自 `updateSessionPrompt`,后者直接读取 Session 快照,而不是列表投影。 + +## Decision + +`SessionManager.updateIntent` 在 `updatePendingPrompt` 之后调用 `this.notifier.notifyNow()`,从而在与变更事件相同的 tick 内刷新列表快照。这符合 Notifier 的通道规则:当某个用户手势的受控输入正是从该快照渲染时,对它的直接回显使用 `notifyNow`;而 intent watch 对其余所有(异步的)intent 状态转换仍保留 `markDirty`。 + +## Alternatives considered + +**把 `startIntent` 中的 intent watch 回调改为 `notifyNow`。** 对那个 seam 而言是错误的通道:该 watch 也会在帧驱动的 Session 变更(发布、发送阶段)时触发,而架构笔记禁止对帧驱动的来源使用 `notifyNow`,因为那会瓦解批处理。 + +**让 `EmptyState` 从 Session 快照而非列表读取提示词。** 这会重构槽位契约(EmptyState 有意绑定到标准的 `useSessions` 数据源,且尚无 session 作用域——前端 Session 是页面本地的),相比刷新它本就读取的那份投影并无收益。 + +**在 `InputBar` 中用本地的非受控状态抑制回滚。** 这只是掩盖症状,放弃了单一真源的草稿(保留下来的提示词必须在工作区重定向以及发送/重试后依然存在),并让其余每一个由列表快照控制的输入都暴露在同一问题之下。 + +## Consequences + +在 hero composer 中输入(包括输入法 composition 在内)会同步回显。在无 intent 的状态上调用 `updateIntent` 仍是一次空操作,不发出任何通知。web workspace-flow 快照的 composer 辅助函数现在断言的是同一 tick 内的回显,而不是等待它,因此一旦回退成延迟回显,就会让无密钥快照门禁失败;一个运行时单元测试在 manager 这一 seam 处钉住了同一份契约。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 78ac843a64..3c96374605 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -116,10 +116,12 @@ function workspaceChip(): HTMLElement { return chip } -/** Wait for the runtime-owned controlled input to echo a browser edit. */ -async function setComposerText(composer: HTMLElement, value: string): Promise { +/** Edit the runtime-owned controlled input and assert the same-tick echo: + * a deferred echo makes React roll the textarea back mid-IME-composition, + * committing partial keystrokes (e.g. Pinyin "nihao" leaking as "nnini h…"). */ +function setComposerText(composer: HTMLElement, value: string): void { fireEvent.change(composer, { target: { value } }) - await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) }) + expect((composer as HTMLTextAreaElement).value).toBe(value) } it('starts a writable page-local draft without inventing a sidebar Workspace', async () => { @@ -127,7 +129,7 @@ it('starts a writable page-local draft without inventing a sidebar Workspace', a const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const tree = screen.getByRole('tree', { name: 'Sessions' }) - await setComposerText(composer, 'keep this local') + setComposerText(composer, 'keep this local') expect({ headline: visibleText(screen.getByText("Let's start building")), @@ -188,7 +190,7 @@ it('drops the page-local draft on refresh while retaining real Workspaces and Se const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const tree = screen.getByRole('tree', { name: 'Sessions' }) - await setComposerText(composer, 'discard this page-local draft') + setComposerText(composer, 'discard this page-local draft') const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]') if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh') @@ -232,7 +234,7 @@ it('keeps a published Session with only cwd membership evidence in Ungrouped', a boot('?fixture&fixtureAttach=fail') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - await setComposerText(composer, 'keep this cwd-only session') + setComposerText(composer, 'keep this cwd-only session') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const tree = screen.getByRole('tree', { name: 'Sessions' }) @@ -267,7 +269,7 @@ it('materializes the automatic Workspace and Session on the first successful sen boot('?fixture=empty') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - await setComposerText(composer, 'build a lighthouse') + setComposerText(composer, 'build a lighthouse') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const tree = screen.getByRole('tree', { name: 'Sessions' }) @@ -296,7 +298,7 @@ it('keeps the published Workspace, Session, and unsent prompt after rejection', boot('?fixture=empty&fixturePrompt=reject') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - await setComposerText(composer, 'do not lose this') + setComposerText(composer, 'do not lose this') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const alert = await screen.findByRole('alert', {}, { timeout: 10_000 }) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 907fc961f6..51d07e70e6 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -162,7 +162,14 @@ export class SessionManager { * @param text - exact controlled-input value for the active frontend Session. */ updateIntent(text: string): void { - this.getIntent()?.updatePendingPrompt(text) + const session = this.getIntent() + if (session === undefined) return + session.updatePendingPrompt(text) + // The intent watch defers via markDirty, but the hero composer reads this + // prompt from the LIST snapshot as a controlled value: it must flush in + // the same tick as onChange (see Notifier.notifyNow) or React rolls the + // textarea back and IME composition breaks. + this.notifier.notifyNow() } private discardIntent(): void { diff --git a/packages/client/runtime/tests/session-intents.spec.ts b/packages/client/runtime/tests/session-intents.spec.ts index c09bfa4ee4..fca2c3e0b4 100644 --- a/packages/client/runtime/tests/session-intents.spec.ts +++ b/packages/client/runtime/tests/session-intents.spec.ts @@ -60,6 +60,34 @@ describe('frontend Session and Workspace intents', () => { expect(workspaces.list.getSnapshot().intent).toBeUndefined() }) + it('echoes updateIntent into the list snapshot in the same tick (controlled-input contract)', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + await ready(api, workspaces, sessions, [workspace('target')]) + let notified = 0 + sessions.list.subscribe(() => { notified += 1 }) + // IME composition drives change events that a controlled textarea must see + // reflected before the handler returns; a microtask-deferred echo makes + // React roll the DOM back and the composition commits partial keystrokes. + sessions.updateIntent('你') + expect(sessions.list.getSnapshot().intent?.prompt).toBe('你') + expect(notified).toBeGreaterThan(0) + }) + + it('ignores updateIntent with no active Intent', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + await ready(api, workspaces, sessions, [workspace('only', [sid('s-real')])], [ + { sessionId: sid('s-real'), updatedAt: 1, running: false }, + ]) + sessions.open(sid('s-real')) + expect(sessions.list.getSnapshot().intent).toBeUndefined() + let notified = 0 + sessions.list.subscribe(() => { notified += 1 }) + sessions.updateIntent('dropped') + expect(notified).toBe(0) + }) + it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => { const api = new FakeApiClient() const { sessions, workspaces } = services(api) From b2d8815a34b584a8704d2285e33ea45b279fc337 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:29:13 +0800 Subject: [PATCH 24/45] test(llm-mock-server): poll for the client-closed outcome The fixed 5ms sleep raced slow CI runners (the server observes the socket close asynchronously); vi.waitFor polls until the outcome lands. --- packages/support/llm-mock-server/tests/server.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/support/llm-mock-server/tests/server.spec.ts b/packages/support/llm-mock-server/tests/server.spec.ts index 1f15ba1a0b..34db57aa82 100644 --- a/packages/support/llm-mock-server/tests/server.spec.ts +++ b/packages/support/llm-mock-server/tests/server.spec.ts @@ -1,5 +1,5 @@ import { request } from 'node:http' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts' import { startMockLlmServer } from '../src/index.ts' @@ -179,9 +179,11 @@ describe('mock LLM server wire behaviors', () => { const response = await chat(server, { signal: controller.signal }) controller.abort() await expect(response.text()).rejects.toThrow() - await new Promise((resolve) => { setTimeout(resolve, 5) }) - - expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + // The server observes the socket close asynchronously; a fixed sleep + // raced slow runners, so poll until the outcome lands. + await vi.waitFor(() => { + expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + }) expect(events.filter(event => event.type === 'result')).toEqual([ expect.objectContaining({ behavior, outcome: 'client_closed' }), ]) From 30db52fa4bc44f9601207d85f9407bfcd76574d6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:33:29 +0800 Subject: [PATCH 25/45] docs: translate remaining non-README documentation --- ...-21-bounded-llm-request-recovery.i18n.yaml | 6 + ...2026-06-21-bounded-llm-request-recovery.md | 2 + ...6-06-21-bounded-llm-request-recovery.zh.md | 148 +++++++++++++ ...026-07-05-windows-fs-permissions.i18n.yaml | 6 + .../2026-07-05-windows-fs-permissions.md | 2 + .../2026-07-05-windows-fs-permissions.zh.md | 33 +++ ...05-windows-jsonl-durable-publish.i18n.yaml | 6 + ...026-07-05-windows-jsonl-durable-publish.md | 2 + ...-07-05-windows-jsonl-durable-publish.zh.md | 35 ++++ ...06-tool-result-retention-library.i18n.yaml | 6 + ...026-07-06-tool-result-retention-library.md | 2 + ...-07-06-tool-result-retention-library.zh.md | 157 ++++++++++++++ ...26-07-08-tool-output-spill-files.i18n.yaml | 6 + .../2026-07-08-tool-output-spill-files.md | 2 + .../2026-07-08-tool-output-spill-files.zh.md | 195 +++++++++++++++++ .../2026-06-24-workspace-context.i18n.yaml | 6 + .../feature/2026-06-24-workspace-context.md | 2 + .../2026-06-24-workspace-context.zh.md | 89 ++++++++ .../feature/2026-07-07-plan-mode.i18n.yaml | 6 + .../feature/2026-07-07-plan-mode.md | 2 + .../feature/2026-07-07-plan-mode.zh.md | 196 ++++++++++++++++++ ...-07-08-background-subagent-tasks.i18n.yaml | 6 + .../2026-07-08-background-subagent-tasks.md | 2 + ...2026-07-08-background-subagent-tasks.zh.md | 64 ++++++ ...-bash-backed-grep-glob-discovery.i18n.yaml | 6 + ...6-07-09-bash-backed-grep-glob-discovery.md | 2 + ...7-09-bash-backed-grep-glob-discovery.zh.md | 170 +++++++++++++++ ...ession-identity-and-log-location.i18n.yaml | 6 + ...agent-session-identity-and-log-location.md | 2 + ...nt-session-identity-and-log-location.zh.md | 87 ++++++++ ...-10-parallel-tool-call-execution.i18n.yaml | 6 + ...2026-07-10-parallel-tool-call-execution.md | 2 + ...6-07-10-parallel-tool-call-execution.zh.md | 103 +++++++++ ...2026-07-13-session-query-tracing.i18n.yaml | 6 + .../2026-07-13-session-query-tracing.md | 2 + .../2026-07-13-session-query-tracing.zh.md | 36 ++++ ...13-documentation-site-projection.i18n.yaml | 6 + ...026-07-13-documentation-site-projection.md | 2 + ...-07-13-documentation-site-projection.zh.md | 47 +++++ ...2026-07-06-recallable-compaction.i18n.yaml | 6 + .../2026-07-06-recallable-compaction.md | 2 + .../2026-07-06-recallable-compaction.zh.md | 110 ++++++++++ ...3-human-review-skill-maintenance.i18n.yaml | 6 + ...26-07-13-human-review-skill-maintenance.md | 2 + ...07-13-human-review-skill-maintenance.zh.md | 85 ++++++++ .../maintaining-dsh-code-review.i18n.yaml | 6 + docs/cookbook/maintaining-dsh-code-review.md | 2 + .../maintaining-dsh-code-review.zh.md | 64 ++++++ .../cordis-tutorial/01-first-plugin.i18n.yaml | 6 + docs/cordis-tutorial/01-first-plugin.md | 2 + docs/cordis-tutorial/01-first-plugin.zh.md | 95 +++++++++ .../02-lifecycle-and-effects.i18n.yaml | 6 + .../02-lifecycle-and-effects.md | 2 + .../02-lifecycle-and-effects.zh.md | 98 +++++++++ docs/cordis-tutorial/03-services.i18n.yaml | 6 + docs/cordis-tutorial/03-services.md | 2 + docs/cordis-tutorial/03-services.zh.md | 98 +++++++++ docs/cordis-tutorial/04-events.i18n.yaml | 6 + docs/cordis-tutorial/04-events.md | 2 + docs/cordis-tutorial/04-events.zh.md | 144 +++++++++++++ docs/cordis-tutorial/05-config.i18n.yaml | 6 + docs/cordis-tutorial/05-config.md | 2 + docs/cordis-tutorial/05-config.zh.md | 84 ++++++++ .../06-composition-and-hmr.i18n.yaml | 6 + .../cordis-tutorial/06-composition-and-hmr.md | 2 + .../06-composition-and-hmr.zh.md | 113 ++++++++++ .../07-into-the-harness.i18n.yaml | 6 + docs/cordis-tutorial/07-into-the-harness.md | 2 + .../cordis-tutorial/07-into-the-harness.zh.md | 107 ++++++++++ docs/cordis-tutorial/index.i18n.yaml | 6 + docs/cordis-tutorial/index.md | 4 + docs/cordis-tutorial/index.zh.md | 58 ++++++ docs/core-data-structures/commands.i18n.yaml | 6 + docs/core-data-structures/commands.md | 2 + docs/core-data-structures/commands.zh.md | 86 ++++++++ docs/core-data-structures/goal.i18n.yaml | 6 + docs/core-data-structures/goal.md | 2 + docs/core-data-structures/goal.zh.md | 145 +++++++++++++ docs/core-data-structures/lsp.i18n.yaml | 6 + docs/core-data-structures/lsp.md | 2 + docs/core-data-structures/lsp.zh.md | 165 +++++++++++++++ docs/core-data-structures/pty.i18n.yaml | 6 + docs/core-data-structures/pty.md | 2 + docs/core-data-structures/pty.zh.md | 91 ++++++++ .../session-reference.i18n.yaml | 6 + .../core-data-structures/session-reference.md | 2 + .../session-reference.zh.md | 67 ++++++ .../session-title.i18n.yaml | 6 + docs/core-data-structures/session-title.md | 2 + docs/core-data-structures/session-title.zh.md | 142 +++++++++++++ docs/core-data-structures/spill.i18n.yaml | 6 + docs/core-data-structures/spill.md | 2 + docs/core-data-structures/spill.zh.md | 85 ++++++++ docs/core-data-structures/tasks.i18n.yaml | 6 + docs/core-data-structures/tasks.md | 2 + docs/core-data-structures/tasks.zh.md | 154 ++++++++++++++ .../token-meter.i18n.yaml | 6 + docs/core-data-structures/token-meter.md | 2 + docs/core-data-structures/token-meter.zh.md | 43 ++++ docs/web-styling.i18n.yaml | 6 + docs/web-styling.md | 166 +++++++-------- docs/web-styling.zh.md | 109 ++++++++++ 102 files changed, 3859 insertions(+), 82 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md create mode 100644 .agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-07-plan-mode.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-07-plan-mode.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md create mode 100644 .agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md create mode 100644 .agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md create mode 100644 .agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml create mode 100644 .agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md create mode 100644 docs/cookbook/maintaining-dsh-code-review.i18n.yaml create mode 100644 docs/cookbook/maintaining-dsh-code-review.zh.md create mode 100644 docs/cordis-tutorial/01-first-plugin.i18n.yaml create mode 100644 docs/cordis-tutorial/01-first-plugin.zh.md create mode 100644 docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml create mode 100644 docs/cordis-tutorial/02-lifecycle-and-effects.zh.md create mode 100644 docs/cordis-tutorial/03-services.i18n.yaml create mode 100644 docs/cordis-tutorial/03-services.zh.md create mode 100644 docs/cordis-tutorial/04-events.i18n.yaml create mode 100644 docs/cordis-tutorial/04-events.zh.md create mode 100644 docs/cordis-tutorial/05-config.i18n.yaml create mode 100644 docs/cordis-tutorial/05-config.zh.md create mode 100644 docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml create mode 100644 docs/cordis-tutorial/06-composition-and-hmr.zh.md create mode 100644 docs/cordis-tutorial/07-into-the-harness.i18n.yaml create mode 100644 docs/cordis-tutorial/07-into-the-harness.zh.md create mode 100644 docs/cordis-tutorial/index.i18n.yaml create mode 100644 docs/cordis-tutorial/index.zh.md create mode 100644 docs/core-data-structures/commands.i18n.yaml create mode 100644 docs/core-data-structures/commands.zh.md create mode 100644 docs/core-data-structures/goal.i18n.yaml create mode 100644 docs/core-data-structures/goal.zh.md create mode 100644 docs/core-data-structures/lsp.i18n.yaml create mode 100644 docs/core-data-structures/lsp.zh.md create mode 100644 docs/core-data-structures/pty.i18n.yaml create mode 100644 docs/core-data-structures/pty.zh.md create mode 100644 docs/core-data-structures/session-reference.i18n.yaml create mode 100644 docs/core-data-structures/session-reference.zh.md create mode 100644 docs/core-data-structures/session-title.i18n.yaml create mode 100644 docs/core-data-structures/session-title.zh.md create mode 100644 docs/core-data-structures/spill.i18n.yaml create mode 100644 docs/core-data-structures/spill.zh.md create mode 100644 docs/core-data-structures/tasks.i18n.yaml create mode 100644 docs/core-data-structures/tasks.zh.md create mode 100644 docs/core-data-structures/token-meter.i18n.yaml create mode 100644 docs/core-data-structures/token-meter.zh.md create mode 100644 docs/web-styling.i18n.yaml create mode 100644 docs/web-styling.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml new file mode 100644 index 0000000000..1c6f4e06ac --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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 +2026-06-21-bounded-llm-request-recovery.md: 22a56dc6d69340ca1b5f7b77edb4731066c9b2f5 +2026-06-21-bounded-llm-request-recovery.zh.md: 09ebce376a206591ac766067cc41497b74ed1545 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 932318da4f..22a56dc6d6 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md) + ## Problem `dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md new file mode 100644 index 0000000000..09ebce376a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -0,0 +1,148 @@ +# Agent Note: LLM(大语言模型)暂时性请求失败的有界恢复 + +Status: implemented + +[English](2026-06-21-bounded-llm-request-recovery.md) | 中文 + +## 问题 + +`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。默认决策为 `fail`;`dsh-compact-basic` 是唯一已交付的恢复监听器,它仅在压缩(compaction)证明持久表层已缩减后,才会对规范化的上下文窗口溢出进行重试。 + +该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号步骤。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 + +此前的边界还留有三个较窄的缺口。 + +- 提供方失败只保留消息,通常还会保留一个 code。HTTP 状态、重试延迟和提供方请求 id 会被丢弃,或者只能通过提供方专用错误对象恢复,因此通用恢复机制如果不解析文本,便无法作出决策或解释决策。 +- 重试的归属因适配器而异。手写 DeepSeek 适配器只尝试一次,pi-ai profile 则可以启用库内部的不透明重试。如果把隐藏的传输重试与 `agent/request-error` 监听器结合,尝试次数会成倍增加,中间失败也不会记入会话日志。 +- 恢复后的失败没有持久状态事实。失败的步骤和分片仍可重建,但观察者无法得知 agent(智能体)是否在有意退避、将等待多久,以及等待原因。长时间的静默等待看起来与循环停滞无异。 + +本决策的目标是从同一个显式提供方/模型请求的暂时性失败中进行有界恢复。提供方或模型故障转移、响应拼接和语义输出修复都属于其他问题,目前没有消费方。 + +## 决策 + +### 保留失败事实,不嵌入策略 + +`@deepseek-ai/dsh-llm` 导出唯一的可 JSON 序列化 `LlmFailure` 载荷: + +```ts ignore-check +type ProviderRequestId = Branded<'ProviderRequestId'> + +interface LlmFailure { + message: string + code: string + status?: number + providerRetryAfterMs?: number + requestId?: ProviderRequestId +} +``` + +`code` 仍是 `HarnessError` 建立的提供方无关机器路由分类体系;新字段是在提供方边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后为提供方发放的字符串。该载荷有意不包含 `retryable`、`failover`、`partialOutput`、提供方、模型、阶段或路由 id 字段。是否可重试属于策略,提供方/模型已位于持久请求头中,部分输出则从失败步骤的 `assistant/chunk` 事件派生。 + +`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。适配器抛出的 `Error` 保留其精确的对象标识:最终适配器 scope 在调用局部的伴随状态中把规范化事实与该对象关联,然后原样重新抛出;非 `Error` 抛出值则依旧被包装。`llmFailureOf(stream, error)` 会在现有来源检查旁取回这些事实,而没有错误对象的带内 finish 则会成为新的 `LlmError`。这既保留了按错误类型或标识分流的监听器,又使所有最终适配器失败(包括未知 SDK 异常)都获得 `UNKNOWN` 终止载荷。 + +agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误对象,并将 `LlmFailure` 作为独立参数传给 `agent/request-error`;它不会改动可能已冻结的第三方错误。在转换带内 finish 以及记录未恢复的 `turn/end.reason` 时,循环也会使用该载荷。 + +适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。 + +共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。 + +### 将重试策略放在现有失败步骤 seam 上 + +`@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。 + +`agent/request-error` seam 携带当前 `LlmFailure`,以及在这段连续恢复序列中导致再次请求的不可变先前失败列表。`dsh-llm-retry` 只计数 code 位于已配置暂时性集合中的先前失败,`dsh-compact-basic` 则只计数先前的上下文溢出失败。模型请求成功后会清空历史。因此,暂时性失败与上下文溢出交替出现时,两种策略会独立消耗各自预算;最大请求数等于 1 加上已加载恢复策略的有限预算总和。 + +该插件在加载时解析并验证以下部署配置: + +```ts ignore-check +interface Config { + maxTransientRetries?: number + initialDelayMs?: number + maxDelayMs?: number + jitterRatio?: number + retryableCodes?: string[] +} +``` + +默认值为两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT` 和 `EMPTY_RESPONSE`)。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。在没有其他恢复策略时,两次重试表示最多发起三次提供方请求。`maxTransientRetries` 是非负整数,延迟是正的有限数且满足 `initialDelayMs <= maxDelayMs`,`jitterRatio` 位于 `[0, 1]`,code 非空且不重复。这些都是 Cordis 配置字段,而不是隐藏常量,使部署能够选择不同的成本与延迟预算。 + +对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。 + +插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的退避回调。每次等待都会融合 waterfall(瀑布式事件)的轮次信号与该生命期信号。effect 清理会先注销监听器,再中止并等待活跃回调;被捕获回调的生命期信号中止时,回调会返回 `fail`,既不能重试,也不能在插件释放后进入其捕获 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 + +休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、从 1 开始的暂时性重试编号、已配置上限、计划延迟和 `LlmFailure`。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。 + +对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。只有在两个信号下完成延迟后,它才会返回 `{ action: 'retry' }`;轮次取消和插件释放会返回 `fail`,此后仍以循环的取消/释放检查为准。 + +agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一有界策略。库消费方仍需显式组合插件:省略该插件时,`agent/request-error` 保持现有的 fail 默认值。 + +### 由单一层负责可见的尝试 + +适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由一个已关闭的失败步骤加 `llm/retry` 表示。 + +`ctx.llm.stream()` 仍是原始的单次尝试 waterfall。压缩摘要等直接调用方会收到结构化失败,但不会自动获得重试,因为它们没有 agent 步骤边界,也没有可供分隔尝试的通用持久位置。未来的直接调用消费方可能会需要一个缓冲辅助函数,仅在尚未发出任何分片时重试;本决策不增加此类辅助函数。 + +### 在能够终止停滞流的位置施加边界 + +每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到下一条有效 `StreamChunk` 到达为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。 + +`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。 + +边界测试证明两个实际传输层都能终止。手写适配器会中止其 fetch/reader,pi-ai 适配器会把稳定信号映射到 SDK,并证明 SDK 会关闭响应。如果定时器只拒绝消费方 promise,却让请求继续运行,就不满足此契约。 + +### 在现有日志中分隔尝试 + +一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会开启下一个编号步骤,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录终止失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。 + +如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。 + +## 不在范围内 + +- 自动提供方或模型故障转移。请求已显式选择一个提供方和模型,提供方注册表也有意规定每个提供方只由一个适配器负责。 +- 在成功的终止性 finish 后重试或继续,或将两次尝试的分片拼接成一条 assistant 消息。 +- 修复格式错误的工具参数、拒答、内容过滤或其他语义模型输出。 +- 无界重试、无人值守地持续重试直至取消、熔断器、共享提供方健康状态或跨 agent 重试预算。 +- 在没有生产消费方的情况下,把 `llm/stream` 改造成响应生命周期或增加便利的生成 API。 + +## 考虑过的替代方案 + +- **在 `llm/stream` 或提供方 SDK 内部重试**:拒绝采用,因为原始流一旦发出分片便没有持久尝试边界,隐藏的 SDK 重试会成倍放大预算,而且两条路径都无法一致地记录每次失败尝试。 +- **向 `dsh-llm` 增加响应开始、中断、丢弃、失败和提交事件**:拒绝采用,因为 agent 日志已经分隔原始分片、成功消息和编号尝试。第二套状态机会重复归属关系,又不能支持有界的同路由重试。 +- **增加逻辑路由、能力矩阵和故障转移选择**:拒绝采用,因为当前请求已经显式指定提供方和模型,每个提供方由一个适配器负责,而且没有当前消费方要求自动回退或能够证明语义兼容性。 +- **把 `retryable` 或 `failover` 放在 `LlmFailure` 上**:拒绝采用,因为适配器报告事实,部署策略决定动作。同一个 429 可以在交互式组合包中重试,也可以在成本受限的批处理中被拒绝。 +- **只要调用方仍处于活跃状态就无限重试**:拒绝采用,因为这会让一次请求产生无界成本和延迟。可见状态能使有界等待易于理解,却不能让无限预算变得安全。 +- **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。 +- **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。 + +## 验证 + +- `LlmFailure` 是最终适配器抛出失败、错误 finish 和中止 finish 使用的唯一可序列化载荷;在可用时,规范化保留稳定 code、状态、重试延迟、品牌化的提供方请求 id、错误原因,以及调用方中止与适配器超时之间的分类。 +- 适配器抛出的 `Error` 会以完全相同的对象抵达 `agent/request-error`,其伴随的 `LlmFailure` 则抵达相邻参数;测试保留针对可扩展及冻结第三方错误的现有对象标识断言。 +- DeepSeek 和 pi-ai 适配器测试覆盖具有代表性的 400、401/403、429、5xx、连接、格式错误/截断流、超时、中止、Retry-After 秒数/日期、请求 id 和未知 SDK 错误路径,恢复策略无需解析消息文本。 +- Pi 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的实际网络请求;独立测试确保移除任一边界都会失败。 +- `agent/request-error` 携带当前失败事实以及不可变的先前已重试失败事实;成功会清除该历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 +- `dsh-llm-retry` 在 Loader 启动时验证每个配置字段,使用 `next()` 委托所有不合格路径,而且在没有其他策略时最多发起 `maxTransientRetries + 1` 次提供方请求。 +- 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发出重试决策,也不留下存活的定时器或 promise。 +- 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。 +- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新步骤中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 +- 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。 +- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 撤回和计划重试渲染。无密钥快照覆盖调度、取消、成功和耗尽;ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 +- 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 +- `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。 + +## 后果 + +- 每次暂时性恢复尝试都以一个已关闭步骤加 `llm/retry` 的形式可见,有界策略还会防止隐藏的 SDK 重试成倍增加成本。即使没有分片到达,重试仍可能造成提供方重复计费;有限的尝试预算只能限制而无法消除此风险。 +- 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。 +- 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。 +- 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。 +- 适配器局部的空闲强制机制可以终止停滞的传输,而不会计入消费方思考时间。每个传输边界的契约测试会防止 SDK 漂移。 +- 多个恢复插件会叠加各自的有限预算。此处它们的分类器互不重叠;重叠的分类器会形成依赖注册顺序的策略,必须由引入它们的插件记录并测试。 + +## 相关资料 + +- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md)负责稳定、可供机器路由的 code 与 cause chaining。 +- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)使提供方/模型和完整请求输入在分发前持久化。 +- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。 +- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)负责当前已关闭步骤的请求恢复 seam 与有界溢出重试。 +- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.i18n.yaml new file mode 100644 index 0000000000..108103c370 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.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 +2026-07-05-windows-fs-permissions.md: 243e264bab12e072a7b4ec6beddde1e05546ae7f +2026-07-05-windows-fs-permissions.zh.md: b88244e9f72f743cfd05a758368b40df053ee38d diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md b/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md index 932b6ddbf4..243e264bab 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-05-windows-fs-permissions.zh.md) + The replacement-file decision in this record is superseded by [Windows DACL preservation](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md). ## Problem diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.zh.md b/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.zh.md new file mode 100644 index 0000000000..b88244e9f7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Windows 写入权限语义:继承 DACL,而非权限模式位 + +Status: implemented + +[English](2026-07-05-windows-fs-permissions.md) | 中文 + +本记录中关于替换文件的决策已由 [Windows DACL 保留机制](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)取代。 + +## 问题 + +`writeFileAtomic` 在 `@deepseek-ai/dsh-fs-local` 中使用 POSIX 权限模式位保护正在写入的内容:以 `0o700` 创建暂存目录,以 `0o600` 打开临时文件,新文件也默认使用 `0o600`。在 POSIX 上,无论父目录的权限如何,这些设置都能保证临时内容仅对所有者可见。 + +Windows 在同一 API 背后没有可用的对等机制。Node 的 `chmod` 在 Windows 上只会驱动只读属性(此包传入的每种模式都包含所有者写权限,因此这些调用是无害的空操作),`stat().mode` 则报告合成的 `0o666`/`0o444` 权限位。真正的安全状态由文件的 DACL 决定:新建文件或目录会从父目录继承,替换操作则需要由取代本文的 Agent Note 所定义的显式处理。 + +## 决策 + +Windows 新建文件使用目录继承,而不使用合成的权限模式位:暂存目录在目标的父目录(`dirname(absolutePath)`)内创建,因此它和临时文件都会继承目标目录的 DACL。替换文件遵循更严格的 [DACL 保留契约](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)。 + +测试仅在 POSIX 上断言权限模式位。Windows 原生覆盖率锁定由本包(package)负责的替换行为;新文件继承仍属于操作系统契约,而不是针对特定机器的 ACL 允许清单。 + +## 备选方案 + +**为新文件显式设置仅所有者可用的 DACL。** 不予采纳,因为这会破坏继承,也会使特意共享项目目录的用户感到意外。替换写入会复制目标现有的 DACL,而不会自行设计仅所有者可用的策略。 + +**在测试中验证 ACL。** `Get-Acl` SID 允许清单或 `icacls` 验证的是 Windows 继承机制以及当前机器的 `%TEMP%` ACL,而非包的行为;`icacls` 还会对知名账户名进行本地化,导致解析容易受语言区域影响。 + +**在 Windows 上跳过 `chmod`。** 为无害的空操作调用增加平台守卫分支,不会改变任何行为。 + +## 后果 + +无论父目录的权限如何,POSIX 都会继续将临时内容限制为仅所有者可用。Windows 中的新目标如果位于广泛可访问的目录内,将按设计继承这种可访问性;如果替换目标存在更严格的 DACL,则会保留该 DACL。 + +在 Windows 上,替换时的模式保留会退化为空操作:可写文件的探测结果为 `0o666`,通过 `chmod` 重放该模式会使只读属性继续保持清除状态。由于发布操作会在合成模式发挥作用前失败,Windows 上无法替换只读目标。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml new file mode 100644 index 0000000000..4322906bb9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.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 +2026-07-05-windows-jsonl-durable-publish.md: 38c4adc7a4f85d45e53e70fcac84073ab4e50775 +2026-07-05-windows-jsonl-durable-publish.zh.md: 8dc77a0ab1b9273cf3f6ecb26916c7861ee81ec4 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md index 23e5f630d3..38c4adc7a4 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-05-windows-jsonl-durable-publish.zh.md) + ## Problem `dsh-session-persistence-jsonl` publishes a session log lazily on the first append. The POSIX protocol writes a temp file, fsyncs it, links it to the final name, fsyncs the parent directory, and then removes the temp link. The parent-directory fsync is part of the durability contract: a crash after the namespace change must not lose the committed final name while leaving callers believing the session log materialized. diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md new file mode 100644 index 0000000000..8dc77a0ab1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Windows 原生持久 JSONL 发布 + +Status: implemented + +[English](2026-07-05-windows-jsonl-durable-publish.md) | 中文 + +## 问题 + +`dsh-session-persistence-jsonl` 在首次追加时延迟发布会话日志。POSIX 协议会写入临时文件,对其执行 fsync,将其链接至最终名称,对父目录执行 fsync,然后移除临时链接。对父目录执行 fsync 是持久性契约的一部分:命名空间变更后发生崩溃时,已经提交的最终名称不能丢失,否则调用方会误以为会话日志已经物化。 + +Windows 具备原子命名空间操作,但 Node 没有暴露与 POSIX 等价的父目录 fsync 契约。如果把 Windows 目录同步失败视为成功,就会在无提示的情况下削弱持久化后端。因此,Windows 路径需要采用不同的发布原语,而不是在 POSIX 的 `syncDir` 辅助函数中添加条件分支。 + +## 决策 + +JSONL 后端会在 `materialize()` 内部、任何命名空间变更之前分流。共享代码计算会话目录、最终日志路径,以及编码后的 header 和初始事件批次;随后 POSIX 与 Windows 分别执行各自的发布协议。 + +POSIX 保留现有协议:创建根目录、项目目录与会话目录,并对其父目录执行 fsync;写入临时文件并对其执行 fsync;使用 `link()` 发布,确保绝不覆盖已有的最终日志;对会话目录执行 fsync;最后移除多余的临时硬链接。 + +Windows 通过持久的暂存发布来创建缺失目录:在固定的 `.dsh-mkdir-` 前缀下创建一个随机同级目录,其名称与目标基本名无关;随后使用 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 将其发布为最终目录名称,且不使用 `MOVEFILE_REPLACE_EXISTING` 或 `MOVEFILE_COPY_ALLOWED`。文件物化先写入临时日志并对其执行 fsync,再以同一个启用写穿透的 `MoveFileExW` 调用将临时文件发布到最终路径,并且同样不允许替换。`koffi` 是覆盖这组 API 所需的最小 Win32 桥接层;`pnpm-workspace.yaml` 允许执行它的安装脚本,因为该包(package)会分发原生 loader 和预构建的平台模块。 + +## 考虑过的替代方案 + +**忽略 Windows 目录同步失败。** 不予采纳,因为这会在没有强制将已发布的命名空间条目写入稳定存储时,就把首次追加报告为持久化成功。 + +**使用 `CreateHardLinkW`。** 不予采纳,因为硬链接依赖文件系统、不能发布目录,并且没有提供写穿透选项。 + +**使用替换或事务型 API。** `ReplaceFileW` 的替换语义与拒绝同一 id 冲突的要求相悖,而新应用设计不应使用 Transactional NTFS。 + +## 影响 + +该后端在各平台上维持同一项外部契约:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.md)会先作用于不透明字节,然后才由任一平台执行发布。 + +Windows 测试会在原生 Windows 上执行真实的 Win32 发布路径。断电行为属于 API 契约属性,单元测试无法证明;可测试的不变量包括:Windows 物化不会调用目录 fsync、最终路径冲突会失败、达到最大长度的目标路径组件仍可物化、临时日志在发布前已经执行 fsync,并且生成的日志可以正常加载。 + +两个平台的追加和修复仍使用普通文件句柄 fsync。追加失败后,系统会关闭仅追加句柄,以读写模式重新打开日志,将文件截断到追加前的大小,并对回滚结果执行 fsync,因为 Windows 不允许在仅追加句柄上调用 `ftruncate`。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml new file mode 100644 index 0000000000..6663606c3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.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 +2026-07-06-tool-result-retention-library.md: 5e42660360e5a23b419c75b9c8006bec459bc322 +2026-07-06-tool-result-retention-library.zh.md: 6e824667f17b361efb57b173c44f489da2cab3b3 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md index 2962f1006e..5e42660360 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-06-tool-result-retention-library.zh.md) + ## Problem Several model-facing tools already bound the amount of context they return, but each one owns a different local mechanism and vocabulary: bash keeps a tail plus spill files, web search caps source lists, web fetch caps body content, and `glob` / `grep` discovery needs an inline first page while keeping exact omission metadata for the full result set. A single `truncate(text)` helper cannot cover those cases: item tools need item counts and grouping outside the primitive, while text tools need byte budgets and UTF-8-safe head/tail cuts. diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md new file mode 100644 index 0000000000..6e824667f1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md @@ -0,0 +1,157 @@ +# Agent Note: 工具结果保留库 + +Status: implemented + +[English](2026-07-06-tool-result-retention-library.md) | 中文 + +## 问题 + +多个面向模型的工具已经限制其返回的上下文量,但每个工具都拥有不同的局部机制和词汇:bash 保留尾部并提供落盘文件;web search 限制来源列表;web fetch 限制正文内容;`glob`/`grep` 发现工具需要在行内提供第一页,同时为完整结果集保留精确的省略元数据。单一的 `truncate(text)` 辅助函数无法覆盖这些情况:条目型工具需要条目计数,并在原语之外分组;文本型工具则需要字节预算和 UTF-8 安全的首尾裁切。 + +这些工具需要共享的抽象是**保留**,而不是通用集合。调用方向一个有界对象输入条目或文本分片,稍后取得保留内容与精确的省略元数据。工具专用代码仍负责业务语义:文件分组、行号、退出码、提供方错误状态、落盘文件和面向模型的说明。公共库只负责一个机械问题:「保留了什么,又省略了什么?」 + +## 决策 + +`@deepseek-ai/dsh-retention` 位于 `packages/util/` 下,与 `dsh-brand` 和 `dsh-timeout` 同级,负责有界的模型可见输出。它是一组纯类与函数构成的库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何内容、不持有跨调用状态,也不发出事件。各工具包(package)需要限制输出时直接导入它。 + +该库包含两个相互独立的 retainer: + +- `ItemRetainer` 处理有序逻辑单元,例如路径、grep 匹配项或搜索来源。v1 只支持 `head` 保留,同时维持 retainer 形态,以便未来加入其他保留策略。 +- `TextRetainer` 处理面向字节的文本流,例如 bash stdout/stderr 或 web 响应正文。它支持 `head`、`tail` 和 `headTail` 保留,并在 `finish()` 时维持 UTF-8 边界。 + +两个 retainer 都会返回一个小型 `PushDecision`;每次调用 `push()` 后,调用方都能得知该单元/分片是否完整保留,以及累积结果此时是否已被截断。因为调用方会继续输入每一个已观察到的条目/分片,所以省略计数是精确的。 + +```ts ignore-check +/** + * How much content the retainer omitted. + * + * `unknown` is reserved for callers that omit without a count; the retainers + * themselves return `none` or `exact`. + */ +type Omitted = + | { kind: 'none' } + | { kind: 'exact'; count: number } + | { kind: 'unknown' } + +interface PushDecision { + kept: boolean + truncated: boolean +} + +/** + * Final result for ordered logical units. + */ +interface RetainedItems { + items: T[] + truncated: boolean + seen: number + kept: number + omitted: Omitted +} + +/** + * Final result for text streams. + * + * The returned `text` is safe to send to a formatter; the retainer does not add + * tool-specific headers, exit markers, XML tags, or recovery instructions. + */ +interface RetainedText { + text: string + truncated: boolean + omittedBytes: Omitted +} +``` + +### 策略 + +条目保留支持头部窗口。文本保留支持头部、尾部与首尾字节窗口。 + +```ts ignore-check +type ItemRetentionStrategy = + | { + /** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */ + kind: 'head' + maxItems: number + } + +type TextRetentionStrategy = + | { + /** Keep the first `maxBytes` bytes. */ + kind: 'head' + maxBytes: number + } + | { + /** Keep the final `maxBytes` bytes. Requires reading to the end. */ + kind: 'tail' + maxBytes: number + } + | { + /** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */ + kind: 'headTail' + headBytes: number + tailBytes: number + } +``` + +### 工具映射 + +`read` 被有意排除在 v1 保留库之外。它的 `read-render` 辅助函数拥有文件专用的分页契约:`offset`/`limit`、行号、`totalLines`、offset 越界错误、逐行预览截断,以及能够在窗口中途停止扫描的所选输出字节上限。这是行窗口渲染器,不是通用保留原语。它未来可以共享中性的提示辅助函数,但不应把已经选定的窗口再传入 `ItemRetainer`。 + +下文的 `FsGlobEntry` 与 `FlatGrepMatch` 是预期由发现工具使用的条目形态,不是现有保留库的导出。`FsGlobEntry` 是一个由后端派生的路径;`FlatGrepMatch` 是后端将保留匹配项按文件分组之前的一条未分组 grep 匹配。 + +`glob` 收集完整的排序路径列表后,使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: globMaxResults }`。工具在行内保留第一页,并可以通过落盘 seam 保存完整列表。路径映射、跳过的候选项与 `incomplete` 均位于 retainer 之外。 + +`grep` 在分组前使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: grepMaxMatches }`。执行器解析 ripgrep 输出、映射路径、应用逐行预览截断,并输入扁平匹配项。调用 `finish()` 后,工具按文件对保留的匹配项分组;如果行内结果达到上限,还可以通过落盘 seam 保存完整匹配列表。分组不属于 retainer,因为上限针对匹配总数,而不是文件数;逐匹配项的预览截断和 `incomplete` 也与结果级保留相互独立。 + +`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责落盘文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.md)相互独立。 + +`web_fetch` 可以使用 `TextRetainer`,配置为 `head` 或 `headTail`;如果提供方必须在内部读取和解码,也可以保留由提供方负责的正文上限。无论采用哪种方式,fetch 结果中的 `truncated` 仍是提供方/工具事实,该库只提供保留文本与省略元数据。 + +`web_search` 可以使用 `ItemRetainer`,配置为 `head`。当前提供方通常返回数组,所以这属于事后处理,但仍能统一提示信息。 + +### 提示 + +该库公开一个中性的提示结构和一个小型格式化钩子,但面向用户的措辞由工具提供。grep 页脚会提示「缩小 pattern、path 或 include」;web fetch 页脚会提示「获取更具体的 URL 或章节」;bash 则可以指向落盘文件。retainer 无法得知这些恢复操作。 + +```ts ignore-check +interface RetentionNotice { + scope: string + strategy: 'head' | 'tail' | 'headTail' + unit: 'items' | 'bytes' | 'chars' | 'lines' + limit: number | { head: number; tail: number } + kept: number + omitted: Omitted +} + +const formatGrepNotice = (notice: RetentionNotice): string => + formatRetentionNotice( + notice, + ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`, + ) +``` + +格式化钩子刻意保持精简:工具把 `RetentionNotice` 转换为自己的页脚文本。辅助函数可以统一省略措辞,但不负责恢复指引。 + +`truncated` 表示 retainer 因预算省略了原本可用的内容,不表示上游结果不完整。工具会为权限失败、跳过的二进制文件、提供方局部失败、不可读候选项、无效 UTF-8,以及其他任何「无法检查」状况保留独立字段。 + +## 影响 + +**已交付内容。** `@deepseek-ai/dsh-retention` 导出 `ItemRetainer`、`TextRetainer`、结果类型(`RetainedItems`、`RetainedText`)、策略类型(`ItemRetentionStrategy`、`TextRetentionStrategy`)、`Omitted`、`PushDecision`、`RetentionNotice`,以及中性的提示辅助函数 `describeOmitted`/`formatRetentionNotice`,且不依赖 Cordis 或任何工具包。单元测试覆盖具有精确省略计数的条目头部保留、文本头部保留、文本尾部保留、首尾字节保留、零预算、UTF-8 边界处理(2、3、4 字节码位,以及每个裁切位置上的无效起始字节)和未知省略量的措辞。 + +**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/retention/README.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口契约(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。 + +**该库维持的边界。** `truncated` 表示 retainer 因预算省略了原本可用的内容,绝不表示上游不完整。工具专用状态,包括 `incomplete`、权限失败、提供方局部失败、跳过二进制文件、bash 落盘路径恢复和无效 UTF-8,均留在工具领域字段中、位于 retainer 之外。未来改动迁移某项工具时,该包的 README 与测试必须证明,除了有意改变的提示措辞外,模型可见的结果文本没有变化。 + +**接受的取舍。** v1 接口刻意只支持条目的 `head` 保留,以及文本的 `head`/`tail`/`headTail` 保留;窗口、分组预算、感知排序的上限和上游停止控制,要等第二个消费方证明需求后再引入。文本保留按字节计数,以保障进程/正文安全;字符级和行级预览预算继续由具体工具负责。 + +## 考虑过的替代方案 + +**只进行事后 `truncate(text)`。** 不予采纳:它适合 Codex 的历史/工具输出截断场景,却会丢失条目计数、分组边界、UTF-8 安全的字节窗口与精确省略元数据。 + +**使用一个带可插拔回调的通用 `Collector`。** v1 不予采纳,因为它会掩盖两种重要的资源模式。逻辑条目保留按条目计数;文本保留按字节计数并维持 UTF-8 边界。独立的 `ItemRetainer` 与 `TextRetainer` 名称明确表达这种差异,同时保持 API 精简。 + +**把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`read` 是当前唯一的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。 + +**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer 使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.md)可以保留完整的已采集结果。 + +**在每个面向模型的工具 schema 中公开上限。** 不作为默认方案:Claude Code 的 grep 公开 `head_limit`/`offset`,但本 harness 会把常规预算保留为部署配置,除非模型确实需要控制分页。未来可以为具体工具增加类似 read 的续传字段;它不属于共享保留原语。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml new file mode 100644 index 0000000000..c241ef4ece --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.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 +2026-07-08-tool-output-spill-files.md: 7c0ca90452645d251559be25108d12883210d00e +2026-07-08-tool-output-spill-files.zh.md: 917d710eb8650e2797287578edd1b0d62813bbd3 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index e2df9a902b..7c0ca90452 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-08-tool-output-spill-files.zh.md) + ## Problem Tool outputs need bounded model-facing previews, but some oversized results are still useful later. A fetched page body or a verbose tool response should not consume the next model request in full, but the model should be able to inspect the complete formatted result later with existing file-reading tools. diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md new file mode 100644 index 0000000000..917d710eb8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -0,0 +1,195 @@ +# Agent Note: 工具输出落盘策略 + +Status: implemented + +[English](2026-07-08-tool-output-spill-files.md) | 中文 + +## 问题 + +工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。 + +这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时落盘文件;普通文本工具结果则仍以内联形式返回,除非工具自行临时实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 + +其形态与超时策略设计一致:工具作者声明规范值与 Native renderer,由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前落盘;由工具负责的展示落盘可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出契约](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。 + +## 决策 + +在新的 `packages/spill/` 分组下增加一层轻量落盘存储 seam 和一个默认落盘策略插件: + +| 包(package) | 角色 | +|---|---| +| `@deepseek-ai/dsh-spill` | 接口:`ctx.spillStore`、词汇类型,不包含存储实现。 | +| `@deepseek-ai/dsh-spill-local` | 本地后端:在宿主文件系统中提供私有、会话作用域的文件存储。 | +| `@deepseek-ai/dsh-spill-policy` | 工具结果策略插件:包装分发后的最终文本结果,并以保留预览和落盘定位符替换超大结果。 | + +系统不增加专用的面向模型消费方包。消费方是现有 `ctx.tools` 执行流水线:`dsh-spill-policy` 通过 `tools/post-execute` waterfall(瀑布式事件)使用最终工具结果,模型则按照后端随定位符返回的检索提示读取内容。 + +### 落盘 seam + +存储 seam 保持最小化:保存文本,并返回定位符与检索提示。 + +```ts ignore-check +interface SpillStore { + saveText(input: SaveTextSpill): Promise +} + +interface SpillSource { + toolName: string + callId: CallId + label: string +} + +interface SaveTextSpill { + owner: { sessionId: SessionId } + source: SpillSource + suggestedName: string + content: string +} + +type SpillLocator = Branded<'SpillLocator'> + +interface SpillRef { + locator: SpillLocator + bytes: number + retrievalHint: string +} +``` + +`SpillLocator` 是一个[品牌化的](../../../../packages/util/brand)模型可见句柄,由后端返回。本地后端将其渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方把它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。`SpillOwner.sessionId` 是保存时的存储命名空间:fork 后的会话会从种子日志继承已有的落盘定位符,无需复制它们或重新取得所有权;fork 后的新落盘使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;落盘 seam 不定义逐会话的清理策略。 + +`dsh-spill-local` 只负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入,以及返回 `{ locator, bytes, retrievalHint }`。它不负责保留策略、工具结果替换、搜索或文件检查。文件写入 `/session-/-`:`root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。 + +### 落盘策略 + +`dsh-spill-policy` 是一个 `tools/post-execute` 结果转换器,只提供一个配置项: + +```ts ignore-check +interface Config { + /** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */ + maxInlineBytes?: number +} +``` + +省略 `maxInlineBytes` 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略: + +1. 让工具正常运行,通过 `next()` 委托,使下游监听器先结算结果。 +2. 仅当已接受的最终 `ContentBlock[]` 全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。 +3. 如果 UTF-8 字节大小不超过 `maxInlineBytes`,保持不变。 +4. 如果超出上限,使用完整的最终文本调用 `ctx.spillStore.saveText()`。 +5. 把模型可见结果替换为保留的首尾预览和落盘引用。 + +预览属于策略所有的实现默认值:以 `maxInlineBytes` 为上限,使用保留库的 `TextRetainer` 进行首尾分割。只有第二种部署证明有此需求后,未来配置才会公开预览大小。 + +替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源: + +```text + + +(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.) +``` + +如果 `ctx.spillStore.saveText()` 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。落盘失败绝不会把成功的工具调用变为 `isError` 结果,也不会隐藏内联结果。 + +策略跳过 `read`,以避免形成 `read -> spill file -> read again` 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。 + +## 示例:web_fetch + +`web_fetch` 是首个示例,因为它天然会返回较大的文本结果,而且无需工具专用的落盘代码。该工具本身无需特殊处理: + +```ts ignore-check +ctx.tools.register(defineTool({ + name: 'web_fetch', + output: { + schema: WEB_FETCH_RESULT_SCHEMA, + render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }], + }, + async execute(args, exec) { + const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined) + return result + }, +})) +``` + +配置 `dsh-spill-policy` 后,格式化后的大型 fetch 结果会自动保留并落盘。部署通过把提供方资源上限设得高于策略上限来展示此行为: + +```yaml +- id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + config: + maxBodyChars: 500000 + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 +``` + +这项分离很重要。`web-fetch-local` 仍负责资源上限(`maxResponseBytes`、`maxBodyChars`),用来保护网络、内存和解码工作。`spill-policy` 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 `truncated: true`,落盘文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。 + +## 与保留和提前落盘的关系 + +保留与落盘存储相互独立: + +- `@deepseek-ai/dsh-retention` 负责预览机制(`TextRetainer`、`ItemRetainer` 和省略元数据)。 +- `@deepseek-ai/dsh-spill` 负责保存最终文本,并返回定位符与检索提示。 +- `@deepseek-ai/dsh-spill-policy` 在工具流水线中应用默认的最终结果策略,将前两者组合起来。 + +最终结果策略不能取代由工具负责的提前落盘。部分有用内容并不存在于最终 `ToolExecutionResult.content` 中: + +- `bash` 的最终输出已经是尾部内容加临时落盘路径;完整的 stdout/stderr 流位于执行器文件中。 +- `subagent` 的最终输出是子 agent(智能体)的最终回答,而不是子 agent 的执行轨迹。 +- 未来的工具可能生成从未出现在最终 `ToolExecutionResult.content` 中的运行时产物。 + +这些场景可以在后续工作中直接使用 `ctx.spillStore`,不属于首个示例的范围。 + +## 非目标 + +- v1 不增加面向模型的 `artifact_read` 或 `artifact_search` 工具。 +- v1 不增加逐工具的保留配置。 +- 不增加面向模型的超时/截断参数。 +- 不把 `read` 输出迁移到落盘文件。 +- 不取代 `web-fetch-local.maxBodyChars` 等提供方/资源上限。 +- 第一版不统一 bash 临时文件,也不采集 subagent 执行轨迹。 + +## 延后事项 + +- 用于现有执行器落盘文件的 `saveFile()`/`linkOrCopy`,这是统一 bash 行为所必需的。 +- 由工具负责的 subagent 执行轨迹落盘(`await run.result`,在 `run.dispose()` 前读取进程内子会话,保存 JSONL)。 +- 如果内置的 `read` 跳过规则不足,再增加逐工具退出或逐工具策略声明。 +- 面向 ACP(Agent Client Protocol)或远程环境的远程/数据库存储后端,因为本地路径在这些环境中没有意义。 +- 旧落盘文件的清理和保留策略,很可能与会话清理绑定。 + +## 测试 + +- `dsh-spill` 单元测试锁定 seam 契约:注册为 `ctx.spillStore`、每个上下文只允许一种实现,并在 dispose(资源释放)时释放。 +- `dsh-spill-local` 单元测试覆盖 `saveText`、`encodeSegment` 清理(分隔符/波浪号/完整路径段的点/空值)、会话哈希目录、仅所有者权限、每次保存生成不同路径、配置根目录/私有根目录,以及存储失败时的拒绝。 +- `dsh-spill-policy` 单元测试通过 `ctx.tools.execute` 驱动真实工具:禁用模式下无操作、替换超大文本、小结果/非文本结果保持不变、跳过 `read`、尽力回退(保存失败/无后端/无所有者),以及下游组合(限制已替换结果、保留 `additionalContexts`)。 +- `dsh-tool-web` 集成测试驱动 `web_fetch`,其实际执行路径经过 `ctx.tools.execute`,并使用真实的 `spill-local` 后端与策略;测试证明只有刻意加入的落盘提示会改变模型可见文本,而落盘文件保存完整的格式化结果。 +- `tui-agent` 示例加载 `spill-local` 与 `spill-policy`,因此其无密钥 Loader/PTY 冒烟测试会执行真实加载路径(namespace-plugin 导出形态与 `inject`)。 + +## 影响 + +默认策略只能看见最终格式化文本。它无法保留已经由提供方限制的内部内容,也无法保留从未成为结果一部分的运行时产物。第一版聚焦最终结果落盘而不是提前落盘,因此可以接受这一限制;由工具负责的提前落盘仍属于后续工作。 + +本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent 工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 + +本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使落盘目录位于会话 cwd 之外。目前这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地落盘路径,或改用检索提示指向受支持读取器的非文件落盘后端。 + +**快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` 落盘提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` 落盘需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。 + +如果策略开始负责工具专用语义,就会膨胀得过大。它必须保持狭窄:只处理纯文本最终结果。由工具负责的提前落盘仍留作未来工作。 + +## 考虑过的替代方案 + +**要求每个工具通过保留声明选择加入。** v1 不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。 + +**把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前落盘合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。 + +**使用 `ctx.fs.writeText` 或面向模型的 `write` 工具。** 不予采纳:工作区文件系统写入带有项目文件语义、写入/编辑策略、观察状态和面向用户的副作用。落盘文件是运行时产物,不是由模型编写的工作区改动。现有 `read` 工具之后可以检查它们,但创建操作属于运行时落盘 seam。 + +**让 `web-fetch-local` 不受限地抓取,只依靠 spill-policy。** 不予采纳:spill-policy 在最终工具结果已经存在之后才运行,无法保护网络、内存或解码资源。提供方资源上限仍然必须存在。 + +**把保留合并进落盘机制。** 不予采纳:保留与落盘职责不同。`TextRetainer`/`ItemRetainer` 决定保留哪部分预览、又省略了什么;落盘存储只负责保存策略要求的最终文本。 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml new file mode 100644 index 0000000000..e8e4c59638 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.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 +2026-06-24-workspace-context.md: 6acdb6241bcc57250e217cfc8856e8b0598d4622 +2026-06-24-workspace-context.zh.md: f165d08108931df21697c7895523f10ef816297f diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 394c0de708..6acdb6241b 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-24-workspace-context.zh.md) + ## Problem Repository guidance such as `AGENTS.md` belongs in a coding session's effective context so project conventions, build commands, and review rules arrive without repeated user pasting. The stdio and ACP products need the same behavior, isolated by session cwd: a global system-prompt section leaks one workspace's files into another live ACP session. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md new file mode 100644 index 0000000000..f165d08108 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -0,0 +1,89 @@ +# Agent Note: 工作区上下文指令文件 + +Status: implemented + +[English](2026-06-24-workspace-context.md) | 中文 + +## 问题 + +`AGENTS.md` 等仓库指引应当进入编码会话的有效上下文,使项目约定、构建命令和评审规则无需由用户反复粘贴即可生效。stdio 与 ACP(Agent Client Protocol)产品需要具备相同行为,并按会话 cwd 隔离:全局系统提示词章节会把一个工作区的文件泄漏到另一个仍在运行的 ACP 会话中。 + +相邻产品形成了值得借鉴的约定,但具体做法各不相同。Codex 原生使用 `AGENTS.md`;Claude Code 使用 `CLAUDE.md`,并采用熟悉的 system-reminder 风格用户上下文;opencode 同时支持这两个名称,每个目录只选一个胜出者,并延迟发现嵌套文件。harness 需要跨工具兼容,同时避免从同一作用域加载重复或互相矛盾的文件。 + +生命周期中有两类截然不同的内容。初始适用文件链足够稳定,可以放入请求前缀并受益于提供方前缀缓存。嵌套文件、编辑、候选项切换和移除都发生在会话启动后,应进入持久的仅追加历史,而不是被冻结的前缀。 + +## 决策 + +该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/session-prefix`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 + +插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。会话前缀信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 + +### 文件名与优先级 + +默认的逐目录候选列表是 `['AGENTS.md', 'CLAUDE.md']`。该列表可通过 `instructionFileCandidates` 配置;`AGENTS.md` 是普通的第一候选项,而不是隐藏优先级。一个目录中只加载第一个存在的普通文件候选项。使用默认值时,`AGENTS.md` 是原生文件,`CLAUDE.md` 是兼容性回退。第二个列表 `localInstructionFileCandidates`(默认为 `['AGENTS.local.md', 'CLAUDE.local.md']`)会在同一目录的基础文件后加载叠加式本地覆盖层;[默认本地覆盖层记录](2026-07-21-local-instruction-overlay.md)负责说明该决策。 + +候选条目必须是同一目录中的文件名。空条目、`.`/`..`,以及包含 `/` 或 `\` 的条目会被忽略。其他同目录名称可以显式选择加入;规则目录和导入语义不属于本契约。 + +用户全局文件固定为 `$DSH_HOME/AGENTS.md`,不受任一候选列表影响,也没有本地覆盖层。`$DSH_HOME` 默认为 `~/.dsh`,与 `~/.codex` 或 `~/.claude` 在 harness 层的 home 角色一致,而不会引入插件专用 home。波浪号展开与默认值位于 `dsh-paths` 中,以便未来的 harness 功能共享同一约定。 + +### 基线前缀 + +agent loop(智能体循环)实例的第一次请求会让插件通过 `agent/session-prefix` 提供一条 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 + +插件会在 `await next()` 返回前前置其贡献,因此会话前缀贡献按插件注册顺序出现。在产品主干中,工作区指令的注册先于 skill 目录,所以它排在前面。循环会深度冻结组合后的前缀,将其记录在 `EpochHeader.messagePrefix` 中,并在该实例内逐字复用。它是请求状态,不是 `Session.deriveMessages()` 历史。 + +恢复 agent 会创建新的循环实例,并使用当前文件重新组合基线;新的前缀由恢复请求 header 锚定。这样,恢复时可以使用当前基线内容,而无需修改先前实例已经使用过的前缀。 + +基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 + +### 动态发现与刷新 + +第一方 `read`、`write` 或 `edit` 调用成功后,`tools/post-execute` 监听器会协调被触及的后代路径链,以及该会话已经知道的每个作用域。新到达的作用域通过 `additionalContexts` 返回,并在下一次请求中使用 `Additional instructions from: ` system-reminder。在 Code Mode 下,`run_code` 会把子分发上下文延后至其外层结果,因此同一更新只会在父结果之后追加,而不会在调用中途注入。 + +内容编辑会追加 `Updated instructions from: `,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: `,并说明先前加载的指令不再适用。 + +动态消息在 `content` 中携带完整的 system-reminder 框架;每个 `context/message` 都作为 user 角色消息逐字抵达模型,核心层不会再添加可选择退出的包装。`context/message.meta` 携带不透明 JSON 状态,该状态会持久化,但绝不会渲染给模型。 + +shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,而从任意命令字符串推断已到达路径,需要实现提示词插件并不拥有的 shell 语义。 + +### 重复抑制与变更检测 + +每个动态工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。 + +协调时,插件扫描自身拥有的 `context/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `context/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 + +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩(compaction)从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 + +被冻结的基线会保留一个内存中的 path/digest map 以供比较。后续成功的文件系统触碰会把基线编辑或移除操作追加为动态消息,绝不重写前缀。恢复时重新组合前缀的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 + +系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的前缀组合。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 + +### 字节预算与有界读取 + +`maxBytes` 是必填项,分别作用于渲染后的基线或单个动态协调批次;系统不存在隐式或无界的渲染预算。非正数或非有限值会禁用加载。内容超过预算时,系统会先省略较宽泛的文件,再截断最具体的文件。可见的 `Workspace instruction budget ...` 提示会指出被省略和截断的路径与字节数,并且输出绝不超过配置字节数。 + +`maxSourceBytes` 是正数的逐文件上限,默认为 1 MiB。loader 会在读取前检查报告的大小,同时仍通过 `streamText()` 消费内容并持续统计 UTF-8 字节数,因此缺失/陈旧的元数据无法迫使其进行无界分配。过大的胜出候选项会被视为不可用,而不是改为同目录中的下一个名称。插件刻意不保留进程级缓存,也绝不保留指令正文。它只为每个有效作用域保存 `{ path, version, digest }`,并将这些状态放在 `WeakMap>` 中:提供方 `FsVersion` 与有效提示词状态同时匹配时跳过读取;版本变化则触发有界读取和 SHA-1 确认。SHA-1 仍是持久化在可见结构化元数据中的跨提供方内容标识;提供方版本只作为内存中的失效快速路径。模型可见变更的缓存转换只有在相应上下文通过完整的工具结果策略链后才会提交;如果该已接受上下文随后与中止步骤一起被丢弃、未能进入日志,缓存转换就会失效。 + +## 考虑过的替代方案 + +**使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。 + +**在每次 `agent/pre-step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token、使重复状态复杂化,并妨碍提供方前缀保持结构稳定。前缀组合提供冻结、已记录且逐实例的基线,动态仅追加消息则负责变更。 + +**在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。 + +**解析渲染后的标题或隐藏注释以恢复已加载状态。** 不予采纳,因为指令正文可能包含相同文本,导致无提示的误报。持久化 JSON 元数据提供明确且对模型不可见的状态通道。 + +**使用模型总结文件。** 不予采纳,因为指令文件本身已经是经过整理的摘要;再执行一次模型调用既不确定,也可能抹掉边界情况要求。使用带字节预算的确定性全文更简单。 + +## 影响 + +工作区指引按会话隔离,并由 demo 前端、Web Host 与每一种工具展示模式共享。初始指令受益于稳定的前缀缓存,嵌套与变更内容则保持持久且可回放。通用的 session/agent 上下文契约通过 prompt-submit 与工具执行后的 `additionalContexts` 数组携带 JSON 元数据,而不会把条目展平。 + +仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 + +系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;外部文件变更会在下一次成功的结构化触碰或恢复时被发现。这使设计保持确定性并且与提供方无关。 + +## 延后事项 + +从 bash 派生路径报告、递归启动扫描、文件监视器、小写默认名称、`.claude/CLAUDE.md`、`.claude/rules/*.md`、导入指令、ACP `additionalDirectories`、信任确认和模型生成摘要均延后处理。项目目录中的 `.local.` 覆盖层现已默认加载([默认本地覆盖层记录](2026-07-21-local-instruction-overlay.md)负责说明该决策);用户全局覆盖层、目录规则系统和导入仍需要各自的优先级与信任设计。 diff --git a/.agents/notes/implemented/feature/2026-07-07-plan-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-plan-mode.i18n.yaml new file mode 100644 index 0000000000..4ceac02ce9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-07-plan-mode.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 +2026-07-07-plan-mode.md: 4265ecdb9fcc022e8b9117ec18f46b9093ce99b8 +2026-07-07-plan-mode.zh.md: f9a734af87e34e946b94da8d7aa656dde78269ec diff --git a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md b/.agents/notes/implemented/feature/2026-07-07-plan-mode.md index f5b76fae5b..4265ecdb9f 100644 --- a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md +++ b/.agents/notes/implemented/feature/2026-07-07-plan-mode.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-07-plan-mode.zh.md) + > **Superseded vocabulary (2026-07-22):** [Collapse named session modes into plan mode](../simplification/2026-07-22-plan-specific-collaboration-state.md) replaces this note's generic `dsh-mode`, `mode/set`, definition map, and `ctx.modes` design with the current plan-specific `dsh-plan-mode`, `plan/mode`, `{ section }`, and `ctx.planMode` contract. The review, boundary, reconstructability, and sandbox-orthogonality decisions below remain in force; generic API examples are retained as the historical design this simplification removed. > **Superseded ACP mapping:** [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) removes the picker, config-option, and elicitation mappings described below. Plan mode remains available to human-facing interfaces. diff --git a/.agents/notes/implemented/feature/2026-07-07-plan-mode.zh.md b/.agents/notes/implemented/feature/2026-07-07-plan-mode.zh.md new file mode 100644 index 0000000000..f9a734af87 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-07-plan-mode.zh.md @@ -0,0 +1,196 @@ +# Agent Note: plan mode——记录到日志的逐 agent 会话模式 + +Status: implemented + +[English](2026-07-07-plan-mode.md) | 中文 + +> **已取代的词汇(2026-07-22):**[将具名会话模式收敛为 plan mode](../simplification/2026-07-22-plan-specific-collaboration-state.md) 已将本笔记中通用的 `dsh-mode`、`mode/set`、定义 map 与 `ctx.modes` 设计,替换为当前 plan 专用的 `dsh-plan-mode`、`plan/mode`、`{ section }` 和 `ctx.planMode` 契约。下文的评审、边界、可重建性与沙箱正交性决策仍然有效;通用 API 示例则作为此次简化所移除的历史设计保留下来。 + +> **已取代的 ACP(Agent Client Protocol)映射:**[ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)移除了下文所述的选择器、配置选项和 elicitation 映射。面向人类的接口仍可使用 plan mode。 + +## 问题 + +此次变更之前,harness 无法持久地让某个 agent(智能体)采用独特的工作姿态。Plan mode 要求 agent 在规划指引下探索和设计,产出可供评审的产物,跨过明确的审批边界,并在恢复与 fork 后还原该状态,同时不能让模型可见请求偏离会话日志。 + +既有扩展 seam 已经提供了周边机制:[`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) 为每个步骤塑造指引,已发送的请求则记录在 `request/header*` 事件中(参见[可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.md));[`ctx.userInteraction`](../../../../packages/ui/user-interaction/README.md) 承载审批问题与纠正反馈(参见 [ask-user 先例](../../implemented/feature/2026-06-25-ask-user-question.md));`SessionEventMap` 承载逐 agent 的持久事实(参见 [`todo/write` 先例](../../implemented/feature/2026-06-29-todo-write-tool.md))。缺少的是将这些 seam 连在一起的具名会话状态,同时仍让独立的沙箱轴与审批轴负责执行约束。 + +## 决策 + +交付项是 **plan mode**。它作为首个**会话模式**发布,即一个具名、记录到日志且逐 agent 生效的协作状态:模式定义是由部署配置、供模型查看的指引;对某个 agent 生效的模式则是从其日志折叠出的会话状态。模式构成一条轴,强制约束旋钮——沙箱模式与审批策略——构成其他轴;它们从不互相读写,这与 Codex 将 Plan/Default 协作预设同沙箱及审批设置分开的做法一致。新的产品包(package)`@deepseek-ai/dsh-mode` 位于 `packages/mode/mode/`,拥有事件词汇、精简的 `ctx.modes` 服务和全部监听器;循环无需改动。`plan` 是唯一的必需定义;采用模式形状的词汇,是为了以后增加第二种模式时无需重命名持久事件类型,而不是因为当前还会发布其他模式。 + +该状态是 `SessionEventMap` 的一个成员:**`mode/set`** 是只记录日志、不进入 surface 的事件,携带具有整值替换语义的 `{ mode: string }`;另有纯函数 `foldMode(events)` 返回生效模式,即最后一个 `mode/set`,没有该事件时则返回默认模式。由于[日志是事实通道](../../implemented/architecture/2026-06-30-event-domain-semantics.md),恢复、fork 和压缩无需额外机制即可还原模式,UI 则从 `session/event` 读取模式切换。默认模式表示不存在模式指引,即没有段落、过滤或门禁。加载 `dsh-mode` 后,每种模式仍会贡献同一个稳定的 `exit_plan_mode` schema;这项固定成本避免了模式边界处的工具目录抖动。 + +模式的所有外显行为都是软约束:`mode:policy` 提示词段落渲染当前定义的指引,而 `exit_plan_mode` 在每种模式下都留在已注册的工具目录中,仅当折叠模式不是 `plan` 时才在执行阶段拒绝。因此,转换只会在下一步骤改变可归因 `request/header` 中的系统提示词部分,从而在不改变 Native schema 或 Code Mode SDK 的情况下继续满足[可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.md)。模式有意不强制执行任何约束:没有执行门禁,不过滤工具,也不触及沙箱或审批旋钮。若用户希望规划期间存在硬性的只读下限,可以在模式选择器旁切换沙箱模式选项;二者先后顺序任意,任何一条轴都不会扰动另一条轴。同样也不存在逐模式的工具允许/拒绝清单;模式允许哪些工具属于副作用问题,在工具定义能够声明自身副作用前暂缓处理(见[延期工作](#deferred))。模式只依靠其段落指引与退出评审来约束行为。 + +模型通过 **`exit_plan_mode`** 工具离开 plan mode。其唯一参数是 plan 文本,因此可以从日志重建 plan;该工具自行通过用户交互 seam 完成评审:问题的辅助详情携带确切 plan,并提供选项与自由文本通道,而不是只有一项裸权限。审批通过后,记录到日志的模式切回默认模式;拒绝则成为携带用户逐字反馈的纠正错误,让模型能沿明确方向继续规划。用户可从任意接口通过 `ctx.modes.set()` 切换模式;切换会在下一个轮次边界应用(会话事件都封闭在轮次内),且只有模型可见状态确实变化时才向模型讲述一次。 + +## 高层 API + +### 一次端到端的 plan-mode 会话 + +用户通过 ACP 模式选择器或终端入口中的 `/plan [message]` 将会话切换到 plan mode;从下一步骤开始,每个请求都携带已配置的 plan 指引段落。如果给出可选消息,同一命令还会把它提交到受影响的步骤中。`exit_plan_mode` schema 在默认模式下已经存在,并会保持逐字节不变。 + +模型进行探索与设计;段落中的指引会让它把变更推迟到 plan 中。沙箱与审批旋钮保持用户设置的值不变;希望规划期间由内核强制只读的部署方(或用户),可以把 plan mode 与独立的沙箱模式选项配合使用。 + +准备就绪后,模型调用 `exit_plan_mode`,并把 plan markdown 作为参数;评审问题将这段确切 markdown 作为辅助详情,用户可以批准,也可以要求继续规划并自由填写反馈。Native 调用还会渲染 plan 卡片;Code Mode 嵌套分发没有 Native 卡片,因此评审详情是共用的呈现接口。 + +批准后,工具把记录到日志的模式切回默认模式:下一步骤会移除 plan 段落,但保留同一个工具目录(变化后的 header 已记录到日志),此后的执行跟踪本就由 `todo_write` 负责。要求继续规划时,模型会收到携带用户反馈文本的纠正错误,随后修改并再次呈现。 + +### 部署配置 + +模式定义是经过校验的插件 Config;依照仓库约定,它可以通过 `cordis.yml` 修改,无需编辑代码。部署必须提供完整的 `plan` 段落;该包不内置任何模型指令。其他模式使用同一份配置 map: + +```yaml +- id: mode + name: '@deepseek-ai/dsh-mode' + config: + modes: + plan: + section: | + You are in plan mode: explore and design, then present the + plan for approval through exit_plan_mode. +``` + +定义的精确形状是 `{ section }`;其中有意不提供逐模式工具清单或强制约束字段(见[常见问题](#faq))。定义名称使用小写斜杠命令子集 `/^[a-z][a-z0-9_-]*$/u`;`default` 是保留项(表示没有策略),不能用作键。名称无效或存在未知定义键——包括 `tools` 清单或 `access` 上限——会在加载时校验失败;未知模式名称则会在调用 `set()` 时大声失败。 + +### 在终端中 + +终端入口通过插件自有的命令注册表(`@deepseek-ai/dsh-commands`),为每个已配置定义获得一条进入命令:`dsh-mode` 为必需定义注册 `/plan [message]`,例如还会注册 `/review [message]`(当配置 `review` 时)。每条命令都记录其具名切换;非空的可选消息会去除首尾空白并传给 `agent.steer()`,后者会把消息放入运行中 agent 的下一步骤,或委托给 `send()` 以开启新的空闲轮次。命令名称与结果不会进入模型历史;这条显式消息则会作为所选模式下的普通用户消息记录到日志。合成的 `default` 条目不贡献命令。退出评审无需新机制即可直接在终端中提示:它是普通的用户交互问题,因此会进入组合后的用户交互提供方提示队列,与 `ask_user_question` 使用的队列相同。 + +### 通过 ACP + +模式选择器是该包的对外接口:`session/new`/`session/load` 会通告 `availableModes`/`currentModeId`,其值来自 `ctx.modes`(通过 `ctx.get` 机会式消费,沿用 `tool-bash` 模式);`session/set_mode` 调用 `set()` 并乐观通知 `current_mode_update`(待生效模式就是用户的选择,记录到日志的 `mode/set` 会在边界处跟进);`session/event` 监听器则会在每次已记录切换不同于最近一次已发送值时再次通知。退出工具复用用户交互 ACP 提供方的 elicitation 流程;其 ACP 映射会携带评审 `detail`,因为 Code Mode 嵌套分发没有 Native plan 卡片,而 Native 调用还可以额外流式传输该卡片。沙箱模式、审批策略和模型等单项环境旋钮不是模式,应归入 `session/set_config_option`(见[常见问题](#faq))。 + +### 面向 agent 创建方 + +`ctx.modes` 是完整的程序化接口:`list()` 返回已配置定义和供选择器使用的合成 `default` 条目;`get(agent)` 返回折叠模式与可能存在的待生效意图;`set(agent, mode)` 则根据 `list()` 的词汇校验名称,并记录将在边界应用的意图。`default` 始终是有效目标,因此退出模式与进入模式使用同一次调用。创建时没有模式选项;调用方在首个轮次前通过 `set()` 选择模式,随后以相同方式刷写。系统也不提供可订阅的实时 `agent/*` 镜像:UI 依照[事件领域语义](../../implemented/architecture/2026-06-30-event-domain-semantics.md)读取 `mode/set`,该事件来自 `session/event`。 + +## 详细设计 + +### 词汇 + +```text +'mode/set': { mode: string } // SessionEventMap merge in dsh-mode: log-only, non-surface, + // whole-value replace — the last one in the log wins +DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable +``` + +载荷不携带原因/溯源字段:工具驱动的切换在日志中紧邻其 `tool/call`,用户切换则位于轮次边界,因此原因就在日志相邻位置。这与[可重建性 Agent Note](../architecture/2026-07-05-reconstructable-requests.md)针对请求头事实所作的「叙述字段可以派生」决策相同(进行中的 `env/state` 事件之所以携带 `source`,正是因为其漂移变体在日志相邻位置没有原因;二者形成对照,并不冲突)。模式名称是配置声明的词汇,不是不透明的跨边界 id,因此仍使用裸字符串(不使用 `Branded`)。 + +### 配置与解析步骤 + +```text +interface ModeDefinition { section: string } // prompt text — a mode's whole vocabulary +interface ModeConfig { modes: Record } // plan is required and owns its complete prompt +resolveConfig(config): ResolvedModes // explicit resolve (the dsh-bash template), fail-loud: + // missing plan, 'default', blank sections, and unknown keys rejected +``` + +单字段形状是有意采用的最简设计,并非最终词汇:逐工具策略维度会以工具定义中的副作用元数据形式回归(见[延期工作](#deferred)),在此处读取,而不是由每种模式重新声明;该维度到来时,配置形状不应需要迁移。 + +### 折叠、服务与刷写 + +`foldMode(events)` 是纯函数(导出供重建方与测试使用),直接折叠仅追加的会话日志;`mode/set` 不是 surface 节点,因此压缩无法遮蔽它。`set(agent, mode)` 根据 `list()` 的词汇校验名称,即已配置定义加上保留的 `default`;后者不能用作配置键,却始终可以作为 `set()` 目标。目标与待生效模式相同(没有待生效模式时则与当前模式相同)时,该方法丢弃无操作;其余情况会把 `{ mode, narrate }` 记录到 `WeakMap` 的待生效意图槽中。它不能立即追加,因为[每个会话事件都封闭在轮次内](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md),而空闲 agent 没有打开的轮次。 + +循环拦截 seam 上经过故障隔离的监听器(参见[防御模式](../../../../docs/defensive-patterns.md):策略插件不得阻塞提示词或轮次)会把待生效意图刷写为一条 `mode/set` 追加:`agent/prompt-submit` 在刚打开的轮次中、首次组装前触发;`agent/turn-continuation` 则在普通步骤关闭后、后续步骤开始前触发。自动请求恢复会绕过 continuation,因此,前置的 `agent/request-error` 包装器会先委托给组合后的策略和异步退避,只在 waterfall 返回循环前刷写 `retry` 决策;effect 作用域的生命周期守卫会抑制在插件资源释放后才恢复的已捕获包装器。三条路径都位于工具执行与日志发布之外(提交后的 `session/event` 观察器只负责观察),因此每个步骤都在其组装所折叠出的模式下运行。刷写模式与最后一个 `request/header` 处的折叠结果不同时,刷写会在同一帧中追加一条合并后的 `context/message` 通知(「用户已将此会话切换到 plan mode。」);面向用户的叙述情形列在[常见问题](#faq)中。 + +### 软层:计算得出的段落与稳定的退出 schema + +已注册的提示词段落从 `AssembleContext.agent` 读取调用 agent 的模式,并解析为当前定义的指引或 `''`。循环逐步骤渲染,并在渲染后的 header 发生变化时记录完整的 `request/header`,因此进入或离开模式均可归因。该段落在每种模式内保持静态,plan 本身则以消息和工具参数留在对话中;无需为了跨压缩保留状态,而在每个请求中重新注入独立的 plan 状态([既有方案](#prior-art)采用的办法),徒增提示词抖动。 + +指引贡献为 `{ name: 'mode:policy', order: 50, text: context => … }`:排在人设(0)之后、工具指引(100–199)之前,并在默认模式或没有 agent 的组装中为空。`exit_plan_mode` 只通过 `ctx.tools` 注册一次且从不过滤,因此模式切换期间 Native schema 与 Code Mode 生成的 SDK 保持逐字节相同;未部署 `dsh-mode` 的环境则没有这项绑定。系统不注册 `tools/pre-execute` 监听器:模式不设置任何门禁,退出工具自身的折叠模式检查会拒绝 plan 之外的调用。退出评审是一个带选项和反馈的问题,不是权限,因此位于工具通过用户交互 seam 执行的过程内。 + +### `exit_plan_mode` + +`defineTool` 有一个必填的 `plan: string` 参数。Native 执行会把它记录在普通 `tool/call` 中;Code Mode 在执行前记录外层 `run_code` 源码,并在分发结算后把规范化的嵌套参数追加到 `tool/code-dispatch`。`execute` 会拒绝没有 agent 的调用(沿用 [`todo_write` 先例](../../implemented/feature/2026-06-29-todo-write-tool.md))和折叠模式不是 `plan` 的调用,并在询问评审人前拒绝空 plan 或不含标题的 plan;随后,它通过 `ctx.userInteraction.ask()` 发起一次单选评审,其 `detail` 是确切 plan,并开放自由文本反馈,供用户批准或要求继续规划。只有恰好选择一个 `Approve` 才表示同意,其他任何形状都按失败关闭处理。批准会记录一项将在边界生效且不叙述的意图,用于切换到 `default`,并返回简短确认。部署指引要求模型把这次调用作为回复中唯一且最后一次工具调用;如果模型违反该规则,运行时仍会让该批次剩余部分保留 plan 指引,下一步骤才记录变化后的 header,其中移除指引而工具 schema 保持不变。所有未获批准的结果都会返回纠正性的 `isError`,并让模式留在 `plan`。 + +其[渲染意图](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)在设计之初就已确定:`presentCall` 是 `generic` 卡片,以 plan 的首个标题命名、以 plan markdown 作为内容,另配一张 `generic` 结果卡片。Native 入口会在问题之前显示该卡片;Code Mode 嵌套分发不会产生 Native 调用卡片事件,因此用户交互 `detail` 会在每个提供方上独立携带同一份 plan。系统机会式消费该 seam(`ctx.get('userInteraction')`),所以 `dsh-mode` 在没有它时仍可组合,并降级为[常见问题](#faq)中确定的手动退出方式。 + +### 依赖与接口 + +`dsh-mode` 是一个产品包,而不是由三个包组成的能力 seam(见[考虑过的替代方案](#alternatives-considered)):它对等依赖 `cordis`、`dsh-session`、`dsh-agent`、`dsh-tools` 与 `dsh-system-prompt`,注入 `['tools', 'systemPrompt']`,并在执行时机会式读取 `ctx.userInteraction`(指向 `dsh-user-interaction` 的仅类型对等依赖边);其仅有的 UI 侧边也是可选的仅类型对等依赖(逐定义进入命令使用 `dsh-commands`)。除 `ctx.modes` 调用接口外,所有内容都通过监听器参与,因此移除该包会平稳移除模式,而不会破坏消费方。终端入口无需模式专用代码:组合命令注册表后,`dsh-mode` 会自行注册每个定义的命令(指向 `dsh-commands` 的可选仅类型对等依赖边),退出评审则使用组合后的用户交互提供方提示队列。[高层 API](#over-acp) 已确定 ACP 协议映射;在包关系上,桥接层对 `dsh-mode` 采用仅类型对等依赖边并机会式读取服务,所以不含该插件的桥接层行为与当前完全相同。 + +### 已记录场景与 harness 操作 + +`input.json` 新增一种步骤操作 `{ "op": "setMode", "modeId": "plan" }`,通过真实的 `session/set_mode` RPC 驱动,并配有脚本化的 `elicitationAnswers` 队列。`plan-mode` 场景在第 1 个轮次前进入 plan,在独立配置的沙箱下运行真实的 `cat`,通过 `exit_plan_mode` 呈现 plan,接收脚本化审批,然后在下一步骤编辑。首个 `request/header` 包含完整、稳定的工具集和已配置模式段落;批准后变化的 header 会保留逐字节相同的工具 schema,只移除该段落。`plan-mode-reject` 固定纠正性的自由文本反馈和未变化的 plan 状态。两份记录都在 Seatbelt 或 bwrap 下回放宿主命令;后端特有的沙箱拒绝仍留在 bash 工具单元层。 + +### 机械收尾 + +系统不声明新的 Cordis 事件(`mode/set` 通过 `session/event` 传递,监听器附着到现有 waterfall),因此事件目录不变。同一变更重新生成以下内容:持久化日志目录(`mode/set`)、服务目录(`ctx.modes`,JSDoc 完整)、配置目录(`ModeConfig`)、工具目录(`exit_plan_mode`)、生产方/消费方 map 与文档图,以及模块图。仓库接线包括:根 tsconfig 的 `paths` 条目、新包组 README 和[包索引](../../../../packages/README.md)中的一行(新增顶层包组正是该表所命名的有意操作)、`architecture.md` 中经过预算检查的 `ctx.modes` 能力服务行,以及实操手册对应行的升级。 + +## 延期工作 + +以下各项都需要独立决策:通过转发的创建时模式选项实现 subagent 模式继承(由于没有消费方而移除,将随首个消费方回归);`plan` 之外的预设模式(只读、接受编辑);若待生效意图丢失被证明是真实问题,则引入空闲记录原语;以及最重要的**在工具定义上自行声明副作用**,即逐工具的只读/变更分类(MCP `ToolAnnotations` 词汇——`readOnlyHint`/`destructiveHint`——是自然模板,其中对不可信提示的警告意味着还需区分信任层级)。通用的逐模式工具策略正在等待这一项:本 Agent Note 最初发布过临时的逐模式名称允许清单,并在发布前移除;手工维护的清单错误地表达了副作用问题,必须跟踪部署所组合的每个工具,且会随工具增加而无声腐化。因此,按模式限制的工具可用性(以及逐工具 `ask` 策略)会作为已声明副作用的消费方回归,而首项消费需求就是其重启触发条件。 + +ACP 自动化组合不挂载 plan mode 或问题工具。面向人类的组合拥有 plan 选择与评审;聚焦的 plan-mode 测试和交互接口快照会固定其已记录状态、指引、评审和稳定工具 schema。 + +## 常见问题 + +以下内容澄清选定设计的行为;遭否决的设计见[考虑过的替代方案](#alternatives-considered),已接受的代价见[后果](#consequences)。 + +**用户切换模式后何时生效?** 在下一个组装前边界生效:`agent/prompt-submit` 覆盖首个步骤,`agent/turn-continuation` 覆盖普通后续步骤,组合策略之后的 `agent/request-error` 重试决策覆盖自动恢复。因此,在请求或重试退避进行期间选择的模式会塑造下一次模型请求。这就是[既有方案](#prior-art)中每项产品都采用的「应用于后续请求」语义。 + +**何时向模型讲述模式变化?** 仅当模型可见状态确实变化时:刷写会把刚刷写的模式与最后一个 `request/header` 处的折叠结果进行比较,并合并讲述一次。净变化为零的切换序列(先进入 plan,再在边界前切回)不会产生叙述;工具驱动的退出只通过自身工具结果叙述;首个轮次前设置的模式也不叙述,因为该段落本身就是状态说明。该原则来自进行中 env-state 提案的边界叙述:如果提示词表层悄然切换,transcript(文本记录)仍会依据 header 已不再具备的状态进行论述。 + +**恢复时,配置已不再定义折叠出的模式会怎样?** 当前配置不再定义的折叠模式名称会在不通知的情况下表现为默认模式,因此会话既不会获得替代约束,也不会变得不可用。`set()` 的大声校验只覆盖写入路径;恢复后的日志以当时找到的配置为准。 + +**如果部署没有组合用户交互提供方,会怎样?** Plan mode 仍然安全,但只能手动退出:`ctx.userInteraction.ask()` 会抛出 `NO_PROVIDER`(seam 不存在时甚至无法解析到该服务),工具返回纠正性的 `isError`,退出方式降级为由用户切换模式,绝不会在未经评审时退出。模式段落会要求模型通过 `exit_plan_mode` 呈现 plan,并在失败时改用普通文本询问用户,因此模型会继续呈现,而不会停滞。 + +**为何没有逐模式工具允许清单?** 因为「哪些工具在规划模式下安全」是每个工具自身的属性(即副作用),不是模式的属性。逐模式名称清单会在错误的归属位置重新声明该事实,必须枚举部署所组合的每个工具(包括 MCP 服务器),且会随工具到来而无声腐化。在工具定义声明其副作用前(见[延期工作](#deferred),其中归档了被移除的临时允许清单及其重启触发条件),模式只通过自身段落和退出评审约束行为;由此产生的暴露面属于已接受代价(见[后果](#consequences))。 + +**subagent 是否继承父级模式?** fork 子级可以直接继承,因为父级的 `mode/set` 位于种子前缀中。spawn 子级从默认模式开始;创建时模式选项与 subagent 提供方的自动转发一并延期(见[延期工作](#deferred))。 + +**plan mode 与沙箱只读模式有何关系?** 二者是互不接触的独立轴:模式是协作姿态(`mode/set` 折叠),沙箱模式是强制约束旋钮(`bash/sandbox-mode` 折叠,参见[沙箱 Agent Note](2026-07-06-sandbox.md))。Plan mode 既不读取也不限制沙箱模式,与 Codex 将 Plan/Default 预设同沙箱及审批设置分开的做法完全一致。希望规划期间由内核强制只读的用户需要同时设置两者:以任意顺序切换模式选择器与沙箱模式选项;每次切换只改变自身折叠结果,因此二者互不干扰,也不存在可能崩溃的还原步骤。日志会把每条轴归因到各自事件:协作姿态对应 `mode/set`,隔离约束对应 `bash/sandbox-mode`。 + +**为何沙箱模式、审批策略或模型本身不属于模式?** 它们是独立于协作状态的单项环境旋钮。已退役的 ACP 映射记录在[仅面向自动化的协议决策](../simplification/2026-07-23-acp-automation-only-protocol.md)中。未来模式定义可以捆绑环境事实(在挂载处通过 `ctx.envState` 应用),让 Codex 风格的预设仍是一种模式;但把审批策略融合进模式概念本身的方案已在[考虑过的替代方案](#alternatives-considered)中遭否决。 + +## 既有方案 + +对已发布 plan mode(Claude Code、Cursor、Copilot、OpenCode、Gemini CLI、Cline、Windsurf、Codex)的调研表明,各产品都包含同样五个部分:低权限工具策略、plan 产物、审批时刻、执行状态切换,以及[问题](#problem)所依赖的持久状态。 + +只要产品公开模式接口,该接口就一定是清单,绝不是布尔值:Claude Code 的选择器提供 `plan`,旁边是 `acceptEdits`(另有自动进入 plan 的模式);Codex 则把 `Plan` 与 `Default` 并列公开为协作模式预设,同时让审批和沙箱设置保持独立。ACP 传输层不公开这项面向人类的控制。 + +由部署拥有的示例提示词借鉴工具性行为,而非产品特有机制。它借鉴 Codex 的以下做法:即使收到祈使式实现语言也留在 plan mode;提问前先探索;区分仓库事实与由用户决定的选择;让 plan 完整覆盖 API、数据流、失败、测试和假设,从而足以作出决策。它还借鉴 Claude Code 的以下做法:禁止变更与提交;优先沿用现有模式;只针对需求或方案选择提问;通过退出工具完成规划,而不在普通文本中请求审批。它有意省略 Codex 协议标签,以及 Claude 的 plan 文件或分阶段 subagent 机制,因为这些属于各自运行时,而非本插件契约。 + +把模式留给约定的生态展示了应避免的失败形态。Pi 风格的模式扩展会争抢一个后写覆盖的全局活跃工具清单,只靠提示词文本强制「只读」(模型幻觉调用一个仍已注册的工具时,该调用会实际执行),并在每个请求中重新注入 plan 状态以跨过压缩。这里通过逐 agent 的折叠状态,以及压缩无法遮蔽的只记录日志、非 surface 事件,从结构上消除了有争议的全局清单和重复注入补丁。相较之下,仅靠提示词的形态被有意保留:Codex 的 Plan 正是如此实现,这也是模式轴可以与强制约束轴自由组合的原因。需要硬性下限的部署会把模式与独立的沙箱旋钮配对,而不是让模式携带自身强制约束(见[常见问题](#faq))。 + +## 考虑过的替代方案 + +**以权限模式作为核心概念(Claude Code 的形态)。** 用一个 `permissionMode` 融合审批策略和工具策略。本设计中,它们是归不同所有者负责的两条轴:审批 seam 拥有「谁回答这个问题」,模式拥有「模型获得什么接口」。ACP 将二者建模为相关但有区别的概念(模式以后可以选择审批策略;届时是模式定义增加字段,而不是合并两者)。 + +**由三个包组成的能力 seam。** 接口/实现/消费方适合可替换后端;模式的可变部分是配置值,而不是实现。拆分会制造一个空实现包,与审批 seam 及 [`todo/`](../../implemented/feature/2026-06-29-todo-write-tool.md) 作出的「不要过早拆分」决策相同。 + +**由循环拥有模式状态。** 依据既有规则(用插件,而不修改循环)予以否决:该功能所需的每个钩子——组装、执行前处理、轮次边界、会话事件——都已经是有文档记录的 seam,修改循环只会增加耦合,别无收益。 + +**带默认拒绝门禁的逐模式工具允许清单(首个发布形态)。** 已在发布前移除。手工维护的名称清单会逐模式重新声明一项逐工具事实(即其副作用):它必须枚举部署所组合的每个工具,包括 MCP 服务器和未来注册项,并会随工具增加而无声腐化(新增只读工具时,在有人编辑每种模式前都会被阻止;编写负担落在了解模式的人身上,而不是了解工具的人身上)。它还过度承诺:该清单看似安全边界,但 shell 之外的任何能力其实都不存在这种真实边界。通用维度已停放到副作用自声明(见[延期工作](#deferred));由此产生的后果——plan mode 只提供指引,也就是该门禁一度弥补的 Pi 缺口——是有意接受的,并计入[后果](#consequences)。 + +**模式上的 `access` 沙箱上限(第二个发布形态)。** 同样已在发布前移除。`ModeDefinition.access` 会把 bash seam 的逐调用沙箱解析限制在模式声明的上限内(一个 `bash/resolve-mode` waterfall 加上取阶梯最小值的监听器;守卫会在执行器无法施加约束时隐藏 bash,并在模式中途拒绝提升权限)。状态仍保持正交,因为上限从不写入沙箱旋钮;但两条轴并不正交:进入 plan 会改变沙箱实际强制执行的内容,把协作姿态与强制约束级别融合起来,违背评审最终达成的 Codex 形态分离方式(Plan/Default 预设从不触及沙箱或审批设置)。这种融合有一项用户可见症状:规划期间把沙箱选项切换为 `workspace-write` 不会产生任何效果。上限、waterfall 和 mode→bash 依赖边随后一并移除;部署可以把模式与独立沙箱模式选项配合使用,从而在规划期间由内核强制只读。以后也可以重新引入模式触发的预设(模式定义捆绑建议的旋钮值,并作为普通旋钮切换加以应用),而无需再次融合两条轴。 + +**仅存在于运行时的模式(只在 UI 或桥接层本地存在,不记录日志)。** 恢复与 fork 会悄然丢失模式,模式引起的 header 增量在日志中也没有可归因原因。记录到日志的状态让模式无需额外机制即可审计和还原。 + +**把模式切换作为 `context/message` 并通过 `agent.inject()` 写入。** 这样可以复用现有的轮次封闭路径,却会把策略状态放入模型 transcript;模型无需被告知两次(段落已经告诉它),而只记录日志的事实不应占用 surface。 + +**plan 文件存储(`.plans/` 目录)。** 这会为日志已经以可回放方式承载的内容创建第二个持久归属位置;需要文件的部署可以添加一个写入文件的工具。同一事实只应有一个归属位置。 + +**用布尔值 `planMode` 取代具名模式。** 对仓库已经跟踪的接口而言过于狭窄:ACP 通告的是模式清单,已发布的选择器也不只填入 plan(见[既有方案](#prior-art));以后再泛化会重命名持久事件词汇。字符串形状的机制现在不产生额外成本;只有 `plan` 作为定义发布。 + +**工具策略栈服务(对 Pi 批评的补救方案)。** 现在就为工具策略建立专用组合服务为时过早:本实现不执行按模式限制的工具过滤,未来的副作用策略可以通过现有带守卫的执行 seam 组合。只有已声明的工具副作用产生具体组合需求后,才应正式建立该服务。 + +**通过审批 seam 执行退出审批(一个 `{ kind: 'ask' }` 门禁决策)。** 最初草案提出该方案;当时审批 seam 是唯一正在落地的询问机制,所以显得自然,但它把评审放进了权限的位置。该 seam 的结果词汇有意封闭且仅供单次使用(`allowed-once`/`rejected`),因此拒绝无法携带反馈,批准也永远无法增加选项(例如「批准并接受编辑」)。退出时刻是一个问题,而非权限;用户交互 seam 为其提供选项与自由文本通道,拒绝反馈也会逐字传给模型。审批 seam 仍适合真正的权限门禁(沙箱提升权限),注册表的 `ask` 词汇也继续供希望在该处设置询问的部署使用。 + +**使用普通文本或 steering(中途引导)退出,而不使用工具。** 这样既没有产物,也没有审批时刻。工具参数本身就是可供评审的 plan,而评审问题会把结构化的是/否选择附着到确切转换上并交给人类。 + +## 后果 + +以下保证现在已经由单元、协议、快照和真实 API 测试层固定: + +- 生效模式是会话日志的纯函数:恢复与 fork 无需额外机制即可还原它;下一变化步骤中,一条 `mode/set` 后会出现匹配的完整 `request/header`。 +- 用户驱动的切换会在下一边界恰好叙述一次,净变化为零的切换序列不会产生叙述;工具驱动的退出只通过自身工具结果叙述。 +- 默认模式下,插件不贡献模式段落,但会贡献稳定的 `exit_plan_mode` schema;未部署 `dsh-mode` 的环境没有这项绑定。 +- 默认、plan 与自定义模式之间转换时,Native 工具 schema 与 Code Mode SDK 保持逐字节相同;只有已配置的指引段落发生变化。 +- Plan mode 不改变强制约束轴上的任何内容:工具集、沙箱模式、权限提升和审批策略在 plan 与默认模式下表现完全相同;部署通过把模式与独立的沙箱/审批旋钮配合使用来强化规划。 +- 模式定义可通过 `cordis.yml` 修改,无需编辑代码;其中必须提供完整 plan 指令。缺失 plan 配置、定义畸形和未知键会在加载时失败,未知模式名称会在 `set()` 时失败。 +- `exit_plan_mode` 始终通告,在 plan 之外会拒绝;批准后只移除 plan 指引,并通过纠正性的 `isError` 携带继续规划反馈;每个面向人类的接口都由其用户交互提供方承载评审。 +- 随功能落地一并交付的文档收尾包括:README、重新生成的目录(持久化日志、配置、Cordis 服务、工具)、包索引与架构行,以及实操手册中的对应行。 + +已接受的代价如下:在 agent 空闲时设置的待生效用户切换,如果进程在下一轮次前退出,就会丢失(UI 会重新应用;若实践中出现问题,空闲记录原语就是逃生口)。模式转换会改变顺序 50 处的系统提示词,因此从该处开始的缓存路径也会变化,但工具 schema 与 Code Mode SDK 不再抖动。**模式只依靠指引约束行为**:忽略该段落的模型可以在 plan 期间执行变更;评审时刻、会话日志以及独立的沙箱、审批和文件系统策略共同构成约束边界。强化规划意味着设置这些旋钮,而不是扩大模式职责;被移除的强制约束形态及其副作用声明重启触发条件仍记录在[考虑过的替代方案](#alternatives-considered)和[延期工作](#deferred)中。面向人类的接口拥有 plan 选择器与评审交互;ACP 自动化传输层两者都不承载。 diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml new file mode 100644 index 0000000000..fb39bd26db --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.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 +2026-07-08-background-subagent-tasks.md: 12b34f2a28cfa311a48904cd5396ec16d9123641 +2026-07-08-background-subagent-tasks.zh.md: 58f035a14d55bc0b4e7f670111909074c3f53b2b diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md index 83ea99dc75..12b34f2a28 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-08-background-subagent-tasks.zh.md) + ## Problem The [subagent seam](2026-06-21-subagent-capability-seam.md) returns a `SubagentRun`, but the model-facing tool originally collected every run synchronously. Independent, slow delegations therefore held the parent call open or ran serially. diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md new file mode 100644 index 0000000000..58f035a14d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md @@ -0,0 +1,64 @@ +# Agent Note: 后台 subagent 任务 + +Status: implemented + +[English](2026-07-08-background-subagent-tasks.md) | 中文 + +## 问题 + +[subagent seam](2026-06-21-subagent-capability-seam.md) 会返回 `SubagentRun`,但原先面向模型的工具会同步收集每一次运行。因此,各自独立的慢速委派要么一直占用父调用,要么按串行方式运行。 + +subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放契约。 + +## 决策 + +每个 `dsh-tool-subagent` 实例都可以公开 `run_in_background`,由 `enableRunInBackground` 控制,且默认启用。禁用该功能的实例不包含此参数,并会在执行时拒绝强制传入的后台参数。提供方选择仍属于部署配置,因此一个实例仍然只为一个提供方注册一个名称可区分的工具。 + +后台 subagent 使用[通用后台任务运行时](../architecture/2026-06-20-generic-long-running-tool-runtime.md)。`task_output`、`task_list` 和 `task_kill` 负责收集、列出、取消、完成通知和提示词引导;系统不提供 subagent 专用的配套工具。 + +前台调用保留其同步契约:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前释放该运行。 + +对于后台调用,工具会验证父级,并在调用 `ctx.tasks.start()` 前拒绝已中止的执行信号。任务运行时会在调用生产者启动器前,预检控制接口和拥有者清理。该启动器创建独立的 `AbortController` 并启动 `ctx.subagents.start()`;返回 id 之后,工具调用的信号不再拥有该子级。 + +任务注册按以下方式映射 subagent seam: + +- `kind` 为 `subagent`,`label` 为模型提供的描述,`owner` 为父 agent(智能体)。 +- `cancel(reason?)` 中止任务自有的控制器。同一个信号同时覆盖尚未完成的提供方启动和已就绪的子级。 +- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。 +- `readOutput` 不存在。任务存活期间,`task_output` 只返回状态;结算后,它以幂等方式返回最终输出。中间的子级活动仍保留在子会话中。 + +## 生命周期 + +后台 subagent 归属于其父 agent,不会在拥有者关闭后持久存续。任务运行时将清理附加到对应拥有者的确切作用域。agent 资源释放会取消任务,并在 `AgentHandle.dispose()` 完成前等待启动回滚或子级资源释放,避免泄漏子 agent 和会话。 + +完成通知会发送给启动时捕获的确切拥有者。如果拥有者清理过程已经释放了注入目标,该通知将被丢弃;生命周期保证是清理,而不是通知。 + +## 模型引导 + +通用任务提示词教会模型一套共享的做法:保留 id;继续独立工作,而不是忙等轮询;在回答前收集相关任务;终止无关工作。subagent schema 只补充说明:后台模式返回 task id,且 `task_output` 用于收集结果。无论模型是否遵循提示词,授权和拥有者清理都会强制执行运行时边界。 + +## 备选方案 + +### subagent 专用的等待、输出和停止工具 + +能力专用工具会重复任务协议,再教一套收集与停止习惯,并增加多个提供方实例的复杂度。通用运行时在不改变工具「每个实例对应一个提供方」形态的前提下,提供了所需行为。 + +### 在拥有者关闭后存续 + +该方案需要持久化的任务状态、子会话恢复、延迟结果交付通道,以及对被遗弃拥有者的处理策略。以拥有者为作用域的清理为进程内工作界定了明确生命周期。持久作业需要单独设计。 + +### 隔离客户端不做拥有者检查 + +agent 和日志可能以会话为作用域,但任务注册表和可预测 id 属于运行时全局范围。因此,通用拥有者防线同样适用于 subagent 和所有其他生产者。 + +### 增量子 transcript 输出 + +将子级历史以流式方式写入父级,会模糊日志边界,并使提供方行为分化。此接口只公开最终输出;更丰富的观察应由会话或 UI 工具承担。 + +## 测试 + +单元测试覆盖固定了停止原因映射、在报告前释放资源、启动与结果失败、对预中止的拒绝、从启动调用信号分离、在提供方就绪前后取消、通过真实任务工具收集、无控制接口的预检防线、运行时缺失失败,以及每实例 schema 开关。快照覆盖固定了面向模型的 schema。 + +## 影响 + +父级可以并行分派慢速委派任务,并通过与 bash 共用的任务控制来收集结果。子级工作不再占用启动它的工具调用,但在收集、终止或拥有者释放之前可以继续消耗资源。提示词引导鼓励收集;拥有者清理则提供硬性生命周期边界。需要同步委派的部署可以按工具实例禁用后台模式。 diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml new file mode 100644 index 0000000000..db23b997c0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.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 +2026-07-09-bash-backed-grep-glob-discovery.md: 9c25afb44885ca2519c5d74a3d721b34fe3561de +2026-07-09-bash-backed-grep-glob-discovery.zh.md: d0dee8e49e0a500c87afbd59423fb65416c1dcc8 diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md index 64fa232831..9c25afb448 100644 --- a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md +++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-09-bash-backed-grep-glob-discovery.zh.md) + ## Problem The harness needs model-facing `glob` and `grep` tools, but making them `ctx.fs` provider methods turns a local product convenience into a universal filesystem backend contract. Local workspace discovery is naturally a process-backed `rg` workflow; remote or virtual filesystem backends may expose their own search API, may not share a local `ripgrep` view, or may not support discovery at all. The v1 should not require every filesystem backend to implement search before the file read/write/edit seam has proven that need. diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md new file mode 100644 index 0000000000..d0dee8e49e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md @@ -0,0 +1,170 @@ +# Agent Note: 由 Bash 支持的 grep 与 glob 发现工具 + +Status: implemented + +[English](2026-07-09-bash-backed-grep-glob-discovery.md) | 中文 + +## 问题 + +harness 需要面向模型的 `glob` 和 `grep` 工具,但如果将它们实现为 `ctx.fs` 提供方的方法,就会把本地产品便利功能变成所有文件系统后端都必须实现的契约。本地工作区发现天然适合由进程支持的 `rg` 工作流;远程或虚拟文件系统后端可能公开自己的搜索 API,可能无法共享本地 `ripgrep` 视图,也可能完全不支持发现。文件读取/写入/编辑 seam 尚未证明此需求前,v1 不应要求每个文件系统后端都实现搜索。 + +搜索输出还受到两层不同的预算约束。工具需要足够多的原始 `rg` 输出,才能计算稳定的逻辑结果;模型则只能收到有界预览,并在格式化结果超出内联预算时获得恢复路径。通用落盘策略只能看到最终工具结果,因此无法恢复搜索工具已经省略的匹配项。搜索工具必须自行负责保留,并尽力落盘格式化结果。 + +## 决策 + +`glob` 和 `grep` 是 `@deepseek-ai/dsh-tool-fs-search` 中的条件式面向模型工具,由 bash seam 支持,不会成为新的 `ctx.fs` 提供方方法。加载插件时,该包(package)执行 `command -v rg >/dev/null 2>&1`:先通过 `ctx.bash.resolve(request)` 解析请求,再通过 `ctx.bash.run(spec)` 运行;如果命令以非零状态退出,该包会记录警告,并且既不注册工具,也不注册提示词章节。如果探针无法启动、超时、中止、被终止,或没有产生退出码,插件加载会明确失败,因为这意味着 bash 执行器损坏,而不是可选二进制文件缺失。注册后,执行流程同样依次调用 `ctx.bash.resolve(request)` 与 `ctx.bash.run(spec)`,并使用工具组装的固定 `rg` 命令模板。工具层负责 schema、参数验证、shell 引用、结果解析、结果格式化、保留、格式化结果落盘交接,以及超时声明。bash 执行器负责请求默认值解析与上限控制、子进程执行、进程组终止、环境清理、原始输出捕获,以及在本地、沙箱或远程 bash 实现之间替换后端。 + +这些工具不使用 `ctx.bash.start()`,也不创建模型可见的后台任务。从 agent loop(智能体循环)的视角看,它们是普通前台工具:只有当 `rg` 命令退出、超时、中止或失败后,工具调用才返回。`defineTool({ timeoutMs })` 声明协作式工具调用预算,`@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;工具会在 `resolve()`/`run()` 前将该信号转发给 bash 请求。bash 后端自身的超时仍作为第二层安全上限;先触发的中止生效。 + +这些工具使 `path` 与 Claude Code 的搜索工具保持一致,但将解析绑定到 bash workdir,而不是 `ctx.fs`。工具从 `exec.agent?.session.header.cwd` 派生 bash 请求 workdir,与 `dsh-tool-bash` 和 `dsh-tool-fs` 一致;如果会话没有 cwd,它会省略 `request.workdir`,由 bash 实现通过 `resolve()` 应用其配置的 cwd 或进程 cwd。对于 `grep`,`path` 是可选的 ripgrep 目标,可以是文件或目录;省略时使用已解析的 bash workdir。对于 `glob`,`path` 是可选的目录搜索根;省略时同样使用已解析的 bash workdir。相对 `path` 值基于该 workdir 解析。只要可行,返回路径就会显示为相对于已解析 bash workdir 的形式;只有在共置部署中,bash workdir 与文件系统 `read` 根指向同一个工作区时,这些路径才保证可以继续读取。v1 会记录这项部署要求,但不执行跨服务运行时验证。在形成共享工作区/根契约或提供方专用搜索后端之前,远程或虚拟文件系统搜索保持暂缓。 + +该包不注入 `fs`,而是注入 `tools`、`systemPrompt` 和 `bash`;它有意读取 `spillStore` 时使用 `ctx.get('spillStore')`,而不使用静态注入,因为格式化结果落盘是可选功能。现有 `@deepseek-ai/dsh-tool-fs` 部署若只需要 `read`/`write`/`edit`,则无需加载 bash。加载搜索功能的部署则必须让 bash 执行器环境可以使用 `rg`,这些工具才会进入模型可见 schema。 + +### 包结构 + +v1 包保持精简。`@deepseek-ai/dsh-tool-fs-search` 内部的源代码布局如下: + +```text +src/index.ts +src/glob.ts +src/grep.ts +src/search-core.ts +src/shell-quote.ts +``` + +`glob.ts` 和 `grep.ts` 各自负责参数验证、命令构造、结果解析、格式化和注册。`shell-quote.ts` 是一个共享辅助模块,因为 shell 引用是两个工具都必须经过的安全边界;`search-core.ts` 是另一个共享模块(实现时对原四文件方案所作的修订):`SEARCH_*` 错误词汇、bash 运行与原始输出获取、格式化结果落盘交接,以及 workdir 相对显示,在两个工具中完全相同。若在每个工具中重复这套精细管道,正是对称性约定所指出的漏提取问题。命令构造器禁止自行拼凑引用,也不能把未经引用、由模型控制的值直接连接到 shell 命令中。 + +### Schema 与配置 + +`glob` 公开精简的发现形状: + +```ts +interface GlobArgs { + pattern: string + path?: string +} +``` + +`grep` 公开 OpenCode 风格的最小形状: + +```ts +interface GrepArgs { + pattern: string + path?: string + include?: string +} +``` + +常规预算不会进入面向模型的 schema。`@deepseek-ai/dsh-tool-fs-search` 拥有以下带默认值并经过验证的配置字段: + +| 字段 | 默认值 | 作用 | +|---|---:|---| +| `globMaxResults` | `100` | 内联保留的最大路径数;与 Claude Code 的默认 `GlobTool` 结果上限一致。 | +| `grepMaxMatches` | `250` | 内联保留的最大扁平匹配数;与 Claude Code 的默认 `GrepTool` `head_limit` 一致。 | +| `grepMaxLineBytes` | `2000` | 每条匹配行预览保留的最大字节数,通过 `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })` 应用。 | +| `rawOutputMaxBytes` | `20000000` | 工具会解析的完整原始 `rg` stdout 最大字节数;与 Claude Code 的 ripgrep 原始缓冲区一致。 | +| `timeoutMs` | `30000` | 附加到两个工具定义并由 `@deepseek-ai/dsh-timeout-policy` 强制执行的工具调用超时。 | + +`globMaxResults` 和 `grepMaxMatches` 使用 `ItemRetainer({ kind: 'head' })`。`grepMaxLineBytes` 针对每条匹配行使用 `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })`,使预览截断保留 UTF-8 边界。这遵循[工具结果保留库](../architecture/2026-07-06-tool-result-retention-library.md)对发现条目的映射:收集完整结果,在内联结果中保留头部条目,并将路径映射、分组和逐行预览放在保留器外部。v1 的 `grep` 不公开 `case_insensitive`、`head_limit`、`offset`、`count`、多行、上下文行、输出模式或文件类型过滤器。模型如需周边上下文,可使用 `read` 读取匹配文件;如需后续结果,则遵循返回的落盘定位符所给出的检索提示。 + +Claude Code 的数值只是两层预算的参考点,并非面向模型 schema 的先例。其专用搜索工具会缓冲最多 20 MB 的原始 ripgrep 输出用于内部处理;在非 WSL 平台上使用 20 秒 ripgrep 超时,在 WSL 上使用 60 秒,之后才在模型看到结果前应用搜索专用上限:`GrepTool` 默认 `head_limit = 250`,并持久化超过 20,000 个字符的格式化结果;`GlobTool` 默认最多 100 条路径,并持久化超过 100,000 个字符的格式化结果。此 Agent Note 采用同样的原始缓冲区和内联数量默认值,将默认搜索超时设为 30 秒,并通过本 harness 的 `ctx.spillStore.saveText()` 路径恢复格式化结果。 + +`path` 字段沿用与 Claude Code 相同的区分方式:`grep.path` 是文件或目录形式的 ripgrep 目标,`glob.path` 则是目录搜索根。v1 不为这些工具公开单独的 cwd/workdir 参数。 + +`include` 是一个正向 glob 过滤器,不是列表,也不是排除语法。系统会预先拒绝逗号分隔或取反的 include 模式,并返回结构化参数错误。shell 命令中使用的每个模型控制值,包括 `pattern`、`path` 和 `include`,都必须经过包私有的 shell 引用辅助模块。 + +### 执行 + +`glob` 构建固定的 `rg --files` 命令,并以解析后的目录搜索根为根(提供 `path` 时使用该值,否则使用 bash workdir):`rg --files --glob --sort=modified --no-ignore --hidden`,另加针对 `.git`、`.svn`、`.hg`、`.bzr`、`.jj` 和 `.sl` 的 VCS 元数据排除项。这样既与 Claude Code 的隐藏/忽略文件发现和修改时间排序保持一致,也避免宽泛搜索包含 VCS 内部文件。工具逐行解析路径,只要可行就将结果映射为相对于 bash workdir 的路径,将每条路径推入 `ItemRetainer({ kind: 'head', maxItems: globMaxResults })`;当保留结果达到上限时,它会格式化完整的已排序路径列表,作为落盘产物。 + +`grep` 构建固定的逐行 `rg --json` 命令,作用于所提供的文件/目录目标(提供 `path` 时使用该值,否则使用 bash workdir),从而无需按冒号拆分,就能解析文件路径、行号和行文本。它消费 `match` 记录,将格式错误的 JSON 或匹配记录视为 `SEARCH_FAILED`;只要可行,就将结果路径映射为相对于 bash workdir 的路径;通过 `grepMaxLineBytes` 应用逐行预览保留,将每个匹配推入 `ItemRetainer({ kind: 'head', maxItems: grepMaxMatches })`,然后只按文件分组内联输出中保留的预览匹配。落盘产物保存完整的格式化匹配列表,而不是只保存省略的尾部,因此检索提示指向模型已经看到的同一逻辑结果。 + +原始 `rg` stdout 是内部传输细节。工具请求 `stdoutMaxBytes: rawOutputMaxBytes`,并通过 `ctx.bash.resolve()` 解析;只有当执行器在该上限内返回未截断的 stdout 时,工具才解析 `stdout.text`。如果 stdout 超过 `rawOutputMaxBytes`,或者执行器仍返回 `stdout.truncated`,工具会以明确的搜索错误失败,要求模型缩小 `pattern`、`path` 或 `include`。工具绝不向模型公开原始 `rg` 输出或 bash 原始落盘路径。 + +只有 stdout 是解析源。对于无效模式、注册后运行时 `rg` 消失,以及搜索失败,stderr 作为诊断文本;如果 bash 截断 stderr,工具会使用保留的 stderr 尾部并附加截断说明,不会读取 `stderr.spillPath`。 + +如果 `ctx.bash.run()` 因工具超时或调用方取消触发而报告 `aborted`,工具会返回结构化失败,而不是假装没有匹配项。如果 bash 自身的超时先触发,工具同样会以明确的超时消息失败。非零 ripgrep 退出语义由工具负责:退出码 0 表示存在匹配并成功;退出码 1 表示没有匹配但成功;无效模式、运行时 `rg` 消失或无法访问搜索 workdir 则表示失败。 + +搜索失败使用包自有的 `HarnessError` 子类与 `SEARCH_*` 代码,而不使用 `FsErrorCode`,因为这些工具不是 `ctx.fs` 提供方操作。v1 的词汇包括 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 和 `SEARCH_ABORTED`。缺少必填字段、空字符串或不支持的取反/列表式 `include` 值等模型参数验证失败,仍作为普通工具参数错误处理。 + +### 格式化结果落盘 + +`ctx.spillStore` 是可选服务,仅用于面向模型的格式化结果。这是代码库中首个工具自有落盘调用模式;此设计有意为之,因为搜索保留属于条目级策略:`globMaxResults` 限制路径数,`grepMaxMatches` 限制匹配数,而工具此时仍持有完整逻辑结果。通用 `dsh-spill-policy` 会在 `tools/post-execute` 阶段限制最终文本字节数;到那时搜索工具已经省略后续路径或匹配,策略无法恢复它们。 + +当搜索产生的逻辑结果数超过内联上限,且 `ctx.spillStore` 存在时,工具会通过 `saveText()` 保存完整的格式化结果。落盘所有者是调用 agent 的会话头 id(`exec.agent?.session.header.id`);缺少该所有者时,搜索会保留内联结果,并报告完整结果无法保存。落盘来源是工具执行身份:`{ toolName: exec.name, callId: exec.callId, label: 'result' }`。建议文件名为 `grep-results.txt` 和 `glob-results.txt`;落盘后端仍将它们视为提示,而不是路径。 + +如果落盘存储不存在、调用没有会话所有者,或保存失败,工具仍返回内联页和页脚,说明完整结果无法保存。格式化结果落盘存储不可用本身绝不能把搜索成功变为 `isError` 结果。 + +bash 原始输出流与格式化搜索落盘产物是两个不同的产物。原始 `rg` stdout 只会在所请求的 bash stdout 上限内于内存中解析;格式化落盘产物则是 `ctx.spillStore.saveText()` 生成的稳定、面向模型的恢复定位符。 + +### 结果形状 + +带有成功格式化落盘的受限 `glob` 结果会返回内联页与落盘通知: + +```text + + +(Showing N of M paths. Full sorted result stored at: /.../session-abc123/9f8e7d-glob-results.txt. Use read with offset/limit, or grep this path to search within it.) +``` + +带有成功格式化落盘的受限 `grep` 结果会返回分组后的预览匹配与落盘通知: + +```text +Found N of M matches + + +Line 12: ... + +(Full grep result stored at: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit, or grep this path to search within it.) +``` + +如果完整逻辑结果未超过内联上限,系统不会创建格式化落盘产物。如果完整逻辑结果过大但无法格式化落盘,页脚会说明结果已受限,完整结果无法保存。`truncated`/省略计数是预算事实,并不表示搜索不完整;超时、无效正则表达式、运行时 `rg` 消失、无法访问 workdir、原始输出溢出、跳过二进制文件和解析失败,仍属于工具领域的错误或不完整字段。 + +## 考虑过的替代方案 + +**将 `glob`/`grep` 放在 `ctx.fs` 上。** v1 不采用:这会迫使每个文件系统后端增加搜索 API,并使本地 ripgrep 行为成为提供方 seam 的一部分。搜索是有用的产品行为,但不像 `readText` 或 `writeText` 那样属于通用文本存储原语。 + +**直接从 `dsh-fs-local` spawn ripgrep。** 此 Agent Note 的 v1 不采用:直接 spawn 提供最简洁的 argv 边界、stdout/stderr 控制与提前停止控制,但会重复 bash seam 已负责的进程执行事项,包括环境清理、进程组终止、超时传播、沙箱/远程执行器替换,以及有界输出捕获。如果 bash 支持的搜索被证明过于依赖 shell 字符串,或必须支持前台流式输出,该方案仍是合理优化。 + +**通过 `ctx.bash.start()` 实现流式提前停止。** 不采用:`start()` 会创建模型可见的后台任务语义,包括 task id、所有者 token、`bash_output`、`bash_kill`、完成通知,并且没有内置超时。`grep` 需要前台工具结果,而不是后台 bash 工作流。如果将来必须流式搜索,正确的抽象是在 bash/进程 seam 上增加前台流式进程句柄,而不是借用公开后台任务 API。 + +**向模型公开 bash 原始落盘路径。** 不采用:bash 原始落盘路径包含原始 `rg` stdout(对于 grep 即 `rg --json` 记录),并非稳定的格式化搜索结果。搜索只把原始 stdout 当作内部传输;模型恢复使用通过 `ctx.spillStore.saveText()` 保存的格式化结果。 + +**先为 bash 输出规范化增加 `spillStore.saveFile()`。** 此 Agent Note 的 v1 不采用:未来规范化 bash 时,`saveFile()` 可以帮助将现有执行器落盘文件移动到会话范围的落盘存储,但搜索只需在生成面向模型的产物前,在内存中获取有界的原始 `rg` stdout。`saveText()` 足以保存格式化搜索结果。 + +**依赖通用 `dsh-spill-policy`。** 不采用:通用 post-execute 落盘只能看到最终工具结果。如果 `grep`/`glob` 内联返回第一页,通用策略无法恢复省略的结果。搜索工具必须在返回有界的面向模型文本前,自行保存完整的格式化结果。 + +**公开 Claude Code 的完整 `GrepTool` schema。** v1 不采用:`output_mode`、上下文标志、多行、`head_limit`、`offset`、`case_insensitive` 和类型过滤器会使面向模型的接口变成 ripgrep 包装层。本 harness 将常规预算与续传机制保留在部署策略和落盘产物中。 + +**保留提前停止搜索,并省略格式化落盘产物。** 此提案不采用:提前停止效率更高,却不给模型检查后续结果的路径。所选 v1 优先保证结果可恢复性与实现简洁性,并以 `timeoutMs`、`rawOutputMaxBytes`、bash 后端上限和格式化落盘产物作为安全后备。 + +**先扩展 bash seam,增加原始输出读取器。** 不采用:可移植的 `readRawOutput(ref, maxBytes)` API 会增加引用生命周期、权限和后端存储语义。逐次运行的 `stdoutMaxBytes` 请求是更窄的 seam:搜索要么在 `rawOutputMaxBytes` 内收到完整 stdout,要么明确失败。 + +**始终注册,只有执行时才报告缺少 `rg`。** 不采用:模型可见工具 schema 是部署能够尝试该能力的承诺。如果 bash 执行器在加载时找不到 ripgrep,更安全的接口是完全没有 `glob`/`grep` 工具或提示词指引。对于注册后发生环境变化的情况,执行时的 `rg` 缺失分类仍作为防御性回退。 + +## 测试 + +- 测试覆盖注册时 `rg` 探测(探测成功会注册两个工具和提示词章节;非零探测会跳过工具与提示词章节并发出警告;基础设施探测失败会拒绝插件加载),证明中止的 `exec.signal` 会到达 bash 后端(通过同一引用的 spec 断言和 `SEARCH_ABORTED` 结果),并覆盖命令构造/引用(恶意模式、带空格路径、以短横线开头的值、引号、换行、glob 元字符:既有单元断言,也针对每个恶意值执行真实 `bash -c` 往返)、将 `grep.path` 用作文件与目录目标、将 `glob.path` 用作目录搜索根、无效模式处理、无匹配、格式错误的 `rg --json` 输出、匹配行预览截断、原始输出溢出、超时/中止、格式化落盘成功/失败、包自有 `SEARCH_*` 错误代码,以及无后台任务不变量。 +- 直接覆盖第一方工具自有落盘先例:落盘后端存在、落盘后端缺失、`saveText()` 失败,以及缺少落盘所有者。 +- 该包通过真实 Loader 路径覆盖命名空间插件的导出形状(`name`、`inject`、`Config` 和 `apply`,且没有默认导出)。 +- 真实执行器集成测试(`dsh-bash-local` + 真实 `rg`)验证外部世界:恶意模式保持惰性、逐会话 cwd 解析、VCS 元数据排除、按修改时间排序,以及真实 ripgrep stderr 分类。如果测试进程的 PATH 中没有 `rg`,该测试会自行跳过(这是与无密钥 e2e 跳过相似的 CI 兼容措施);伪执行器测试覆盖注册和执行时缺少 `rg`,并由逐文件 100% 覆盖率门禁兜底。 +- transcript 可见落盘通知仍有快照缺口:此功能合入时记录了缺口说明,没有快照。快照层会回放 acp-agent 树;在其中加入搜索插件会改变组装的系统提示词,必须使用真实密钥重新录制每一份预期输出,而实现环境没有密钥。落盘通知的确切 transcript 文本由单元测试固定(`formatGlobOutput`/`formatGrepOutput` 以及通过注册表执行的落盘测试);下一次拥有密钥的会话应把插件接入 acp-agent 树,并运行一次 `test:snapshot:record`。 + +## 后果 + +- `glob` 和 `grep` 是 `@deepseek-ai/dsh-tool-fs-search` 中的条件式面向模型工具,不是 `ctx.fs` 提供方方法,也不属于现有 `@deepseek-ai/dsh-tool-fs` 根插件。只有 bash 执行器能找到 `rg` 时才会注册;该包注入 `tools`、`systemPrompt` 和 `bash`,不注入 `fs`,并使 `ctx.spillStore` 保持可选,读取时使用 `ctx.get('spillStore')`。 +- Schema 严格为 `glob(pattern, path?)` 和 `grep(pattern, path?, include?)`;搜索上限与超时是带默认值并经过验证的 Config 字段(`globMaxResults`、`grepMaxMatches`、`grepMaxLineBytes`、`rawOutputMaxBytes`、`timeoutMs`)。 +- 工具通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 执行,转发 `exec.signal`,绝不调用 `ctx.bash.start()`,也绝不公开 bash task id。如果存在 `exec.agent?.session.header.cwd`,bash 请求 workdir 来自该值;解析后的 `spec.workdir` 决定执行与相对路径显示。 +- 工具向 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,只解析上限内未截断的 stdout,并将超限或仍被截断的原始输出视为明确的搜索失败;绝不向模型公开原始 `rg` 输出。 +- 只要可用,过大的完整格式化结果会通过 `ctx.spillStore.saveText()` 保存,而内联结果保持有界;落盘失败、后端缺失或所有者缺失时,系统保留内联结果并报告未保存的剩余内容,绝不会返回 `isError`。 +- 包 README、生成的配置目录与导出 JSDoc 会记录 Config 字段和 `SEARCH_*` 代码;tui-agent 示例会提供条件式工具插件(acp-agent 树等待完成上述快照重新录制);fs 组 README 会记录 `rg` 可用性以及 bash/文件系统共置部署要求。 + +## 风险 + +在宽泛模式下,完整运行的 `grep` 可能比提前停止搜索更慢。v1 为了简化实现并恢复完整结果而接受这项成本,同时通过工具超时、bash 超时、`rawOutputMaxBytes` 和输出上限加以约束。如果实际运行过慢,仍可采用直接 ripgrep 或前台流式替代方案。 + +Shell 命令构造是最尖锐的安全边界。`ctx.bash` 接受命令字符串而不是 argv 向量,因此实现必须集中处理 shell 引用,并测试恶意模式、带空格路径、以短横线开头的模式、引号、换行和 glob 元字符。 + +v1 假设 bash 与文件系统共置部署。如果 bash 搜索一个工作区,而 `read` 工具基于另一个根解析路径,返回路径可能无法继续读取。该包会记录这项要求,但不在运行时验证。 + +落盘定位符由后端负责。当前本地后端返回本地文件系统路径,适用于 `read`/`grep` 能打开这些文件的部署;远程或工作区受限部署可以使用另一种后端,让其定位符和检索提示指向受支持的检索机制。 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml new file mode 100644 index 0000000000..2e7b5b1d58 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.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 +2026-07-10-agent-session-identity-and-log-location.md: a55bf276bff998a94f84ec1af078022e4881903c +2026-07-10-agent-session-identity-and-log-location.zh.md: 84b8b22187ccd1078ff13e39f4a345efbecfaeac diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index d4c5154ed2..a55bf276bf 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-10-agent-session-identity-and-log-location.zh.md) + ## Problem An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md new file mode 100644 index 0000000000..84b8b22187 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -0,0 +1,87 @@ +# Agent Note: 向工具与钩子公开 agent(智能体)会话标识和 JSONL 位置 + +Status: implemented + +[English](2026-07-10-agent-session-identity-and-log-location.md) | 中文 + +## 问题 + +agent 可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话,也无法找到记录该调用的持久 transcript(文本记录)。搜索 `./.sessions` 等同于猜测部署配置和 JSONL 布局;自定义根目录、替代持久化后端、恢复、fork,以及并发运行的父子 agent,都会让这种猜测失效。钩子同样需要 transcript 位置,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。 + +这项边界必须维持两个属性:事实的所有者决定如何解析该事实;每个子进程接收每次执行的快照,而不是进程级可变全局状态。尤其是嵌套 harness 不能把环境中的 `DSH_*` 值泄漏给当前 agent、持久化后端或配置均可能不同的子进程。 + +## 决策 + +在 [`SessionPersistence`](../architecture/2026-06-14-session-persistence.md) seam 上增加同步、无副作用的位置查询: + +```ts +import type { SessionHeader } from '@deepseek-ai/dsh-session' + +interface SessionLocation { + readonly kind: string + readonly path: string +} + +interface SessionPersistence { + locate(meta: SessionHeader): SessionLocation | undefined +} +``` + +`path` 是指向该后端为 `meta` 保留的专用日志的绝对本地路径;`kind` 标识其表示形式。JSONL 使用解析后的根目录和路径辅助函数返回 `{ kind: 'jsonl', path }`。SQLite 以及任何无法诚实提供逐会话本地产物的后端均返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告延迟创建的目标路径。 + +面向模型的 bash 包(package)拥有一个 `ctx.bashEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境接口可供诊断工具和未来的提示词/UI 消费方枚举。 + +注册表会为每次前台和后台 bash `ToolExecution` 重新构建受信任的覆盖层: + +- `DSH_HOME` 始终是配置的 Harness home 绝对路径。独立的 [`@deepseek-ai/dsh-paths`](../../../../packages/util/paths/README.md) 工具库规定其优先级:显式 `dshHome`,其次是环境中的 `$DSH_HOME`,最后是 `~/.dsh`。 +- `DSH_SHELL=1` 始终存在,用于标识由 DeepSeek Harness 管理、面向模型的 bash 子进程。 +- 执行具有关联 agent 时,`DSH_SESSION_ID` 存在并等于 `agent.session.header.id`。 +- 内置的持久化转换层提供 `DSH_SESSION_JSONL` 的条件是 `ctx.sessionPersistence.locate(header)` 返回 `kind: 'jsonl'`。 + +会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 + +bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量进行过滤和通道校验。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层。普通 `env` 仍是钩子所用的通用进程内插件接口,但不能包含受管理的键;对称地,`dshEnv` 不能包含普通键。本地执行器会在 spawn 前拒绝任一错误通道,移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 + +bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。 + +[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.md)在构造 payload 时,从同一持久化 seam 解析 transcript 位置。Codex 使用 `transcript_path: string | null`;Claude Code 保留其字符串字段,并回退为 `''`。钩子查询不会物化或刷写会话。 + +## 同类产品调研 + +同类产品把稳定标识与物理存储分开处理。Codex 向 spawn 的 shell 注入稳定的 `CODEX_THREAD_ID`,而 recorder 和钩子接口负责提供 transcript 路径。Claude Code 通过结构化的钩子/状态输入提供 `session_id` 和 `transcript_path`。OpenCode 在结构化工具上下文中携带标识;Kimi Code 展开会话占位符;Reasonix 则把活动会话路径保存在控制器上。可移植的规则是:在调用边界注入标识,由存储层解析位置,绝不在并发 harness 中使用进程级的当前会话全局变量。 + +## 生命周期与持久化语义 + +新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID` 和 JSONL 目标。JSONL 文件可能要等到第一次成功的轮次结束检查点后才存在,而且在一个轮次仍未结束时,它只包含上次刷写的前缀。`DSH_SESSION_JSONL` 是位置提示,不是授权凭据或新鲜度保证。 + +恢复操作复用已加载的 header,因此 id 和位置不变。fork 和 spawn 会创建新的会话 id 与位置。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。替换持久化服务会影响后续收集,因为转换层在执行时查询 `ctx.get('sessionPersistence')`;注册表本身受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。 + +`dshHome` 是与会话无关的部署上下文。agent-core 通过 `@deepseek-ai/dsh-paths` 解析出一个值,并将其同时传给 tool-bash 和本地 skill(技能)发现;独立消费方调用同一解析器。如果顶层 `dshHome` 与 `skills.local.dshHome` 均已提供但解析结果不同,组合会失败,而不会公开互相矛盾的 home。持久化可以独立变更,无需把其事实冻结到会话前缀中。 + +## 测试 + +单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL/SQLite 定位器契约测试与两套钩子桥接测试均锁定 transcript 可用和不可用两种方言。 + +一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照覆盖会锁定录制请求 header 中的通用 bash 说明。该契约属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。 + +## 考虑过的替代方案 + +**只提供 id,再用 `find`。** 搜索无法得知自定义根目录或后端布局,并且在多会话环境下存在竞态。 + +**只提供绝对路径。** 路径可能不可用、延迟创建或取决于表示形式,不能作为稳定的会话标识。 + +**使用全局 `process.env`。** 并发 agent 会互相覆盖,嵌套 harness 也会继承陈旧的当前会话值。 + +**把持久化说明放入会话前缀。** 活动服务可以在 HMR 或未来的后端切换中改变,而会话前缀保持冻结;持久化专用指引会因此变得陈旧。 + +**使用类型化 waterfall 事件。** 监听器不运行就无法声明所有权,而后续监听器可以无提示地覆盖键。注册表能在注册时检测键冲突,并且保持可枚举。 + +**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。钩子仍然需要 `locate()`。 + +**增加面向模型的 `session_info` 工具。** bash 已经提供查询接口,新增工具只会多出 schema 和一次调用;注册表可以扩展至未来的环境事实,无需为每项事实增加一个工具。 + +## 影响 + +每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中的完整 `DSH_*` 命名空间由 harness 管理:系统移除环境中已有的受管理值、重新加入当前受信任的值,并禁止普通调用方通过 `env` 绕过所有权检查。 + +该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml new file mode 100644 index 0000000000..0c7363827e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.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 +2026-07-10-parallel-tool-call-execution.md: c67ae61939a3e7974f9bf729058a57f5576308a1 +2026-07-10-parallel-tool-call-execution.zh.md: a80317aa951cbf3a9cae0651348c99712a4193d5 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index 4904da5bd5..c67ae61939 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-10-parallel-tool-call-execution.zh.md) + ## Problem An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads and web requests even though the model has already requested them together. diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md new file mode 100644 index 0000000000..a80317aa95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -0,0 +1,103 @@ +# Agent Note: 按单次调用安全性并行执行工具调用 + +Status: implemented + +[English](2026-07-10-parallel-tool-call-execution.md) | 中文 + +## 问题 + +一条 assistant 消息可以包含多个并列的 `tool-call` 块。尽管模型已经同时请求了这些调用,串行执行仍会叠加各个独立读取和 Web 请求的延迟。 + +并发属于宿主调度范畴,不是面向模型的工具元数据。循环需要在不硬编码工具名称、不向 JSON Schema 暴露调度策略的前提下,判断哪些调用可以重叠执行。 + +会话日志仍是权威记录:每个已启动的调用都有审计事件,都会获得结果;无论完成顺序如何,模型历史都按原始调用顺序观察结果。 + +## 决策 + +每个工具都可以提供可选的 `isConcurrencySafe(args)` 分类器。该分类器必须是同步纯函数:它只检查当前调用已解析的参数,不执行 I/O 或任何变更。只有显式返回 `true` 才表示选择并行;分类器缺失、参数无效、分类器抛错或返回任何其他值,都会使该调用按独占方式执行。规范类型契约见[工具数据结构](../../../../docs/core-data-structures/tools.md)。 + +分类器有意设计为一元函数。返回 `true` 表示工具承诺:此调用可以与任何同样返回 `true` 的并列调用重叠执行。调度器不会比较调用,也不会证明它们的资源访问相容。 + +这个一元分类器仍然可以感知输入。工具可以将只读操作分类为并行,将变更操作分类为独占。该接口无法表达「仅当路径不同时,这些写入才安全」之类的关系规则,因此,安全性依赖并列调用的调用仍按独占方式执行。 + +`defineTool()` 先验证参数,再调用类型化分类器。无效参数会被归为独占,且只有该调用真正执行时才会产生常规参数错误。`ctx.tools.executionMode(exec)` 会解析当前有效的工具定义,并返回带标签的 `parallel` 或 `exclusive` 模式;未知工具将以安全方式退化为独占。 + +使用带标签的模式,而不是公开布尔型调度器 API,使得以后可以表达感知资源的变体,无需改变分类器契约。 + +## 调度与顺序 + +循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。如果注册表变更使该调用变为独占,当前池会先完全排空,然后该调用才作为下一个屏障启动。 + +例如: + +```text +[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)] + +→ [read(A), read(B)] +→ [write(A)] +→ [read(C)] +``` + +`read(A)` 和 `read(B)` 可以重叠执行。`write(A)` 要等两者都完成后才启动,`read(C)` 则要等写入完成后才启动。 + +每组都使用一个由 `maxParallelToolCalls` 限制上限的滚动池:循环先按模型顺序启动调用,直到达到上限;每有一个调用结算,就再启动一个。独占组是容量为 1 的池。将上限设为 `1` 可保持串行执行。 + +只有派发和工具主体会重叠执行。`tools/pre-execute` 和 `tools/post-execute` 按模型顺序运行,因为中间件可能维护对顺序敏感的状态。`tools/execute` 包装层会环绕并发派发运行,因此必须能在不同执行之间重入。 + +每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。 + +如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。 + +Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。 + +## 安全契约 + +工具返回 `true` 即承诺:其主体可以与其他并行调用同时运行。它不得直接变更父会话或其他由父级拥有的状态;它将输出返回给循环,由循环按模型顺序提交。 + +执行期间触及的任何共享状态都必须支持并发。这也包括工具包装层和提供方:它们可以在内部串行化,也可以实施自身容量限制,但必须在并发派发时不破坏状态。 + +## 配置与声明 + +`maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.md)。 + +当前实现中的声明保持保守。Web 搜索、Web 获取和文件系统读取选择并行。文件系统写入与编辑、bash 工具、subagent 委派、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。subagent 可能共享父级的工作区或外部资源,而一元分类器无法证明并列委派的作用互不重叠。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 + +文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。 + +## 验证 + +单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文,以及中止排空。第一方测试固定每项并行声明。 + +快照覆盖固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 覆盖固定其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 + +## 备选方案 + +**保持串行执行。** 这可以避免新的顺序和中止情形,但会保留独立并列调用所产生的不必要延迟。 + +**使用一个工具级布尔值。** 固定的 `supportsParallelToolCalls` 标志更小,但无法区分同一工具的只读操作和变更操作。感知参数的分类器保留了这项区分。 + +**使用有状态的分类。** 向分类器提供实时 agent、注册表或 I/O 访问,会使决策依赖分类器的运行时机,并在分类与派发之间留下缺口。可变授权和陈旧状态检查仍属于执行时职责。 + +**使用感知并列调用或感知资源的分类。** 调度器可以成对比较调用,或让每个调用声明资源读写要求。这样可以并行化不冲突的写入,却要求不相关工具共享资源标识和冲突语义。一元契约选择放弃这部分并发性,并在安全性取决于关系时安全退化。 + +**并行执行完整的工具流水线。** 这样可以让循环继续使用公开的单调用 API,但会并发运行 pre-execute 和 post-execute 中间件。现有防护和钩子桥可能承载有序状态,因此只允许派发重叠。 + +**公开分阶段方法或调度 waterfall。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在出现另一个消费方之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略 seam 保留了插入点。 + +**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。 + +**使用固定大小的窗口。** 如果在启动下一个窗口前等待当前窗口的每个调用,一个缓慢调用就会使容量闲置。滚动池在保持上限的同时避免了这项延迟。 + +**向模型暴露并发元数据。** 模型已可以发出并列调用。宿主调度元数据会扩大请求,却无助于工具选择。 + +## 影响 + +该设计以安全退化为原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。 + +在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。 + +有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。 + +并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。 + +工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。 diff --git a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml new file mode 100644 index 0000000000..ca8877d5a7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.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 +2026-07-13-session-query-tracing.md: 47c12824a331546676d3bc79920f861afe648431 +2026-07-13-session-query-tracing.zh.md: 485060f9e57b5644f7b364e2120bfe30607b1945 diff --git a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md index 08f856863e..47c12824a3 100644 --- a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md +++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-13-session-query-tracing.zh.md) + ## Problem Session relationships are encoded across immutable headers, positional surface operations, and logged provenance arrays. A consumer reconstructing those relationships directly would need to duplicate corpus precedence, surface folding, malformed-log handling, deterministic lineage ordering, and cloning. Positional replacement and provenance are different graphs, so collapsing them into one generic edge type would also lose meaning. diff --git a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md new file mode 100644 index 0000000000..485060f9e5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 会话查询关系追踪 + +Status: implemented + +[English](2026-07-13-session-query-tracing.md) | 中文 + +## 问题 + +会话关系分散编码在不可变 header、位置式表面操作和已记录的来源数组中。消费方如果直接重建这些关系,就必须重复实现语料优先级、表面折叠、格式错误日志的处理、确定性的谱系顺序和克隆。位置替换与来源属于不同的图,因此把两者合并为一种通用边类型也会丢失含义。 + +## 决策 + +`ctx.sessionQuery` 除精确读取外,还公开 `traceSession(sessionId)` 和 `traceEvent({ sessionId, seq })`。两者都是基于现有「实时数据优先」语料的一次性视图:会话追踪读取一次完整语料列表,事件追踪读取一份逻辑日志并执行一次规范表面折叠。服务在调用结束后不会保留谱系、反向索引或替换状态。 + +`SessionLineageTrace` 返回目标、按从直接父级到外层父级排序的已知父级,以及递归的后代树;同级节点先按创建时间排序,再按 session id 排序。`complete: true` 会携带已知根节点;`complete: false` 会携带第一个无法解析的父级 id。与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。 + +`SessionEventTrace` 将位置关系与来源关系分开保留。`replacedBy` 是直接的位置替换者,`replacementChain` 沿替换者追踪至最终节点,`replacedEventSeqs` 则列出目标直接移除的真实表面节点。`sourceEventSeqs` 保留日志中直接来源的顺序,而 `derivedEventSeqs` 按日志顺序列出后续的直接反向引用。来源关系不会传递展开。 + +## 校验边界 + +事件追踪会在分析表面之前检查目标是否存在。随后,事件列表与追踪都会使用 `dsh-session` 的单遍表面折叠,对加载的日志整体进行接受或拒绝:事件 seq 从零开始且连续;表面标记符合事件类型的适用范围;只有表面事件类型可以携带来源;存在的数组必须非空且没有重复项;每个来源必须是更早的 seq;每次位置替换必须指明并引用它所移除的全部表面节点。任何契约违例都使用 `SESSION_QUERY_INVALID_SURFACE`;系统不存在只用于分类、要求更弱的表面标准。 + +所有返回的记录与数组都与内部状态分离。已知的实时事件追踪绝不查询持久化;持久化事件追踪保留精确读取所要求的列表/加载一致性检查。会话谱系必然属于跨语料操作,因此也保留跨语料的持久化失败语义。 + +## 考虑过的替代方案 + +- **公开独立的追踪辅助函数**:不予采纳,因为源优先级与状态分离边界属于 `ctx.sessionQuery`;公开辅助函数会诱使调用方绕过该边界。 +- **合并替换边与来源边**:不予采纳,因为位置替换可以遮蔽表面节点,同时引用不在表面上的构造输入,而消费方需要区分这两种含义。 +- **返回传递来源闭包**:不予采纳,因为这会掩盖日志中直接记录的证据、增大结果,并让一条遥远的格式错误边改变原本局部的输出。 +- **在格式错误的来源关系上返回尽力而为的追踪结果**:不予采纳,因为结构上看似合理的局部图会显得具有权威性。当规范的关系契约损坏时,精确检查必须快速失败。 + +## 影响 + +消费方无需缓存或引入第二份语料,即可获得确定性的关系视图。事件追踪每次调用都会执行全日志校验和分配,而谱系追踪每次调用都会列出完整的逻辑语料。这些成本让真源保持明确,并且与承载内容的全文搜索及过滤 API 相互独立。 + +该功能具备单元测试和服务层覆盖率,但没有快照或端到端 fixture(测试前置数据),因为它没有引入面向模型的消费方、transcript(文本记录)变更或跨进程协议。 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml new file mode 100644 index 0000000000..9a0f4cfbda --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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 +2026-07-13-documentation-site-projection.md: 2452c9dfa53e05061446df2fe650f3b4d6428c01 +2026-07-13-documentation-site-projection.zh.md: 9df230ea8adeb8744387a5f7efdf288d6a1f6eaa diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index a5c15686ae..2452c9dfa5 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-13-documentation-site-projection.zh.md) + ## Problem The repository needs a navigable documentation website without turning the website directory into a second documentation source. Copying package guides, architecture pages, or generated catalogs into a site-specific tree allows the two copies to drift, while pointing VitePress directly at the repository root couples public URLs and navigation to the internal file layout. Repository-relative links also need different destinations on the website: published pages stay inside the site, but source files and unpublished contributor documents belong on GitHub. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md new file mode 100644 index 0000000000..9df230ea8a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 将规范文档投影到网站 + +Status: implemented + +[English](2026-07-13-documentation-site-projection.md) | 中文 + +## 问题 + +仓库需要一个可导航的文档网站,但不能让网站目录成为第二个文档源。把包(package)指南、架构页面或生成目录复制到网站专用目录树,会使两份副本发生漂移;让 VitePress 直接指向仓库根目录,又会把公开 URL 和导航与内部文件布局耦合。仓库相对链接在网站上也需要指向不同位置:已发布页面应留在站内,源文件和未发布的贡献者文档则应指向 GitHub。 + +## 决策 + +规范 Markdown 保留在拥有它的仓库层级中。面向产品的指南位于 `docs/user/`,生成的参考资料保留在现有生成目录中,架构页面和实操手册(cookbook)页面也保留在现有的 `docs/` 路径。 + +`website/docs.ts` 是一份显式的发布 manifest(元数据清单)。每个条目将一个规范源文件映射到稳定的公开路由、侧边栏、分区和顺序。因此,新增或移除已发布页面是一项可评审的 manifest 变更,而不是隐式目录扫描的结果。 + +在 VitePress 启动或构建之前,`scripts/project-doc-site.ts` 会把 manifest 投影到被忽略的 `website/.generated/` 目录。生成目录树遵循公开路由,使 VitePress 导航、locale 检测和本地搜索使用同一套路由词汇。每个页面都会获得一个指向其规范仓库文件的 `editSource` frontmatter 字段;编辑链接回调只读取该页面的数据,因此公开 URL 与源文件布局彼此独立。 + +各 locale 的首页投影只保留规范 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 + +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影快速失败。单元测试固定这些转换,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 + +`website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举已跟踪且未被忽略的文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 + +Mermaid 渲染规范图表。网站工作区显式声明 `vitepress-plugin-mermaid` 要求 Vite 预打包的 5 个包,因为 pnpm 的严格依赖隔离会使本地开发服务器无法使用这些传递依赖;Knip 将这种仅运行时使用记录为有意的依赖例外。 + +网站发布与网站构建保持分离。专用 GitHub Actions 工作流运行现有文档门禁,将 `website/.dist` 作为 Pages 产物上传,并只在构建成功后部署。`actions/configure-pages` 在构建时向 VitePress 提供目标位置的 base path,因此私有 Pages 源站、未来的公开项目路径和自定义域名不需要各自的检入配置。Pages 可见性仍是仓库托管设置,而不是工作流权限。 + +## 考虑过的替代方案 + +**在 `website/` 下提交复制的 Markdown。** 这种方式让 VitePress 设置更直接,但每份复制的指南或 API 表格都会多出一个所有者,并且需要一套无法识别权威副本的同步约定。 + +**让 `website/` 成为每个已发布页面的规范归属。** 这种方式仍只有一份副本,却只是为了满足渲染器,就把架构、生成的参考资料和面向贡献者的材料移出了各自的仓库归属层级。 + +**自动发现所有 Markdown 文件。** 这种方式最大限度减少 manifest 维护,却会意外发布内部文档、把源文件移动暴露为 URL 变更,并根据偶然的目录顺序生成导航。 + +**使用文件系统符号链接。** 符号链接保留单一来源,却无法解决公开路由或仓库相对链接问题,而且在本地开发、包工具和托管 CI 环境中的行为不够可预测。 + +**只在部署工作流中构建。** 部署作业可以在合并后发现渲染故障。把生产构建纳入 `doc-sync`,则无论是否存在公开部署,同一个故障都能在本地和常规 CI 中暴露。 + +**硬编码公开项目路径。** 固定的 `/deepseek-harness/` base 适用于公开项目 URL,却不适用于私有 Pages 站点分配的唯一源站,也不适用于未来的自定义域名。使用 Pages 元数据可让这些目标位置共享同一份构建契约。 + +## 后果 + +文档事实只有一个可编辑归属,公开路由在源文件移动后仍保持稳定,网站也能纳入生成的参考资料而无需提交另一份生成副本。本地开发会监视规范输入并重新生成一次性投影。布局门禁会把陈旧的网站专用 Markdown 目录树变成合并失败,而不是被忽略的构建输入。影响文档网站的合并会把检查过的结果部署到 Pages,手动触发则提供恢复与验证入口。 + +发布 manifest 是一份需要维护的 allowlist,链接投影也引入了一层仓库专用的构建适配器。新增一种 Markdown 链接行为时,需要增加投影器测试。Mermaid 支持也会增大客户端 bundle,但能保留规范文档中已经使用的图表。 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml new file mode 100644 index 0000000000..7dcdab8078 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.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 +2026-07-06-recallable-compaction.md: ed5491e642ea7ac99fd9f4ba071a61e655f968d3 +2026-07-06-recallable-compaction.zh.md: 4060df2c2550f9ea3287adfb51d097c1a60baf71 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md index 3f54030f9d..ed5491e642 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md @@ -2,6 +2,8 @@ Status: proposed +English | [中文](2026-07-06-recallable-compaction.zh.md) + ## Problem Compaction is a one-way door. The summary the model sees carries no reference to what it shadows — the `shadowedRange` provenance lives only on the log-only `compact/summary` event — and no tool lets the model read a shadowed span back. Whatever the summarizer drops is gone from the model's reachable world, even though the append-only log holds every byte. Repeated compaction compounds this: the head checkpoint is rewritten every pass, so the request prefix takes a full prompt-cache miss each time, and earlier summaries are re-summarized generation after generation. diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md new file mode 100644 index 0000000000..4060df2c25 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md @@ -0,0 +1,110 @@ +# Agent Note: 可回溯压缩(compaction):索引检查点、状态检查点与会话内历史回溯 + +Status: proposed + +[English](2026-07-06-recallable-compaction.md) | 中文 + +## 问题 + +压缩是一扇单向门。模型看到的摘要没有指向被其遮蔽内容的引用,因为 `shadowedRange` 来源只存在于仅写入日志、模型不可见的 `compact/summary` 事件上,也没有工具能让模型重新读取被遮蔽的区段。即使仅追加日志仍保存每一个字节,摘要器丢弃的内容也会离开模型可触达的世界。重复压缩会进一步放大问题:每一轮都会重写头部检查点,因此请求前缀每次都会完全失去提示词缓存命中,而更早的摘要也会一代又一代地被重新摘要。 + +根本原因是一个产物承担了两个互相冲突的角色。**索引** 需要冻结、按时间排序且成本低廉;模型的**工作记忆** 则需要全局视图、重新确定优先级并且可变。单一摘要无法同时胜任两者。 + +主流编码 harness 都没有让模型在循环内回溯,而且调研过的实现均未让压缩感知前缀缓存。事件溯源会话具备持久原文、可按 seq 寻址和精确回放的特征,是支持这两项功能的天然底座。 + +## 提案 + +把检查点拆为两类,并让被遮蔽的历史重新可达。 + +### 冻结的索引检查点 + +新近变为陈旧的历史按确定性策略拆分为分片:向 `chunkTokens` 累积;使用 `toolPairingBalancedBefore`/`toolPairingBalancedAfter` 对齐边缘;优先选择轮次边界;在平衡允许的范围内,把最终边界放在尽量接近保留边界的位置,使尾随切片缩小到大约一个轮次。每个分片通过一次 `compactRegion` 调用压缩为一个**索引存根**(`stubTokens`,约 100–200 个 token): + +- 用两三行说明发生了什么; +- 用一行关键词记录低频字面锚点,例如确切的错误字符串、值和配置键,并按类别分组; +- 由代码组装页脚:`[checkpoint c: shadows conversation span #–#; originals retrievable via history_read]`。指针根据来源组装,绝不由模型编写。 + +已经提交的存根永不重写,也绝不再次进入之后的压缩区域。存根调用采用分层输入:固定前导内容与逐字节相同的本轮开始状态检查点(该阶段所有调用共享的前缀);随后是先前所有已提交存根的关键词行,使新条目索引其分片的独特内容,而不是重复整个目录;再加最近一两个已提交存根以维持时间连续性;最后是切片本身。同一轮中的同级存根不作为输入,因为并发阶段禁止这种依赖,而与轮次对齐的边界已经维持局部连续性。状态检查点只作为背景,绝不能成为存根需要总结的材料。完全由回溯内容构成的切片只通过代码生成存根,即只写一行指针,不调用 LLM(大语言模型)。存根调用失败时采用相同降级方式:其切片获得一个仅包含代码指针的存根,本轮继续执行,使状态重写成为一轮中唯一的强制 LLM 依赖。 + +### 状态检查点 + +系统维护一份可变的工作记忆文档(最多一份;第一次压缩前为零份),位于所有存根之后、保留尾部之前。每一轮根据先前状态与本轮变为陈旧的内容重写它,成本为 O(previous + new);过程遵守摘要提示词中已有的「合并而不重复陈述」规则,并覆盖决策、当前状态、约束和后续步骤。它带有自己的页脚,其大小上限与当前摘要处于同一量级。 + +膨胀保护会约束整轮操作:如果压缩后大小没有严格小于压缩前大小,就不提交任何内容,并继续当前轮次;本次尝试延后至积累更多陈旧历史后再进行。保护逻辑在两侧比较同一项度量:优先使用请求路径上提供方报告的用量;如果不可用,则两侧都回退为字符估算器。 + +### 一轮的执行过程 + +- 分片切片由表面位置范围表示。一轮分两个阶段运行:所有摘要调用先并发执行并在表面之外缓冲;随后严格从左到右提交区域,先提交各分片,最后提交尾随切片,使状态检查点通过连续的单节点替换落在所有存根之后。墙钟时间维持在接近一次摘要调用的水平。 +- 被取代的状态检查点会作为普通历史折入下一轮的第一个分片,不需要墓碑或新原语。其存根省略该状态,`history_read` 会把它渲染为 `[prior state checkpoint]`,并让页脚随渲染文本一同传递,使每个尾随切片都可通过两跳链路触达。 +- 范围选择会感知冻结边界:可压缩区段从最后一个已提交索引检查点之后开始;只有在不存在索引检查点时,才从表面头部开始。旧会话现有的头部检查点会被视为状态类检查点:其文本作为合并基线,其节点则像其他被取代状态一样折入历史。 +- 摘要阶段发生崩溃时不会提交任何内容;提交中途崩溃会留下一个从左到右的已提交前缀,恢复后的一轮从日志中最新的状态类 `compact/summary` 事件读取合并基线,并无条件提交剩余区域。恢复 `[stubs…][state][tail]` 的优先级高于缩减大小。 + +### 回溯工具 + +新增包(package)`@deepseek-ai/dsh-tool-recall`,它只是 `dsh-session` 与 `dsh-compact` 词汇之上的消费方,注册两个面向模型的工具: + +- `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。 +- `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。 + +两个工具都读取 `exec.agent.session.events`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志是归档,`compact/summary` 来源是索引元数据,而这些工具是两者之上的读取路径。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compact-basic` 移入 `dsh-session`,供摘要器与工具共享。 + +### 缓存与成本 + +一轮后的请求前缀为 `[system][stubs…][state][tail]`。冻结存根在各轮之间逐字节稳定,因此缓存缺失从替换先前状态检查点的 token 才开始,规模保持 O(new chunks + state + tail),而当前实现会从位置零开始缺失。回溯输出落在尾部,不会改变前缀。每轮摘要输入大约是当前实现的两倍,另加一个 m·S 背景项;该成本受到 `chunkTokens` 下限(状态上限的小倍数)以及经过校验的 `stubTokens`/`chunkTokens` 比例上限约束。共享前缀输入布局依次为前导内容、逐字节相同的本轮开始状态、位于尾部的切片内容,使同级调用可以按缓存费率重复读取。 + +### 打包方式 + +该设计以新的后端 `dsh-compact-recallable` 交付,挂在现有 `ctx.compact` seam 上,并在已交付的示例配置中默认启用。`compact-basic` 保留为参考实现和该 seam 的设计对照,与成对 LLM 适配器的模式一致。seam JSDoc 中「最多一个自动生成的检查点,始终位于头部」这一条会放宽,改为说明两个后端各自的行为。 + +### 与进行中工作的关系 + +- **工具结果裁剪**(进行中的裁剪服务):其替换节点携带 `sourceEventSeqs`;同一注册表折叠会把经过裁剪的结果列为可回溯。它属于后续范围,两项工作互不阻塞。 +- **提供方 token 用量核算**(正在把压缩压力迁移至提供方报告用量的工作):为保护逻辑提供核算基础;本实现堆叠在它之后。 +- **「查询会话」backlog(待翻清单)条目**:它是跨会话的泛化方案;本 Agent Note(agent 决策记录)把范围限定在实时会话内,并选择工具名称与渲染方式,使该工作能够扩展本设计而不产生冲突。 +- **训练**:何时回溯属于学习到的行为。确定性页脚与关键词锚点为训练提供稳定目标,而回溯使用情况在会话日志中完全可见,可供轨迹导出;基准测试与 RL 设计由后训练侧推进。 + +### 后续事项 + +以下项目在评审期间已经明确,但会延后至观察结果证明需要时再实现: + +- 保护逻辑降级阶梯(通过代码汇总最早的存根前缀,保留页脚,已汇总 id 仍可作为回溯目标;随后在冻结边界后生成一份摘要):触发条件是观察到保护逻辑活锁或存根区域压力。 +- 存根输出回声检测(句子级 n-gram,豁免短字面量;先重试,再剥离):触发条件是观察到职责分工泄漏。 +- 定期使用分片原文刷新状态:触发条件是交接探针观察到漂移。 +- `stateFallbackThreshold`(存根数量低于阈值时使用完整细节的状态提示词):触发条件是短会话回归。 +- 延迟注册回溯工具:触发条件是在从不进行压缩的会话中测得上下文开销。 +- 在 pre-step 分摊存根起草工作:一旦已经陈旧但尚未压缩的内容积累超过 `chunkTokens`,就在下一个 pre-step 起草该分片的存根(一个仅写入日志的草稿事件,在分片周围的上下文仍然存活时写入),让压缩轮提交草稿,而不是集中执行摘要。这是后台压缩的确定性、精确回放等价形式(Claude Code 会话记忆采用这种模式;OpenClaw 证明同步语义完全相同)。触发条件是观察到一轮延迟,或近实时起草带来的存根质量收益得到验证。 +- 拆分摘要模型;由模型选择分片边界;跨会话回溯;语义搜索回退:每项都必须由各自证据支持。 +- 更丰富的 `history_search` 查询形式:正则表达式,以及对日志 JSON 工具结果执行的结构化查询(sql/jq 风格,或由 agent 针对索引存储编写查询)。触发条件是观察到搜索漏检;首版先交付字面量匹配,使回溯路径保持为日志的纯函数。 + +## 考虑过的替代方案 + +- **分阶段交付**(先在当前后端之上单独交付回溯工具;观察到回溯使用后,再决定是否拆分检查点):不予采纳。未经训练的模型会低频使用任何新工具,因此该条件测量的是训练缺失,而不是设计价值;训练侧需要完整机制来构建环境;预发布阶段修改持久化格式的成本最低;缓存经济性则属于第一方已经掌握的知识,不是等待遥测验证的假设。实现仍以堆叠 PR 方式落地,并先交付回溯工具,但这只是构建顺序,不是决策门槛。 +- **只保留冻结的全尺寸摘要,不设状态检查点**:不予采纳,因为永久前缀会无界增长、自我加速并最终发生颠簸,而且没有任何内容可以重新确定优先级。 +- **只保留纯存根,不设状态检查点**:不予采纳,因为这假定模型知道自己缺少什么,在面对未知的未知时会失败。 +- **由 LLM 老化/整合冻结分片**:不作为常规机制,因为摘要的摘要会丢失信息,并使冻结前缀频繁变化;其保留下来的形式是由代码汇总,且延后实现。 +- **把完整前缀作为分片摘要器输入**:不予采纳,因为成本为 O(N²);状态文档以 O(state) 提供相同背景。 +- **一次摘要调用输出全部结果**:不予采纳,因为摘要路径没有结构化输出约束;解析一份自由文本响应并将其拆开,正是保守失败设计要避免的脆弱 seam。 +- **由模型选择分片边界**:延后实现,因为相对于未经证明的收益,解析与校验成本过高;分片策略位于配置之后。 +- **由模型编写指针**:不予采纳,因为指针必须精确,应由确定性代码组装。 +- **FTS/向量索引伴随存储**:在会话内不予采纳,因为实时日志已在内存中且大小有界,在预算内进行字面量扫描已经足够;只有跨会话范围才能证明索引的价值。 +- **回溯路径中的语义搜索回退/次级模型提取**:不予采纳,因为其中的 LLM 或嵌入调用会破坏无密钥回放的确定性;回溯必须保持为日志的纯函数。 +- **使用原始事件而不是渲染后的 transcript**:不予采纳,因为这会泄漏仅日志可见的词汇与分片噪声;模型应读取模型曾经看到的内容。 +- **什么都不做(用 resume/fork 恢复)**:不予采纳,因为这会把恢复变成人工操作。 + +## 验收标准 + +- 长会话自动压缩后,每一轮完成时都会得到 `[stubs…][state][tail]`;先前存根在各轮之间保持逐字节相同;已提交存根绝不落入后续区域;被取代的状态检查点无需墓碑即可折入历史,渲染时带有标签,并可通过两跳链路触达和搜索。 +- 每个检查点的表面文本都以确定性页脚结束;页脚通过回放逐字节往返;状态检查点的来源记录其更宽的输入范围。 +- 在全部摘要就绪且保护逻辑通过同类核算前,不提交任何内容;保护失败不会提交任何内容,也不会让轮次失败;提交中途被终止后,下一次 pre-step 会恢复处理,从日志读取合并基线,并无条件提交状态区域以完成本轮;旧版头部检查点会被视为状态类。 +- `history_read` 在预算内渲染任意已记录检查点的区段,并提供可用游标;`history_search` 覆盖每个被遮蔽区段,返回带检查点 id 的片段与覆盖元数据,测试尤其要能找到只存在于被已取代状态检查点遮蔽区段中的内容,这是锁定尾随切片可达性的回归用例;两个工具都会拒绝非 agent 调用方,并对从未存在的 id 或遗留 `compact/start` 返回类型化错误;回溯内容作为普通 `tool/result` 出现;请求重建不变量会在同时包含压缩与回溯的会话上通过;一项无密钥快照场景端到端覆盖先压缩再回溯;工具 schema 与提示词章节在各轮之间逐字节相同。 +- 在长时间跨度 bench 套件中:任务成功率在预算相同的条件下不低于 `compact-basic`;交接保真探针(在一轮后重新陈述 K 项已知决策和约束)的得分不降低;每次运行都通过 dsh bench 报告流水线汇报回溯使用频率和命中有效性,并同时报告存根目录注意力度量与缓存命中遥测。 +- seam JSDoc、压缩能力 seam Agent Note、`architecture.md`,以及生成的工具、配置、持久化与模块图目录都在同一改动中更新;全部预算位于配置中;新源码目录具备逐文件 100% 覆盖率与 HMR(热模块替换)释放测试。 + +## 风险 + +- **回溯属于学习到的行为**:未经训练的模型会低频使用它,bench 报告会持续追踪这项差距,直至训练弥合问题。在此之前,状态检查点会让质量下限保持在当前摘要水平。 +- **未知的未知仍然存在**:如果某项细节既未出现在摘要中,也未出现在关键词中,就不会触发回溯。回溯把「即使已经怀疑也无法触达」变成「怀疑时可以触达」。 +- **存根目录会占用注意力**:每次请求中包含数十张稳定的索引卡,可能稀释模型关注点;验收标准中的 bench 度量会将其与 `compact-basic` 对比。 +- **成本**:每轮摘要输入大约是当前实现的两倍;短会话的成本和质量接近当前水平,而设计收益随会话长度增长。 +- **状态漂移与职责分工泄漏** 可以通过交接探针和存根评审观察;对应措施已列为后续事项。 +- **两个后端** 会增加维护接口;seam 契约和共享回溯消费方会约束该成本,bench 对比则用于逐步决定默认实现。 diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml new file mode 100644 index 0000000000..d392c8cf22 --- /dev/null +++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.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 +2026-07-13-human-review-skill-maintenance.md: 76391bd110b7a86b194a9340ffcf7cc4602d1d2e +2026-07-13-human-review-skill-maintenance.zh.md: 67d68d07cc7f467a310b64d833a28da6f04bcc8e diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md index 6d4de215c9..76391bd110 100644 --- a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md +++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md @@ -2,6 +2,8 @@ Status: proposed +English | [中文](2026-07-13-human-review-skill-maintenance.zh.md) + ## Problem The `dsh-code-review` skill records failure modes that require reviewer judgment, but one-off audits are expensive to repeat and easy to scope inconsistently. Treating every comment as a lesson produces checklist bloat; treating merge, thread resolution, or an author's “fixed” reply as proof of adoption promotes feedback that the final code may not implement. The maintenance process needs enough evidence and independent review to fail closed without requiring a webhook service, durable event state, or automatic repository promotion before the workflow has proven useful. diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md new file mode 100644 index 0000000000..67d68d07cc --- /dev/null +++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md @@ -0,0 +1,85 @@ +# Agent Note: dsh-code-review 的定期人工评审维护 + +Status: proposed + +[English](2026-07-13-human-review-skill-maintenance.md) | 中文 + +## 问题 + +`dsh-code-review` skill(技能)记录需要评审人判断的失败模式,但一次性审计既难以重复,作用域也容易不一致。把每条评论都当作教训会让检查清单不断膨胀;把合并、讨论串已解决或作者回复「已修复」视为采纳证据,则会把最终代码可能并未落实的反馈提升为规则。维护流程需要足够的证据和独立评审,以便在证据不足时按不采纳处理,同时无需在工作流证明有用之前引入 webhook 服务、持久事件状态或自动仓库推广。 + +## 提案 + +在仓库外定期维护。一项保存在 skill 维护者机器上、而不提交到本仓库的私有工具,会针对刷新至 `origin/master` 的干净完整历史 checkout 运行。预期的调度器每天运行,并使用两天的 UTC 重叠窗口;手动运行可以通过另一个 `--since` 时长或重复的 `--pr` 参数指定显式集合。扫描相对于当前 skill 是幂等的,不存储仓库游标。推广时唯一会变更的仓库文件是 [.agents/skills/dsh-code-review/SKILL.md](../../../skills/dsh-code-review/SKILL.md);draft PR(Pull Request)携带来源概述,因此评审人无需私有适配器日志,也能审计源反馈与采纳证据。 + +```mermaid +flowchart TD + A["Maintainer or scheduler runs the tool on origin/master"] --> B["List PRs merged in the overlap window"] + B --> C["Collect pre-merge User feedback and final PR evidence"] + C --> D["Two reviewers verify provenance and adoption"] + D --> E{"Both confirm human-authored and adopted?"} + E -- "No" --> F["Exclude or retain as unresolved"] + E -- "Yes" --> G["Two reviewers classify against the current skill"] + G --> H["Draft a complete candidate from agreed guidance"] + H --> I["Two reviewers inspect the same skill diff"] + I -- "Blocking finding" --> J["Bounded revision loop"] + J --> I + I -- "Both approve" --> K["Run documentation and lint checks"] + K --> L["Leave a reviewed local working-tree diff"] +``` + +### 采集契约 + +每个选定的 PR 都会在获取任何反馈前接受过滤:其 merge commit 必须是 `origin/master` 的祖先。merge commit 可达性是唯一的资格检查:直接 base 为功能分支的堆叠 PR,只要该 base 随后已经进入 master,就会被纳入,因为无论中间 stack 如何,评审人评论的代码此时已经在 master 上。工具还会解析落地 merge 的目标父级;无法重建的落地形态会记录到 `skipped-pulls.json` 并跳过。单个 PR 在预检、采集或证据收集时失败,只会被跳过,不会中止整次运行。如果时间窗口会超过 GitHub 搜索的 1,000 条结果上限,搜索阶段会明确失败,避免无提示地遗漏已合并 PR。采集阶段会完整读取内联评审评论、评审提交和 PR commit 的分页连接。不采集 PR 对话评论,因为强制推送后,GitHub 当前状态无法证明哪个仍然存在的 commit 位于评论之前,采纳契约因此必然会无条件排除这些评论。只有当 GitHub 报告 actor `type` 为 `User`,并且创建时间与最后编辑时间都严格早于 PR 合并时,工作流才接受已采集反馈;与合并同一时间戳的编辑视为合并后操作。评审提交使用 GraphQL 的 `lastEditedAt`,因为 REST 表示不含编辑时间。 + +### 采纳证据 + +每条反馈都携带稳定的来源 ID 和有界的变更证据。评审人的 `commit_id` 仍属于该 PR 时(强制推送场景无法确认即排除),工具会选择 committer 时间戳严格早于反馈的最新 PR commit 作为基线,而不是采用评审人点击的 commit,因为后者可能更旧。工具绝不会直接比较该基线与落地 merge:这种 diff 会混入不断前移的目标分支中的无关变更。相反,它会向采纳评审人提供两份 PR 专用 patch 快照。令 `B` 为反馈基线,`T` 为落地 merge 的目标父级,`M` 为落地 merge。反馈时快照是从 `merge-base(B, T)` 至 `B` 的树 diff;最终快照是从 `T` 至 `M` 的树 diff。因此,目标分支专有变更不会出现在任一 PR patch 中,而反馈后加入 PR 的变更只会出现在最终快照中。遭到强制推送的评审、早于全部现存 PR commit 的反馈,以及无法重建目标父级的落地形态,会在任何评审人看到之前确定性地归类为 `unclear`。合并状态、已解决讨论串、作者回复「已修复」或同文件编辑只是上下文,不是采纳证明;PR 作者自己的评论绝不会进入适配器,因为它们不可能构成对作者自身意见的采纳。 + +### 双评审人分类与起草 + +两个独立配置的评审适配器,会从来源(`human-authored`、`forwarded-automation` 或 `unclear`)和采纳情况(`adopted`、`rejected` 或 `unclear`)两个维度,对每个符合条件的条目进行分类。只有两个适配器都判定为 `human-authored` 加 `adopted` 的条目才会继续。采纳集合随后会针对当前 skill 接受第二次独立分类:候选项、已经覆盖、实现专用或并非反馈。单个条目即可符合要求,不要求重复出现。意见分歧会得到一次有界的重新评估;如果仍未解决,则继续保留在运行产物中。单个批次的适配器输出如果未通过 schema 或 id 校验,系统会在批次层按不采纳处理:其中的每条反馈都标记为 unclear 并路由到 `excluded`,而不是中止整次运行;有问题的原始输出会保存在该次运行的私有产物中,供调试使用。如果任一适配器在某项操作的任何非空批次中都没有返回有效结果,运行会以非零状态退出并发出失败记录,而不会报告「没有候选项」。 + +主适配器只根据结构化的共同指引起草,绝不接收原始评审文本。根据适配器作者的契约,它保持无工具且只读:返回完整的候选文件内容,由工具校验后写入唯一目标。两个适配器随后评审同一份完整 skill diff;阻塞性问题会进入有界修订循环,而且两者必须批准同一版修订。工具会在运行文档和 lint 检查前,以及报告成功前,再次拒绝暂存改动和目标 skill 之外的编辑,因此检查或并发进程无法通过添加其他路径混入。失败时,工具使用尽力而为的比较并交换恢复自身写入,避免覆盖维护者的并发编辑。成功时,它保存一份候选资料包,其中包含源 `origin/master` commit、源 skill blob ID、已评审 diff、完整候选文件、源反馈 ID 与 URL、落地证据范围、适配器判定和检查结果;它绝不提交、推送、打开或合并 PR。 + +### 评审适配器协议 + +每个私有可执行文件从 stdin 接收有字节上限、带版本的 JSON 请求,并在 stdout 返回有字节上限且符合 schema 的 JSON。两个评审命令解析为逐字节相同的可执行文件时,工具拒绝运行;这是机械性的最低标准,保证主适配器与次适配器由独立提供方或模型驱动,仍是部署运维方的责任。`access` 与 `tools` 字段是适配器作者承担的契约标记,不是 OS 沙箱:评审子进程在清理后的环境中 spawn,其 `cwd` 指向私有运行目录而非仓库根目录;反馈包装在带随机数的 `` 块中,每个提示词都会要求模型把它视为数据;128 位随机数防止不受信任的正文伪造结束标签。每个子进程都采用有界、感知中止的进程树清理。适配器作者把每项操作实现为纯只读推理(inference);即使 `edit` 操作也只会在 JSON 中返回完整候选内容,由工具校验后写入唯一目标。每个生产 `git`/`gh`/检查命令同样在清理后的环境中 spawn,避免 pre-push 钩子的路由变量无提示地重定向维护工具。候选写入与失败回滚会针对最近一次写入内容使用尽力而为的比较并交换;回滚还会取消暂存目标,避免由适配器或检查暂存的候选项在失败运行后遗留并进入之后的 commit。 + +### 推广契约 + +推广辅助工具从刷新至 `origin/master` 的干净 checkout 开始;当前 skill blob 与资料包中记录的源 blob 不同时,它拒绝应用候选项。运维方随后重新运行维护分析,或手动把 diff 变基后重新评审候选项;辅助工具绝不会用陈旧的完整文件输出替换较新的 `SKILL.md`。应用仍然有效的候选项后,它会打开 draft PR,其正文列出源反馈 URL 或 ID、用作采纳证据的落地 commit 范围、来源运行、检查结果和任何运维方编辑。原始适配器提示词与响应保持私有,但仓库评审人会获得足够的来源信息,以判断每条提议规则是否确实来自已采纳的人类反馈。 + +### 机制所在位置 + +工具源码、适配器二进制文件、提供方凭据和预期的每日调度器保存在维护者机器上,不会提交到本仓库。本文规定协议,参考实现属于私有基础设施。该机制只服务于由单个运维方维护的一项 skill,因此,让机制编辑持续接受仓库评审的成本高于来源可追溯性的收益。如果该机制将来移交给第二位维护者,移交工作需要一篇后续 Agent Note(agent 决策记录)来修订本决策;任何接手者都应从运维文档 [docs/cookbook/maintaining-dsh-code-review.md](../../../../docs/cookbook/maintaining-dsh-code-review.md) 入手。 + +## 考虑过的替代方案 + +- **把工具放入本仓库。** 对单维护者作用域不予采纳:仓库维护开销(类型检查、lint、覆盖率与横切重构)会超过已提交来源信息的价值。未来移交时仍可重新考虑。 +- **记录每条反馈产生时的 PR head**:不予采纳,因为这需要持续运行的观察器、持久事件状态、重试和强制推送协调。定期维护会在可用时使用经过评审的 commit 证据,并在整 PR 证据范围过宽、无法确认时直接排除。 +- **持久化已处理 PR 游标**:不予采纳,因为带重叠的时间窗口扫描成本低廉,并且相对于当前 skill 天然幂等;游标状态反而带来恢复和漏事件问题。 +- **每次新评论都运行**:不予采纳,因为一轮评审会产生许多相关评论,并且缺少判断采纳情况所需的最终产物。 +- **把合并或讨论串解决视为采纳**:不予采纳,因为 PR 可能在反馈被拒绝、被取代或刻意不解决的情况下合并。 +- **自动创建或合并仓库改动**:不予采纳,因为工具首先需要通过有用的定期输出积累可信记录。维护者检查并通过普通仓库评审推广本地 diff。 +- **从已经修复的 bot 问题中学习**:不予采纳,因为来源契约限定为人类评审反馈。系统会在分析前按 actor 类型过滤,并通过来源评审排除转发自动化问题的人类账号。 +- **让同一个评审人既当作者又作最终裁决**:不予采纳,因为独立判定能在不受支持的概括进入 skill 之前暴露问题。 + +## 验收标准 + +从 `proposed/` 推广到 `implemented/`,需要在针对本仓库的真实端到端运行中观察到以下全部事实: + +- 私有工具从刷新至 `origin/master` 的干净 detached checkout 运行,并且要么报告「没有候选项」,要么只生成 `.agents/skills/dsh-code-review/SKILL.md` 的 working-tree diff。**2026-07-15 已观察:** 扫描 62 个已合并 PR,跳过 5 个(merge commit 不可达或采集超过 250 个 commit 的上限),考虑 426 条人类反馈,发现 0 个候选项。 +- 两个评审适配器独立配置(不同的提供方或模型),无需用户干预即可完成 analyze/adopt/review 流程。**2026-07-15 已观察:** 不同的主/次适配器约用 8 分钟完成采纳与分析;一次适配器 id 幻觉由批次级保守失败机制处理,没有中止整次运行。 +- 调度器在没有交互式终端的情况下触发工具,并通过持久通知通道把候选 diff(或「没有候选项」记录)送达运维方。 +- 一个受控采集场景会在反馈基线之后,向目标分支加入与反馈匹配的变更;评审证据排除该目标分支专有变更,同时保留后续由 PR 自身加入的变更。 +- 源 skill 改变后,推广辅助工具会拒绝候选项;仍然有效的候选项会按照上文定义的来源概述打开 draft PR。 +- 该工作流生成的至少一个候选 diff 会由运维方检查,并通过普通仓库 PR 评审推广到 `master`。该 PR 用以证明工作流能够把已采纳反馈转化为已交付的 skill 指引。 + +## 风险 + +- **根据 committer 时间戳推断因果关系。** 反馈 commit 基线通过比较 GitHub commit 时间戳与反馈创建时间戳选出;committer 时钟偏差与重写仍会留下误判采纳的残余窗口。与 GitHub 的 PR 事件流交叉比对可以进一步收紧,但需要采集定期工具作用域以外的事件。 +- **两个「非候选项」分类结果会直接路由到 `excluded`,不进入争议轮次。** 两个分类器都判断「不是候选项」,但对非候选原因意见不一时(例如 `covered` 与 `specific`),该条目会被排除而不是重新评估。两个分类器都同意它不会形成新的评审行为,所以争议轮次不会改变结果。 +- **超出字节哈希差异的双评审人独立性属于部署契约。** 两个命令解析为逐字节相同的可执行文件时,工具拒绝运行,但它无法验证两个不同包装层是否由不同提供方或模型驱动。运维方必须配置相互独立的主适配器与次适配器。 +- **候选写入与回滚使用尽力而为的比较并交换。** POSIX 上基于文件的比较并交换并不真正原子;窗口持续一个事件循环周期。该工具面向单用户定期维护,不考虑真正并发的编辑器。 +- **单维护者风险。** 由于机制位于单台机器,服务中断后,skill 维护会完全停止,直到运维方恢复服务,或通过一篇后续 Agent Note 把机制移交给新维护者。 diff --git a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml new file mode 100644 index 0000000000..b983ffd591 --- /dev/null +++ b/docs/cookbook/maintaining-dsh-code-review.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 +maintaining-dsh-code-review.md: 2b5d0d926ae922f2650daac33cf35991cb71c5e5 +maintaining-dsh-code-review.zh.md: c0e8b64fde3a67174878b4b0665712c9ba2e67c0 diff --git a/docs/cookbook/maintaining-dsh-code-review.md b/docs/cookbook/maintaining-dsh-code-review.md index 8af449b749..2b5d0d926a 100644 --- a/docs/cookbook/maintaining-dsh-code-review.md +++ b/docs/cookbook/maintaining-dsh-code-review.md @@ -1,5 +1,7 @@ # Maintaining the dsh-code-review skill +English | [中文](maintaining-dsh-code-review.zh.md) + The [`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill is kept current by a single designated operator running a private periodic maintenance tool. This cookbook is the entry point for that operator — and for anyone taking over the role — and for repo contributors who want to understand why skill updates arrive as small periodic PRs rather than one-off audits. The workflow itself is specified in the [human-review skill-maintenance Agent Note](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md). ## What the maintainer receives diff --git a/docs/cookbook/maintaining-dsh-code-review.zh.md b/docs/cookbook/maintaining-dsh-code-review.zh.md new file mode 100644 index 0000000000..c0e8b64fde --- /dev/null +++ b/docs/cookbook/maintaining-dsh-code-review.zh.md @@ -0,0 +1,64 @@ +# 维护 dsh-code-review skill + +[English](maintaining-dsh-code-review.md) | 中文 + +[`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill(技能)由一名指定操作员通过私有的周期维护工具持续更新。本实操手册(cookbook)既是该操作员和接任者的入口,也帮助仓库贡献者理解为何 skill 更新会以小型周期 PR(Pull Request)的形式出现,而不是一次性审计。工作流本身由[人工评审 skill 维护 Agent Note(agent 决策记录)](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md)规定。 + +## 维护者会收到什么 + +每天运行私有工具,并使用 2 个 UTC 日的重叠窗口;在拟议的调度器完成验收运行之前,操作员按相同频率手动调用包装脚本。每周手动恢复运行使用 7 日窗口。工作流会: + +1. 选择指定窗口内合并、且合并 commit 可从 `origin/master` 到达的 PR(每天运行默认选择 2 个 UTC 日,每周运行选择 7 日)。合并 commit 无法到达的 PR(例如父分支被 squash 的堆叠分支),或超出 250 个 commit 获取上限的 PR,会记录到 `skipped-pulls.json` 并跳过,不会中止本次运行。 +2. 收集合并前带 commit 锚点的人工评审反馈(行内评论和评审提交),然后比较反馈时与最终落地的 PR patch。它不获取 PR 会话评论,因为 GitHub 当前状态无法为这些评论提供可抵抗 force-push 的反馈时基线;它也不会把只存在于目标分支的变更作为采纳证据。 +3. 两个独立配置的评审适配器先对来源和采纳情况分类,再根据当前 skill 对双方一致认定已采纳的条目分类。 +4. 主适配器起草完整修订版 `SKILL.md`;两个适配器评审同一份 diff;只要仍有阻塞发现,循环就会继续,直到双方批准。 +5. 工具声明成功前,会针对候选版本运行 `pnpm run doc-sync` 和 `pnpm run lint`。 + +每次运行都把产物保存在操作员的机器上。保存的 diff、候选 `SKILL.md` 和提升 manifest(元数据清单)按时间戳命名,存放在 `~/dsh-code-review-outputs/` 下。manifest 记录源 master commit 与 skill blob、源反馈 ID 和 URL、已落地证据范围、适配器裁决和门禁结果;每个适配器的原始 I/O 留在私有临时目录中,该目录路径会写入通知和 `~/Library/Logs/dsh-code-review-maintainer/` 下的每日日志。维护 worktree 在每次运行后都会恢复为干净状态,避免操作员直接在维护副本中编辑。 + +## 操作员如何处理候选 diff + +某次运行产出候选版本时,macOS 会发出一条带 `dsh-code-review-promote ` 提示的通知。 + +1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受:维护者契约规定最终判断由操作员作出。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。 + + ```sh + ls ~/dsh-code-review-outputs/ # every candidate ever produced + less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.diff + less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.SKILL.md + less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.manifest.json + ``` + +2. **与运行产物交叉核验。** 提升 manifest 会把每条拟议规则映射到源反馈和已落地证据;每个适配器的详细 I/O、共识和采纳证据位于本次运行的私有临时目录中(路径见日志)。至少抽查一个候选项:链接的人工评论是否确实支持新增规则?链接的 PR 是否确实采纳了它? + +3. **从三种处理方式中选择一种:** + - **丢弃。** 删除保存的候选版本。下一次运行会依据届时的当前 skill,重新考虑同一份反馈。 + + ```sh + rm ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.{diff,SKILL.md,manifest.json} + ``` + - **暂存成批。** 如果更新很小,可以把候选版本留待与后续版本合并。源 skill 检查仍然适用;如果 `master` 先发生变化,请重新运行分析,或手动 rebase 并重新评审 diff。 + - **提升。** 在仓库的干净 `master` checkout 中运行提升辅助工具。它会刷新 `master`、验证当前 skill 与记录的源 blob 一致、应用保存的 diff,并创建一份 draft PR,其正文包含 manifest 的来源摘要。如果 skill 已发生漂移,它会停止而不是覆盖更新后的指导;操作员仍需在 GitHub 上评审 PR,并选择合并或关闭。 + + ```sh + cd ~/path/to/deepseek-harness # clean master + dsh-code-review-promote 2026-07-16T02-00-00Z + ``` + +4. **不要逐字提交适配器输出。** 提升过程中可以进行小幅编辑,例如收紧措辞、移除只有结合源 PR 上下文才有意义的示例、把规则并入现有规则。这些编辑是预期行为,也保留了工作流所依赖的「评审者判断」。合并前应在该分支上修订这些改动。 + +## 运行未产出候选版本时 + +只要每个非空分类阶段都至少产生一个有效的适配器结果,这就是常见情况。工具会在每日日志中记录「无候选版本」,不发送通知(避免提醒疲劳),然后继续。某天没有 skill 更新,说明工作流运行正常,而不是停滞。 + +## 中断与交接 + +该机制运行在一台机器上。操作员应随时处理以下中断: + +- **错过每日运行。** 2 日重叠窗口会自动覆盖一次漏跑;更长的间隔可通过设置 `DSH_CODE_REVIEW_SINCE=` 手动运行包装脚本来恢复。重叠窗口具有幂等性:当前 skill 已包含的指导会被归类为 `covered`,不会再次成为候选项。 +- **适配器提供方中断。** 当两个评审命令解析为逐字节相同的可执行文件时,工具会拒绝运行。某个批次的适配器响应未通过 schema 或 ID 校验时,该批次会整体 fail-closed(其中每个条目都标记为不明确),运行则继续;原始输出会保留以便调试。如果任一适配器在某项操作的所有非空批次中都未产生有效结果,本次运行就会失败、写入失败记录并通知操作员;它绝不会把提供方完全中断折叠成「无候选版本」。 +- **交接给另一名维护者。** 新建一篇取代当前记录的后续 Agent Note:要么把机制移入仓库,要么记录新操作员的私有设置。不要暗中转交工具;Agent Note 的风险章节已把「单维护者关键人风险」列为交接必须记录决策的原因。 + +## 操作员的私有设置位于何处 + +工具源代码、评审适配器、提供方凭据和调度器属于操作员的私有基础设施,按设计位于本仓库之外(参见 Agent Note 的「机制位于何处」章节)。本实操手册和 Agent Note 描述的是**工作流保证什么**;这些保证**如何**实现则属于私有基础设施问题。如果你是新操作员,应以 Agent Note 的 `## Proposal` 各节作为实现依据。 diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml new file mode 100644 index 0000000000..6ab341e8f5 --- /dev/null +++ b/docs/cordis-tutorial/01-first-plugin.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 +01-first-plugin.md: b730b7ad7dc9ebd2e5dc8af4f7a83bd58ac7d4d8 +01-first-plugin.zh.md: 1e6901c048f5268ddead0faca85eaf5eef9c7533 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 084f9964e1..b730b7ad7d 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -1,5 +1,7 @@ # 1. Your first plugin +English | [中文](01-first-plugin.zh.md) + In the loader configuration used here, a Cordis plugin module named-exports an `apply` function. When Cordis loads it, it calls `apply` with a **context** — the `ctx` object through which the plugin registers everything it contributes. ## Write the plugin diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md new file mode 100644 index 0000000000..1e6901c048 --- /dev/null +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -0,0 +1,95 @@ +# 1. 编写第一个插件 + +[English](01-first-plugin.md) | 中文 + +在本教程使用的 loader 配置中,Cordis 插件模块通过命名导出提供 `apply` 函数。Cordis 加载模块时,会用一个 **上下文** 调用 `apply`;该上下文就是 `ctx` 对象,插件通过它注册自己贡献的所有内容。 + +## 编写插件 + +在 `tmp/cordis-tutorial` 目录中(参见[环境设置](index.md#setup))创建 `hello.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'hello' + +export function apply(ctx: Context) { + console.log('hello from my first plugin') +} +``` + +`name` 导出项是可选的显示元数据;它用于在诊断信息中标识插件。 + +## 组合应用 + +本教程的启动器通过配置组装应用。创建 `cordis.yml`: + +```yaml +- name: './hello.ts' +``` + +该文件是一组 Cordis 配置项的列表。`name` 是模块指定符,可以是相对路径或 NPM 包(package)名;loader 会挂载每个配置项。各项会并发启动,因此它们在列表中的位置不保证插件的加载先后;顺序由服务依赖(`inject`,参见[第 3 章](03-services.md))决定,而非文件中的位置。 + +## 运行 + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +预期输出: + +``` +hello from my first plugin +``` + +当没有任何内容继续运行时,进程会自行退出。具体过程如下: + +1. 启动器创建根 `Context`,并挂载 **Loader** 插件。 +2. Loader 读取 `cordis.yml`,解析 `./hello.ts`,然后将其作为子插件挂载。 +3. Cordis 调用你的 `apply(ctx)`。 + +你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[TUI agent(智能体)](../../examples/tui-agent/cordis.yml) 就是一个更长的插件组合。 + +## 其他两种插件形态 + +函数是最常见的形态,但 Cordis 接受三种形态: + +```ts +import { Service, type Context } from 'cordis' + +// 1. Function plugin (what you just wrote). +export function apply(ctx: Context) {} + +// 2. Object plugin: an object with an `apply` method. +export const objectPlugin = { + name: 'object-plugin', + apply(ctx: Context) {}, +} + +// 3. Class plugin: a Service subclass (covered in chapter 3). +export class MyService extends Service { + constructor(ctx: Context) { + super(ctx, 'myTutorialService') + } +} +``` + +在你需要公开服务之前,请一直使用函数形态;[第 3 章](03-services.md)介绍了何时应当使用类形态。 + +## 尝试制造错误 + +让 `apply` 抛出异常: + +```ts ignore-check +export function apply(ctx: Context) { + throw new Error('apply exploded') +} +``` + +再次运行:进程会因该错误而终止。插件加载失败必须明确报错,不会仅跳过该配置项。 + +还需要尽早了解一个例外:如果某个配置项的模块无法被 **解析**,例如路径或包名拼写错误,Cordis 会通过 logger 服务报告错误,而不会使进程崩溃。在启动阶段,这条报告可能在 console 导出器开始观察之前丢失。如果新增配置项似乎没有任何效果,请先检查拼写。 + +下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml new file mode 100644 index 0000000000..6d9e4bb6fd --- /dev/null +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.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 +02-lifecycle-and-effects.md: f1b39e06e9d25c51ab2d76503025e2b6ffe90c73 +02-lifecycle-and-effects.zh.md: a6021ed7475a0045d480810747244274eb5b4198 diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md index 68a0f12ec8..f1b39e06e9 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -1,5 +1,7 @@ # 2. Lifecycle and effects +English | [中文](02-lifecycle-and-effects.zh.md) + A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in `ctx.effect()`. ## Effects diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md new file mode 100644 index 0000000000..a6021ed747 --- /dev/null +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -0,0 +1,98 @@ +# 2. 生命周期与 effect + +[English](02-lifecycle-and-effects.md) | 中文 + +Cordis 插件可能因配置编辑、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 `ctx.effect()` 中。 + +## Effect + +对于 Cordis 尚未管理的资源,例如定时器、连接或 watcher,应将其包装在 `ctx.effect()` 中并返回 disposer(dispose(资源释放)函数): + +创建 `lifecycle.ts`,将它放在 `tmp/cordis-tutorial` 中: + +```ts +import type { Context } from 'cordis' + +export const name = 'lifecycle-demo' + +function heartbeat(ctx: Context) { + console.log('heartbeat plugin loading') + ctx.effect(() => { + const timer = setInterval(() => console.log('tick'), 200) + return () => { + clearInterval(timer) + console.log('heartbeat cleaned up') + } + }) +} + +export function apply(ctx: Context) { + // Mount a child plugin and keep its fiber to dispose it later. + const fiber = ctx.plugin(heartbeat) + // The demo timer is itself an effect: if THIS plugin is unloaded first, + // the pending callback is cancelled instead of firing on a dead app. + ctx.effect(() => { + const timer = setTimeout(async () => { + await fiber.dispose() + console.log('disposed') + process.exit(0) + }, 700) + return () => clearTimeout(timer) + }) +} +``` + +让 `cordis.yml` 指向该文件: + +```yaml +- name: './lifecycle.ts' +``` + +运行(`node --import tsx ../../vendor/cordis/bin.js`)后会得到: + +``` +heartbeat plugin loading +tick +tick +tick +heartbeat cleaned up +disposed +``` + +请留意三点: + +- `ctx.plugin(heartbeat)` 会把一个**来自代码**的函数挂载为插件,这与 YAML loader 为每个配置项执行的操作相同。函数插件不需要 `apply` 方法:Cordis 会直接调用该函数,其名称只用于诊断。只有对象形态才要求 `apply` 方法,例如 `ctx.plugin({ apply(ctx) { /* ... */ } })`。调用会返回一个 **fiber**,即一个已加载插件实例的运行时句柄。 +- effect 主体在加载期间运行;它返回的 disposer 在卸载期间运行。对于生命周期与插件一致的资源,你绝不需要自行调用 disposer。 +- `fiber.dispose()` 会等该插件的所有清理工作(包括异步 disposer)完成后才结束,并递归卸载它挂载的所有子插件。 + +## Fiber 状态机 + +每个已加载插件实例都拥有一个 fiber,并依次经过以下状态: + +``` +PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED + ↘ FAILED +``` + +- **PENDING**:已经声明,但所需服务(第 3 章)尚不可用。 +- **LOADING / ACTIVE**:`apply` 正在运行/已经完成。 +- **FAILED**:`apply` 或配置校验抛出异常。 +- **UNLOADING / DISPOSED**:disposer 正在运行/一切均已拆除。 + +你会在[第 6 章](06-composition-and-hmr.md)再次遇到 PENDING,它通常就是「为什么我的插件没有输出」的答案。 + +## 已经属于 effect 的操作 + +你很少需要亲自编写 `ctx.effect()`,因为内置注册 API 本身已经是 effect: + +- `ctx.on(event, listener)`:监听器会在卸载时移除([第 4 章](04-events.md))。 +- `ctx.plugin(child)`:子插件会随父插件一同 dispose。 +- 服务注册属于 effect。`ctx.tools.register(...)` 等 harness 注册表也会把返回的 disposer 附着到调用插件上,因此会自动回卷([第 7 章](07-into-the-harness.md))。 + +对于 Cordis 不管理的资源,应在 `ctx.effect()` 内获取它,并返回用于释放资源的 disposer。此后 Cordis 会在卸载期间调用该释放逻辑,热重载时也不例外。 + +有一项顺序注意事项:disposer 会按注册顺序的逆序启动,但多个**异步** disposer 会并发运行。如果拆除步骤必须按顺序执行,请把它们放在同一个 disposer 中,并在其中依次等待每步完成。 + +下一章:[服务](03-services.md):插件如何共享功能。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml new file mode 100644 index 0000000000..d42d5eb250 --- /dev/null +++ b/docs/cordis-tutorial/03-services.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 +03-services.md: 5848132c6ad18338fa893954d45fc20005db6199 +03-services.zh.md: 3c77d0451df9062f1a344e7474e6be141b709197 diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md index 9f62003e99..5848132c6a 100644 --- a/docs/cordis-tutorial/03-services.md +++ b/docs/cordis-tutorial/03-services.md @@ -1,5 +1,7 @@ # 3. Services +English | [中文](03-services.zh.md) + A **service** is a named capability one plugin provides and other plugins consume through `ctx`. In the harness, `ctx.tools`, `ctx.llm`, and `ctx.agents` are services. A consumer names the capability, such as `'tools'`, rather than importing its provider, so configuration can select a provider without changing the consumer. ## Provide a service diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md new file mode 100644 index 0000000000..3c77d0451d --- /dev/null +++ b/docs/cordis-tutorial/03-services.zh.md @@ -0,0 +1,98 @@ +# 3. 服务 + +[English](03-services.md) | 中文 + +**服务**是一个插件提供、其他插件通过 `ctx` 消费的命名功能。在 harness 中,`ctx.tools`、`ctx.llm` 和 `ctx.agents` 都是服务。消费方只命名 `'tools'` 之类的功能,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。 + +## 提供服务 + +创建 `greeter.ts`,将它放在 `tmp/cordis-tutorial` 中: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + greeter: GreeterService + } +} + +export class GreeterService extends Service { + constructor(ctx: Context) { + super(ctx, 'greeter') + } + + greet(who: string) { + return `Hello, ${who}!` + } +} + +export const name = 'greeter' + +export function apply(ctx: Context) { + ctx.plugin(GreeterService) +} +``` + +两部分协同工作: + +- **运行时**:`super(ctx, 'greeter')` 以名称 `greeter` 注册该实例。此后,任何插件都可以通过 `ctx.greeter` 访问它。注册属于 effect,卸载提供方时会移除该服务。 +- **编译时**:`declare module 'cordis'` 块使用 TypeScript 声明合并,把 `greeter` 加入 `Context` 接口,使 `ctx.greeter` 在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。 + +`Service` 子类本身就是插件(第 1 章介绍的类形态),因此 `ctx.plugin(GreeterService)` 会像挂载其他插件一样挂载它。 + +## 使用 `inject` 消费服务 + +创建 `consumer.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'consumer' +export const inject = ['greeter'] + +export function apply(ctx: Context) { + console.log(ctx.greeter.greet('world')) +} +``` + +`inject` 列出该插件需要的服务。Cordis 会让插件保持 PENDING,直到列出的每项服务都存在,因此在 `apply` 内可以保证 `ctx.greeter` 已经就绪。`cordis.yml` 中的加载顺序无关紧要:决定插件何时启动的是依赖关系,而不是文件顺序。 + +组合并运行: + +```yaml +- name: './greeter.ts' +- name: './consumer.ts' +``` + +``` +Hello, world! +``` + +交换 `cordis.yml` 中两行的顺序后重新运行,输出仍然相同。尝试彻底移除 `./greeter.ts`:消费方会保持 PENDING,不输出任何内容,既不崩溃,也不会只运行一部分。处于 PENDING 的 fiber 也不会让 Node 的事件循环保持活跃,因此如果组合中没有其他运行项,进程会静默地以状态码 0 退出。[第 6 章](06-composition-and-hmr.md)介绍如何诊断这种状态。 + +## 加载后仍会跟踪依赖关系 + +`inject` 并非一次性的启动检查。如果应用运行期间所需服务消失,例如提供方被卸载或热替换,每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect([第 2 章](02-lifecycle-and-effects.md)),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会回卷。 + +这也是配置中可以替换服务的原因:卸载 `dsh-bash-local` 配置项,挂载另一个 `bash` 提供方,所有注入 `'bash'` 的插件都会干净地重启并使用新实现。 + +## 可选依赖 + +`inject` 用于硬性依赖。如果某项功能缺失时插件仍可运行,请跳过 `inject`,并在使用处探测: + +```ts ignore-check +export function apply(ctx: Context) { + // undefined when no provider is loaded; the plugin still runs. + const greeter = ctx.get('greeter') + console.log(greeter?.greet('maybe') ?? 'no greeter available') +} +``` + +## 命名 + +每个应用中的服务名称共用一个扁平命名空间。请为自有服务添加有辨识度的前缀或命名空间(harness 已占用 `tools` 和 `llm` 等普通名称);生成的[服务目录](../cordis-catalog/services.md)列出 harness 注册的每个名称。 + +下一章:[事件](04-events.md):无需共享服务即可通信。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml new file mode 100644 index 0000000000..cd1fc962a7 --- /dev/null +++ b/docs/cordis-tutorial/04-events.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 +04-events.md: 18f39dc1b693e5fb7e1793ec4b7dcac9cf24db95 +04-events.zh.md: f55a61ff2f43ea42968893d07eb92ea0613b921a diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index 4c14ba5313..18f39dc1b6 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -1,5 +1,7 @@ # 4. Events +English | [中文](04-events.zh.md) + Services support direct calls; **events** let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions. ## Declare, emit, listen diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md new file mode 100644 index 0000000000..f55a61ff2f --- /dev/null +++ b/docs/cordis-tutorial/04-events.zh.md @@ -0,0 +1,144 @@ +# 4. 事件 + +[English](04-events.md) | 中文 + +服务支持直接调用;**事件**让插件无需知道有哪些插件正在监听,就能发出通知。harness 使用事件处理工具结果、模型请求和审批决定等交互。 + +## 声明、发出与监听 + +创建 `stats.ts`,将它放在 `tmp/cordis-tutorial` 中。它是一项负责计数并在每次变化时发出通知的服务: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + stats: StatsService + } + interface Events { + 'stats/report'(name: string, count: number): void + } +} + +export class StatsService extends Service { + private counts = new Map() + + constructor(ctx: Context) { + super(ctx, 'stats') + } + + bump(name: string) { + const next = (this.counts.get(name) ?? 0) + 1 + this.counts.set(name, next) + this.ctx.emit('stats/report', name, next) + } +} + +export const name = 'stats' + +export function apply(ctx: Context) { + ctx.plugin(StatsService) +} +``` + +`interface Events` 合并与第 3 章的 `interface Context` 合并在事件系统中相互对应:它声明事件名称及其监听器签名,因此 `ctx.emit` 和 `ctx.on` 都具有完整类型。`namespace/action` 命名约定让扁平的事件命名空间保持易读。 + +创建 `reporter.ts`: + +```ts ignore-check +import type { Context } from 'cordis' +import type {} from './stats.ts' + +export const name = 'reporter' +export const inject = ['stats'] + +export function apply(ctx: Context) { + ctx.on('stats/report', (name, count) => { + console.log(`[stats] ${name} -> ${count}`) + }) + ctx.stats.bump('tool_call') + ctx.stats.bump('tool_call') + ctx.stats.bump('prompt') +} +``` + +`import type {} from './stats.ts'` 行不会在运行时导入任何内容;它的作用是让 TypeScript 看到声明合并。组合并运行: + +```yaml +- name: './stats.ts' +- name: './reporter.ts' +``` + +``` +[stats] tool_call -> 1 +[stats] tool_call -> 2 +[stats] prompt -> 1 +``` + +因为 `ctx.on()` 属于 effect,监听器会随插件一同消失,绝不需要手动维护 `removeListener`。 + +## 分发模式 + +`emit` 是 5 种分发模式之一。事件采用哪种模式是其契约的一部分,决定了监听器能否返回值、能否并发运行,以及能否彼此短路: + +| 模式 | 调用 | 语义 | +|---|---|---| +| emit | `ctx.emit(name, ...args)` | 同步广播;不会等待或收集返回的 promise 与值。 | +| parallel | `await ctx.parallel(name, ...args)` | 所有监听器并发运行,并一同等待。 | +| serial | `await ctx.serial(name, ...args)` | 监听器按顺序运行并等待;第一个非 `null`/`false`/`undefined` 返回值胜出,并停止后续监听器。 | +| bail | `ctx.bail(name, ...args)` | serial 的同步版本。 | +| waterfall(瀑布式事件) | `ctx.waterfall(name, ...args, next)` | 环绕中间件,见下文。 | + +每个 harness 事件都会在生成的[事件目录](../cordis-catalog/events.md)中记录其模式。 + +## Waterfall:转换或短路 + +waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 `next()` continuation;它可以转换 `next()` 的返回值,也可以不调用 `next()` 就直接返回,从而短路链条的其余部分。Cordis 文档把后一种行为称为否决。创建 `waterfall-demo.ts`: + +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'demo/transform'(input: string, next: () => Promise): Promise + } +} + +export const name = 'waterfall-demo' + +export function apply(ctx: Context) { + // Listener 1: wrap the downstream result. + ctx.on('demo/transform', async (input, next) => { + const downstream = await next() + return downstream.toUpperCase() + }) + + // Listener 2: short-circuit when it owns the decision. + ctx.on('demo/transform', async (input, next) => { + if (input.includes('blocked')) return '** blocked **' + return next() + }) + + void (async () => { + console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello')) + console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words')) + })() +} +``` + +让 `cordis.yml` 只指向该文件并运行: + +``` +HELLO +** BLOCKED ** +``` + +按顺序看第二行如何产生:监听器 1 先运行并调用 `next()`,从而调用监听器 2;监听器 2 看到 `blocked` 后直接返回而不调用 `next()`,因此最内层默认逻辑(传给 `ctx.waterfall` 的函数)从未运行;返回途中,监听器 1 再把替换消息转换为大写。 + +由此得到一项纪律:**只负责观察或标注的 waterfall 监听器必须调用 `next()`**;不调用就直接返回代表有意短路。如果日志监听器忘记调用 `next()`,会悄无声息地吞掉所有下游的默认行为。这一点极其重要,已成为本仓库的常设规则([waterfall 语义](../cordis-primer.md#cordis-waterfall-semantics))。 + +harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`agent/request`](../cordis-catalog/events.md#agentrequest--waterfall) 允许插件替换模型调用配置,[`approval/request`](../cordis-catalog/events.md#approvalrequest--waterfall) 允许策略代替用户作答。 + +下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml new file mode 100644 index 0000000000..deb6f119c2 --- /dev/null +++ b/docs/cordis-tutorial/05-config.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 +05-config.md: fc19add239636fa9e7071d9c77e48595caec1f08 +05-config.zh.md: 52a75e40672c9a08d285677dd14dcd404b925e5a diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index 09aaf8971d..fc19add239 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -1,5 +1,7 @@ # 5. Configuration +English | [中文](05-config.zh.md) + Each `cordis.yml` entry can carry a `config` block, and the plugin declares a schema that validates it before `apply` runs. Bad config fails the load with a precise error — the plugin never starts half-configured. ## A configurable plugin diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md new file mode 100644 index 0000000000..52a75e4067 --- /dev/null +++ b/docs/cordis-tutorial/05-config.zh.md @@ -0,0 +1,84 @@ +# 5. 配置 + +[English](05-config.md) | 中文 + +每个 `cordis.yml` 配置项都可以携带 `config` 块,插件则声明一个 schema,在运行 `apply` 前验证该块。错误配置会导致加载失败,并给出准确的错误:插件绝不会在配置不完整时启动。 + +## 可配置插件 + +创建 `config-demo.ts`,并将其放在 `tmp/cordis-tutorial` 中: + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' + +export const name = 'config-demo' + +export interface Config { + greeting: string + targets: string[] +} + +export const Config: Schema = Schema.object({ + greeting: Schema.string().default('Hello'), + targets: Schema.array(String).default(['world']), +}) + +export function apply(ctx: Context, config: Config) { + for (const target of config.targets) { + console.log(`${config.greeting}, ${target}!`) + } +} +``` + +导出的 `Config` 既是 TypeScript 接口,也是同名的运行时 schema:消费方获得类型,Cordis 获得验证器。本仓库使用 [Schemastery](https://github.com/shigma/schemastery) 定义 schema;Cordis 本身接受任意 [Standard Schema](https://standardschema.dev/) 验证器,因此将普通对象导出为 `Config` 无法工作。 + +对其进行配置: + +```yaml +- name: './config-demo.ts' + config: + targets: ['alpha', 'beta'] +``` + +运行: + +``` +Hello, alpha! +Hello, beta! +``` + +未提供 `greeting`,因此 schema 默认值会将其补齐:`apply` 始终会收到完整且经过验证的配置。 + +## 明确报错 + +现在向它传入无效内容: + +```yaml +- name: './config-demo.ts' + config: + targets: 'not-an-array' +``` + +``` +ValidationError: invalid config: + - $.targets expected array but got not-an-array (at targets) +``` + +插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的 schema 有效配置命名了不可用的资源或提供方,该插件也应当在能解析该引用时立即拒绝。 + +## 计算得到的配置值 + +本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值,例如从环境中读取 API key: + +```yaml +- name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY +``` + +`!!js` **仅在 `config` 内有效**。配置项元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。 + +下一章:[组合与 HMR](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml new file mode 100644 index 0000000000..01f9345de3 --- /dev/null +++ b/docs/cordis-tutorial/06-composition-and-hmr.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 +06-composition-and-hmr.md: 66d6a9d93fe39baa881940ba32388979e2678505 +06-composition-and-hmr.zh.md: ebe63fc26607ae6d9344c4795a7975496ed901b5 diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md index bb236cc169..66d6a9d93f 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -1,5 +1,7 @@ # 6. Composition and HMR +English | [中文](06-composition-and-hmr.zh.md) + Every capability built so far is a plugin, and `cordis.yml` selects the application's plugin tree. This chapter changes that composition, hot-reloads a plugin, and diagnoses a plugin that never loads. ## Entries are more than a name diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md new file mode 100644 index 0000000000..ebe63fc266 --- /dev/null +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -0,0 +1,113 @@ +# 6. 组合与 HMR(热模块替换) + +[English](06-composition-and-hmr.md) | 中文 + +到目前为止构建的每项功能都是插件,`cordis.yml` 则选择应用的插件树。本章会改变这种组合、热重载一个插件,并诊断始终无法加载的插件。 + +## 配置项不只有名称 + +配置项除了 `name` 和 `config`,还接受其他元数据: + +```yaml +- id: greeter # stable identity for this entry + name: './greeter.ts' +- id: consumer + name: './consumer.ts' + disabled: true # keep the entry, skip mounting it +``` + +`id` 为配置项提供稳定标识,使 loader 能区分修改现有配置项与先删除再添加。`disabled: true` 会卸载插件而不删除其配置项;改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。 + +组可以嵌套一份配置项子列表,并将其作为一个单元加载和卸载;`isolate` 则为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的 `bash`,互不影响。这些概念值得在用到之前先了解;[Cordis 入门](../cordis-primer.md)和[服务隔离示例](../user/develop/framework/service.md#service-isolation)介绍了详细内容。 + +## 热模块替换 + +卸载会释放 effect([第 2 章](02-lifecycle-and-effects.md)),加载则遵循依赖关系([第 3 章](03-services.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@cordisjs/plugin-hmr` 插件会监视文件,并在保存时执行这一过程。 + +在 `tmp/cordis-tutorial` 中编写 `cordis.yml`: + +```yaml +- id: logger + name: '@cordisjs/plugin-logger-console' +- id: timer + name: '@cordisjs/plugin-timer' +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] +- id: hello + name: './hello.ts' +``` + +列表中增加了两个支持插件:HMR 通过 Cordis logger 服务记录日志,因此没有 console exporter 时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@cordisjs/plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。 + +HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构。请在 tsx 下运行 Cordis: + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +现在编辑 `hello.ts`,修改日志消息并保存: + +``` +hello from my first plugin +2026-07-22 15:44:36 [I] hmr watching [ '.' ] +2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts +hello from my EDITED plugin +``` + +旧实例先卸载(其所有 effect 都会回卷),新代码随后加载,`apply` 再次运行。按 Ctrl-C 停止进程。编辑 `cordis.yml` 本身也会触发更新:loader 按 `id` 比较配置项,只挂载、卸载或重新配置发生变化的部分。这就是上述配置项显式携带 `id` 的原因:不带该字段的配置项在每次读取时都会获得一个新生成的 id,所以只要配置文件发生任何编辑,即使自身文本未变,它也会被视为先删除再添加并重新挂载。 + +## 诊断始终无法加载的插件 + +依赖驱动加载也有另一面:如果插件的 `inject` 指定了无人提供的服务,它就会一直等待,不输出任何内容。这不是错误,因为 PENDING 是合法状态,提供方可能稍后才挂载。 + +你可以直接查看这些状态。每个上下文都能枚举插件注册表;创建 `diagnose.ts`: + +```ts +import { FiberState, type Context } from 'cordis' + +export const name = 'diagnose' + +export function apply(ctx: Context) { + setTimeout(() => { + for (const runtime of ctx.registry.values()) { + for (const fiber of runtime.fibers) { + if (fiber.state === FiberState.PENDING) { + console.log(`${fiber.name} is PENDING — a required service is missing`) + } + } + } + }, 500) +} +``` + +再创建一个依赖无法满足的插件 `needs-timer.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'needs-timer' +export const inject = ['timer'] + +export function apply(ctx: Context) { + console.log('needs-timer loaded') +} +``` + +```yaml +- name: './needs-timer.ts' +- name: './diagnose.ts' +``` + +运行它(直接执行 `node --import tsx ../../vendor/cordis/bin.js`,按 Ctrl-C 停止): + +``` +needs-timer is PENDING — a required service is missing +``` + +`inject: ['timer']` 没有提供方。向列表添加 `- name: '@cordisjs/plugin-timer'` 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。 + +下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml new file mode 100644 index 0000000000..c85bcad755 --- /dev/null +++ b/docs/cordis-tutorial/07-into-the-harness.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 +07-into-the-harness.md: 6ec42c50fe5059955734fe7bc46117538dafaffc +07-into-the-harness.zh.md: 32b21b008837e2972a53db9d893788dc6a7de9a9 diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index a86c538d48..6ec42c50fe 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -1,5 +1,7 @@ # 7. Into the harness +English | [中文](07-into-the-harness.zh.md) + This chapter registers a model-callable tool with the harness's `tools` service, executes it through the harness tool pipeline, and observes the result event. It remains keyless and does not call a model. ## A tool plugin diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md new file mode 100644 index 0000000000..32b21b0088 --- /dev/null +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -0,0 +1,107 @@ +# 7. 进入 harness + +[English](07-into-the-harness.md) | 中文 + +本章会向 harness 的 `tools` 服务注册一个可由模型调用的工具,通过 harness 工具流水线执行它,并观察结果事件。整个示例无需密钥,也不会调用模型。 + +## 工具插件 + +创建 `greet-tool.ts`,将它放在 `tmp/cordis-tutorial` 中: + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { CallId } from '@deepseek-ai/dsh-llm' + +export const name = 'greet-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'greet', + description: 'Greet the named person.', + parameters: { + name: { type: 'string', required: true, description: 'Who to greet' }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args) { + return `Hello, ${args.name}!` + }, + })) + + // Drive one call through the real execution pipeline, standing in for + // the model. CallId brands the correlation id a provider would issue. + void (async () => { + const result = await ctx.tools.execute({ + callId: CallId('demo-1'), + name: 'greet', + arguments: { name: 'Cordis' }, + signal: new AbortController().signal, + }) + console.log('tool replied:', JSON.stringify(result.content)) + })() +} +``` + +这里的每个模式都来自前几章:`inject: ['tools']`([第 3 章](03-services.md))会让插件等待工具注册表就绪;`ctx.tools.register(...)` 会把注册 disposer 附着到插件([第 2 章](02-lifecycle-and-effects.md)),因此卸载时会注销工具。`defineTool` 将 `parameters` 规约转换为向模型展示的 JSON Schema,推导 `args` 的类型,并在 `execute` 运行前校验模型提供的参数。工具返回由 `output.schema` 声明的规范值;`output.render` 则另行生成原生且持久的结果内容。 + +## 观察插件 + +创建 `tool-logger.ts`。这是一个独立插件,通过 harness 的 `tools/result` 事件观察应用中的每次工具调用: + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' + +export const name = 'tool-logger' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.on('tools/result', (exec, result) => { + const text = result.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') + console.log(`[tool-logger] ${exec.name} -> ${text}`) + }) +} +``` + +`import type {} from '@deepseek-ai/dsh-tools'` 行会引入该包的声明合并,使 `'tools/result'` 及其 payload 具有类型。这与第 4 章导入 `stats.ts` 的做法相同,只是扩展到了包级别。 + +## 组合并运行 + +```yaml +- name: '@deepseek-ai/dsh-system-prompt' +- name: '@deepseek-ai/dsh-tools' +- name: './tool-logger.ts' +- name: './greet-tool.ts' +``` + +`@deepseek-ai/dsh-tools` 会注入 `systemPrompt` 服务,因为工具需要向系统提示词贡献 schema,所以组合中也要列出该服务的提供方。缺少提供方时,工具插件会像[第 6 章](06-composition-and-hmr.md)所述那样保持 PENDING。 + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +``` +[tool-logger] greet -> Hello, Cordis! +tool replied: [{"type":"text","text":"Hello, Cordis!"}] +``` + +logger 会先触发:`tools/result` 在结果物化过程中发出,早于 `execute` 的 promise 向调用方返回结果。两个插件都不知道另一个插件存在,它们由注册表服务和事件连接。 + +## 从这里走向完整 agent(智能体) + +真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和前端。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 + +后续可以阅读: + +- [构建工具](../user/develop/basic/tool.md):深入了解 `defineTool`,包括呈现和更丰富的 schema。 +- [三层功能设计](../user/develop/practice/index.md):harness 如何组织可替换功能。 +- 生成的[服务](../cordis-catalog/services.md)与[事件](../cordis-catalog/events.md)目录:可以注入和监听的所有内容。 +- [架构](../architecture.md):这些插件所处的系统地图。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml new file mode 100644 index 0000000000..275c700851 --- /dev/null +++ b/docs/cordis-tutorial/index.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 +index.md: af622ad4e35829c6283c40f1b0019d7959dac973 +index.zh.md: 35bad552ecce9c0496b0ed88b041a8109c81945b diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index 9cf3966117..af622ad4e3 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -1,5 +1,7 @@ # Cordis tutorial +English | [中文](index.zh.md) + Cordis is the plugin framework underneath the DeepSeek Harness SDK: a small runtime where every capability — tools, LLM adapters, file access, the agent loop itself — is a plugin mounted into a shared context. This tutorial teaches Cordis hands-on: each chapter is a runnable example you build in a scratch directory inside this repository, ending with a plugin wired into real harness services. The audience is agent developers. You do not need deep TypeScript experience; the [TypeScript notes](#typescript-notes) below explain the syntax that may be unfamiliar, and every chapter shows the exact commands and expected output. @@ -41,6 +43,8 @@ That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js)) 6. [Composition and HMR](06-composition-and-hmr.md) — the config file as a plugin tree, hot reload, and diagnosing a plugin that never loads. 7. [Into the harness](07-into-the-harness.md) — register a model-callable tool against real harness services. + + ## TypeScript notes The examples use three TypeScript features beyond ordinary modern JavaScript: diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md new file mode 100644 index 0000000000..35bad552ec --- /dev/null +++ b/docs/cordis-tutorial/index.zh.md @@ -0,0 +1,58 @@ +# Cordis 教程 + +[English](index.md) | 中文 + +Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行时,其中的每项能力,包括工具、LLM(大语言模型)适配器、文件访问乃至 agent loop(智能体循环)本身,都是挂载到共享上下文中的插件。本教程通过动手实践讲解 Cordis:每一章都是一个可以运行的示例,你将在本仓库内的临时目录中逐步构建它,最后把一个插件接入真实的 harness 服务。 + +本教程面向 agent 开发者。你不需要深入掌握 TypeScript;下文的 [TypeScript 说明](#typescript-notes)会解释可能陌生的语法,并且每一章都会给出确切命令和预期输出。 + +如果你想阅读精简的概念参考,而不是逐步实践,请参阅 [Cordis 入门](../cordis-primer.md)。详尽的 API 参考见生成的[事件](../cordis-catalog/events.md)与[服务](../cordis-catalog/services.md)目录,以及 [Cordis 核心 API](../cordis-catalog/core/context.md)页面。 + +## 准备工作 + +你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 + +```sh +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness +pnpm install +``` + +创建各章使用的临时目录。`tmp/` 已被 git 忽略,因此你在其中写入的任何内容都不会进入版本控制: + +```sh +mkdir -p tmp/cordis-tutorial +cd tmp/cordis-tutorial +``` + +每一章都从该目录运行同一条命令: + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +这个单文件启动器(见 [vendor/cordis/bin.js](../../vendor/cordis/bin.js))会创建根 `Context`、挂载 Loader 插件,并让它从当前目录加载 `./cordis.yml`。其余所有内容,包括有哪些插件以及如何配置它们,都来自你稍后将编写的 YAML 文件。`--import tsx` 标志让 Node 无需构建步骤即可运行配置所指向的 TypeScript 文件。 + +## 章节 + +1. [你的第一个插件](01-first-plugin.md):插件是函数,由 loader 挂载。 +2. [生命周期与 effect](02-lifecycle-and-effects.md):由 Cordis 管理的注册会在所属插件卸载时撤销。 +3. [服务](03-services.md):在 `ctx` 上公开一项能力,并通过 `inject` 依赖它。 +4. [事件](04-events.md):类型化事件、广播分发和 waterfall(瀑布式事件)的短路行为。 +5. [配置](05-config.md):读取 `cordis.yml` 中经过校验的配置,并在输入错误时快速失败。 +6. [组合与 HMR(热模块替换)](06-composition-and-hmr.md):把配置文件作为插件树,使用热重载,并诊断始终无法加载的插件。 +7. [进入 harness](07-into-the-harness.md):基于真实的 harness 服务注册一个可由模型调用的工具。 + + + +## TypeScript 说明 + +这些示例使用了普通现代 JavaScript 之外的三项 TypeScript 功能: + +- **类型注解** 描述值,但不会改变运行时行为:`ctx: Context` 表示 `ctx` 具备 Cordis 上下文 API,`who: string` 接受文本,而 `string[]` 表示字符串数组。 +- **`import type { Context } from 'cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。 +- **声明合并**(`declare module 'cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。 + +第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/core-data-structures/commands.i18n.yaml b/docs/core-data-structures/commands.i18n.yaml new file mode 100644 index 0000000000..ba55abec39 --- /dev/null +++ b/docs/core-data-structures/commands.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 +commands.md: 056c775f4c2e1586447db11821e5c7d56be01881 +commands.zh.md: 1a51305df356d8becf8c5517704dc375cdb8b585 diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md index c36942cf18..056c775f4c 100644 --- a/docs/core-data-structures/commands.md +++ b/docs/core-data-structures/commands.md @@ -1,5 +1,7 @@ # Human Commands +English | [中文](commands.zh.md) + The human-command seam of [`dsh-commands`](../../packages/ui/commands). Interactive adapters use it to discover and directly execute plugin-owned commands for an exact agent without creating a model message. The [command Agent Note](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns dispatch and lifecycle rationale; the [package README](../../packages/ui/commands/README.md) owns composition and limitations. Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) diff --git a/docs/core-data-structures/commands.zh.md b/docs/core-data-structures/commands.zh.md new file mode 100644 index 0000000000..1a51305df3 --- /dev/null +++ b/docs/core-data-structures/commands.zh.md @@ -0,0 +1,86 @@ +# 用户命令 + +[English](commands.md) | 中文 + +[`dsh-commands`](../../packages/ui/commands) 的用户命令 seam。交互式适配器用它发现插件拥有的命令,并针对确切的 agent(智能体)直接执行这些命令,而不创建模型消息。[命令 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) 负责分发与生命周期的决策依据;[包(package)README](../../packages/ui/commands/README.md) 负责组合方式与限制。 + +来源:[`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) + +## 输入元数据 + +该 seam 公开一个可选的非结构化输入提示。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 + +```ts type-equiv +/** Immutable metadata for a command's optional unstructured input. */ +interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} +``` + +## 定义 + +`CommandDefinition` 是由插件编写的注册定义。注册表会验证并冻结一份与原始注册对象脱离的生效定义。 + +```ts type-equiv +/** Plugin-owned command registration. */ +interface CommandDefinition { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Execute against the receiving agent without sending the command to the model. */ + readonly handler: (invocation: CommandInvocation) => CommandResult | Promise +} +``` + +## 调用与结果 + +适配器拥有取消操作,并传入确切的目标 agent。`rawInput` 紧接在解析后的名称之后,并保留适配器传入的分隔符与后缀。结果会直接呈现给 UI,而不是工具结果或会话事件。 + +```ts type-equiv +/** Invocation passed to one registered command handler. */ +interface CommandInvocation { + /** Exact agent whose human-facing surface received the command. */ + readonly agent: Agent + /** Exact text following the registered command name, including separator whitespace. */ + readonly rawInput: string + /** Cancellation signal owned by the dispatching UI request. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** Expected command outcome rendered directly by the dispatching UI. */ +type CommandResult = + | { readonly kind: 'success'; readonly text?: string } + | { readonly kind: 'error'; readonly text: string } +``` + +## 发现与解析视图 + +作用域解析后,适配器会获得不含处理器的不可变描述符。`parseCommand()` 在注册表解析前返回 `ParsedCommand`;语法有效的输入仍可能指向不可用的命令。 + +```ts type-equiv +/** Handler-free immutable command view returned to UI adapters. */ +interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor +} +``` + +```ts type-equiv +/** Syntactically valid slash command before registry resolution. */ +interface ParsedCommand { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Exact text following the command name. */ + readonly rawInput: string +} +``` diff --git a/docs/core-data-structures/goal.i18n.yaml b/docs/core-data-structures/goal.i18n.yaml new file mode 100644 index 0000000000..47dc0c1b1c --- /dev/null +++ b/docs/core-data-structures/goal.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 +goal.md: 2e8d296eeda6e5f69c0f92829e347b7f55f41fa9 +goal.zh.md: a9c946e7cd37cf948c7ac0f3e4d0ea35ac80d614 diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index c0351f2f77..2e8d296eed 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -1,5 +1,7 @@ # Same-session goals +English | [中文](goal.zh.md) + Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). ## Identity and lifecycle diff --git a/docs/core-data-structures/goal.zh.md b/docs/core-data-structures/goal.zh.md new file mode 100644 index 0000000000..a9c946e7cd --- /dev/null +++ b/docs/core-data-structures/goal.zh.md @@ -0,0 +1,145 @@ +# 同会话目标 + +[English](goal.md) | 中文 + +事件溯源目标领域及其策略消费方共享的类型。[目标领域 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的字面形态。 + +## 标识与生命周期 + +`GoalId` 是[品牌化 id](core.md#branded-ids)。调用方通过 `GoalRef` 修改一个确切修订版本;每次获准的持久变更都会递增修订号。 + +```ts type-equiv +/** Compare-and-set identity for one exact goal revision. */ +interface GoalRef { + /** Stable goal identity. */ + readonly id: GoalId + /** Positive revision; every durable mutation increments it. */ + readonly revision: number +} +``` + +持久阶段回答目标发生了什么。进程本地激活状态则另行回答续跑消费方能否开始另一个 Round。 + +```ts type-equiv +/** Durable continuation phase. Activation is process-local and separate. */ +type GoalPhase = + | 'active' + | 'paused' + | 'blocked' + | 'complete' +``` + +阻塞是唯一表示「因问题而停止」的持久状态。由策略负责的阻塞原因会携带一个用于路由、稳定且采用 lower-kebab-case 的代码,以及一段供人和模型阅读的自由文本说明。 + +```ts type-equiv +/** Machine-routable and human-readable explanation for a blocked goal. */ +interface GoalBlockReason { + /** Stable lower-kebab-case classification chosen by the blocking policy. */ + readonly code: string + /** Non-empty explanation shown to humans and models. */ + readonly message: string +} +``` + +```ts type-equiv +/** Full durable state written by every non-clear goal mutation. */ +interface GoalSnapshot extends GoalRef { + /** Human-requested completion objective. */ + readonly objective: string + /** Durable lifecycle phase. */ + readonly phase: GoalPhase + /** Present exactly while `phase` is `blocked`. */ + readonly blockedReason?: GoalBlockReason + /** Total admitted goal-round cap. */ + readonly maxGoalRounds: number +} +``` + +```ts type-equiv +/** Current goal projection, including values derived from the session log. */ +interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} +``` + +## 持久变更 + +每次变更都是 Round 编号为 0、来源为目标的 `user/message`,其元数据要么是完整快照,要么是清除墓碑。版本、元数据、目标来源和逐字渲染内容共同构成一项回放不变量。 + +```ts type-equiv +/** Full-snapshot goal mutation retained in a model-visible context event. */ +interface GoalSnapshotChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: Exclude + readonly goal: GoalSnapshot + readonly roundsStarted: number + readonly createdAt: number + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Tombstone retained when the current goal is cleared. */ +interface GoalClearChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: 'clear' + readonly cleared: GoalRef + readonly clearedAt: number +} +``` + +目标状态变更使用 Round `0`。续跑消费方会为每个获准的用户消息轮次标注正数且连续的 Round 编号和当前修订号;回放会拒绝编号缺口、陈旧修订号、已停止阶段和超出上限。 + +```ts type-equiv +/** Message attribution for durable goal state and continuation rounds. */ +interface GoalMessageSource { + readonly kind: 'goal' + readonly goalId: GoalId + readonly revision: number + /** Zero for state changes; positive for admitted continuation rounds. */ + readonly round: number +} +``` + +## 请求与通知 + +创建操作会区分调用方省略的值与部署选择,`create()` 会在内部解析后者。编辑是局部替换,其运行时校验器要求至少提供一个字段。每条变更通知都会携带获准的操作和确切修订号;清除操作不带 `goal`。 + +```ts type-equiv +/** Input whose omitted round cap is resolved by the service configuration. */ +interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} +``` + +```ts type-equiv +/** Fields changed by an edit; at least one must be present. */ +interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} +``` + +```ts type-equiv +/** Live notification after one goal mutation has been accepted for logging. */ +interface GoalChanged { + readonly operation: GoalOperation + readonly ref: GoalRef + /** Absent for a clear tombstone. */ + readonly goal?: GoalView +} +``` + +## 服务行为 + +[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更、叠加延迟注入,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用契约和面向模型的契约。 diff --git a/docs/core-data-structures/lsp.i18n.yaml b/docs/core-data-structures/lsp.i18n.yaml new file mode 100644 index 0000000000..5ab99680eb --- /dev/null +++ b/docs/core-data-structures/lsp.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 +lsp.md: 62b133cbfdf521e067c56355664d7514a613397f +lsp.zh.md: d7000970ec9114bcdad40a39d2712d48b9865529 diff --git a/docs/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md index eb370f6e38..62b133cbfd 100644 --- a/docs/core-data-structures/lsp.md +++ b/docs/core-data-structures/lsp.md @@ -1,5 +1,7 @@ # LSP navigation +English | [中文](lsp.zh.md) + The LSP seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) exposing semantic code navigation on one `ctx.lsp` service, split across packages: interface ([dsh-lsp](../../packages/lsp/lsp), `ctx.lsp` + the provider registry), a generic implementation ([dsh-lsp-local](../../packages/lsp/lsp-local), a configured stdio language-server host), and consumer ([dsh-tool-lsp](../../packages/lsp/tool-lsp), the `lsp` tool schema). LSP is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A provider swap does not change how the model asks for navigation. Source: [`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts) diff --git a/docs/core-data-structures/lsp.zh.md b/docs/core-data-structures/lsp.zh.md new file mode 100644 index 0000000000..d7000970ec --- /dev/null +++ b/docs/core-data-structures/lsp.zh.md @@ -0,0 +1,165 @@ +# LSP 导航 + +[English](lsp.md) | 中文 + +LSP seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md):它在单一 `ctx.lsp` 服务上公开语义代码导航,并拆分到多个包(package):接口([dsh-lsp](../../packages/lsp/lsp),`ctx.lsp` + 提供方注册表)、通用实现([dsh-lsp-local](../../packages/lsp/lsp-local),经过配置的 stdio 语言服务器宿主)和消费方([dsh-tool-lsp](../../packages/lsp/tool-lsp),即 `lsp` 工具 schema)。LSP 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换提供方不会改变模型请求导航的方式。 + +源文件:[`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts) + +## 操作与坐标 + +seam 与模型恰好公开 4 项语义查询;该联合是闭合的,因此新增一项查询会通过编译强制要求同步修改 seam、提供方和工具。位置与范围采用从零开始的 UTF-16 坐标,与协议一致;面向模型的工具采用从 1 开始的光标约定,并在输入和输出时进行转换。 + +```ts type-equiv +/** + * The four semantic queries the seam and model expose. A closed union: adding an operation is a + * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are + * deliberately deferred (they need different schemas). + */ +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' +``` + +```ts type-equiv +/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */ +interface LspPosition { + /** Zero-based line. */ + readonly line: number + /** Zero-based UTF-16 code-unit offset within the line. */ + readonly character: number +} +``` + +```ts type-equiv +/** A zero-based UTF-16 half-open range `[start, end)`. */ +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} +``` + +## 请求 + +每个字段都是必填项:`workspaceRoot` 由调用方提供,`languageId` 来自提供方注册而非请求,超时与结果上限由消费方决定。因此没有字段需要由实现提供默认值,也不存在 `resolve()` 步骤。提供方收到调用方请求和派生的 `languageId`;后者只用于同步瞬态文档,从不参与选择。 + +```ts type-equiv +/** + * A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied, + * `languageId` comes from the provider registration (not here), and consumers own timeouts and + * result limits — so no field needs implementation defaulting and there is no `resolve()` step. + */ +interface LspQueryRequest { + /** Which semantic query to run. */ + readonly operation: LspOperation + /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */ + readonly filePath: string + /** The zero-based UTF-16 cursor position to query at. */ + readonly position: LspPosition + /** The workspace root the provider resolves against and indexes; required, never defaulted. */ + readonly workspaceRoot: string +} +``` + +```ts type-equiv +/** + * A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId` + * the seam derived from the provider's extension mapping. The language id only synchronizes the + * transient document; it does not participate in selection. + */ +interface LspProviderQuery extends LspQueryRequest { + /** The LSP language id for `filePath`, from this provider's extension mapping. */ + readonly languageId: string +} +``` + +## 结果 + +这是一个闭合的可辨识联合:导航操作规范化为 `locations`,`hover` 规范化为内容或 `null`。消费方使用 `switch` 对 `kind` 做穷尽处理,因此新增分支会使编译失败,直到完成处理。`findReferences` 始终包含声明;提供方在内部强制保证这一点,因此调用方没有对应 flag。`locations` 变体携带 `resolvedWorkspaceRoot`,即提供方对请求中 `workspaceRoot` 的规范形式,也是其 `file:` URI 所相对的根目录;调用方在相对化显示路径时应使用它,而不是可能经过符号链接的请求根目录。 + +```ts type-equiv +/** One resolved location: a document URI and the range within it. */ +interface LspLocation { + /** The target document URI (`file:` or otherwise), verbatim from the server. */ + readonly uri: string + /** The range within the target document. */ + readonly range: LspRange +} +``` + +```ts type-equiv +/** Normalized hover content, or `null` for no hover at the position. */ +interface LspHover { + /** The normalized hover text (markdown or plaintext, provider-joined). */ + readonly contents: string + /** The range the hover applies to, when the server supplied one. */ + readonly range?: LspRange +} +``` + +```ts type-equiv +/** + * The closed result union. Navigation operations (`goToDefinition`, `findReferences`, + * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`. + * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. + * + * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the + * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that + * relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`; + * otherwise a symlinked workspace misclassifies in-workspace results as external. + */ +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string } + | { readonly kind: 'hover'; readonly hover: LspHover | null } +``` + +## 提供方与服务 + +每个提供方拥有一个稳定的品牌化 `id`,以及一份互斥的、小写且以点开头的扩展名映射。`registerProvider` 会原子保留 id 和每个扩展名:注册无效或冲突时不发布任何内容;其 disposer 会释放所有保留项。每次查询独立选择提供方,且选择与顺序无关;没有匹配项时抛出 `LspError` `LSP_UNAVAILABLE`。该 seam 不公开协议类型、进程或文档控制,也不提供通用 JSON-RPC 逃生口。 + +```ts type-equiv +/** + * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). + * `findReferences` always includes declarations — the provider enforces this internally; callers + * get no flag. + */ +interface LspProvider { + /** Stable provider identity, reserved atomically with the extension mappings. */ + readonly id: LspProviderId + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + readonly extensionToLanguage: Readonly> + /** + * Run one query. The seam has already selected this provider and derived `languageId`. + * @param request - the resolved provider query (caller request + derived language id). + * @param signal - optional cancellation; the provider stops its own work when it aborts. + * @returns the normalized, closed-union result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} +``` + +```ts type-equiv +/** + * The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query + * execution; exposes exactly the four operations and no protocol escape hatch. + */ +interface LspService { + /** + * Register a provider, atomically reserving its id and every normalized extension. Any conflict + * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all + * reservations. Disposed with the calling fiber. + * @param provider - the backend to register. + * @returns a synchronous disposer releasing the id and all extension reservations. + */ + registerProvider(provider: LspProvider): () => void + /** + * Select a provider by the file's extension and run one query. Selection is per-query and + * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. + * @param request - the normalized query. + * @param signal - optional cancellation forwarded to the selected provider. + * @returns the normalized, closed-union result. + */ + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +`LspProviderId` 是该 seam 的品牌化 id(来自 [dsh-brand](../../packages/util/brand) 的 `Branded<'LspProviderId'>`);`LspError` 扩展 `HarnessError`,提供 `LSP_INVALID_PROVIDER`、`LSP_CONFLICT`、`LSP_UNAVAILABLE`、`LSP_DISPOSED`、`LSP_UNSUPPORTED_OPERATION` 和 `LSP_MALFORMED_RESPONSE` 等稳定错误码,调用方应按错误码路由,而不是解析 `message`。 diff --git a/docs/core-data-structures/pty.i18n.yaml b/docs/core-data-structures/pty.i18n.yaml new file mode 100644 index 0000000000..6fa7d71d13 --- /dev/null +++ b/docs/core-data-structures/pty.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 +pty.md: 97e1e662d1128ab0555e34f8284cf69d7d9d0d1a +pty.zh.md: b17bc0d2c7bdb2a980df36824bd360ea975967f5 diff --git a/docs/core-data-structures/pty.md b/docs/core-data-structures/pty.md index b205ec64c9..97e1e662d1 100644 --- a/docs/core-data-structures/pty.md +++ b/docs/core-data-structures/pty.md @@ -1,5 +1,7 @@ # Persistent PTY Sessions +English | [中文](pty.zh.md) + Types shared by PTY backends, `ctx.pty`, and the model-facing consumer. The [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) owns the rationale; this page records the cross-package vocabulary from [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts). ## Identity and readiness diff --git a/docs/core-data-structures/pty.zh.md b/docs/core-data-structures/pty.zh.md new file mode 100644 index 0000000000..b17bc0d2c7 --- /dev/null +++ b/docs/core-data-structures/pty.zh.md @@ -0,0 +1,91 @@ +# 持久 PTY 会话 + +[English](pty.md) | 中文 + +PTY 后端、`ctx.pty` 与面向模型的消费方共享的类型。[持久 PTY Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 负责记录决策依据;本页记录来自 [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts) 的跨包(package)词汇。 + +## 标识与就绪 + +`PtySessionId` 是由服务铸造的品牌化 id。可选名称是拥有者本地的显示元数据;授权比较的是确切的所属 `Agent`,而不是名称或猜测的 id。 + +`PtyWaitReason` 说明一次发送为何返回。它与 `PtySessionStatus` 无关:一次发送可能因静默或超时而返回,但顶层 shell 仍然存活;`session_exit` 表示该 shell 已退出,而不是某个任意的前台子进程已退出。 + +```ts type-equiv +/** Why one interactive send returned control to its caller. */ +type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit' +``` + +```ts type-equiv +/** Top-level PTY process status, independent of a send's wait reason. */ +type PtySessionStatus = + | { kind: 'running' } + | { kind: 'exited'; exitCode: number | null; signal: NodeJS.Signals | null } +``` + +## 后端与活跃会话 + +后端负责某个已注册类型的启动方式和就绪检测。`PtyService` 只在初始化成功后才发布返回的会话,随后负责 id 授权与清理。无法清理部分启动资源的后端会以 `PtyBackendCleanupError` 拒绝,从而让资源释放流程保留该清理失败,同时不替换调用方的取消原因。后端会话拥有终端状态,并负责使已捕获资源完全停稳。 + +```ts type-equiv +/** Replaceable provider for one PTY session type. */ +interface PtyBackend { + /** Stable type selected by {@link PtySpawnRequest.type}. */ + readonly type: string + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link PtyBackendCleanupError}. */ + spawn(spec: PtyBackendSpawnSpec): Promise +} +``` + +```ts type-equiv +/** Backend-owned live session retained by {@link PtyService}. */ +interface PtyBackendSession { + /** Initial bounded terminal output returned from `terminal_open`. */ + readonly motd: string + /** Top-level process id when one exists. */ + readonly pid?: number + /** Start one exclusive send operation. */ + startSend(request: PtySendRequest): PtySendOperation + /** Read one bounded page from retained scrollback. */ + read(request: PtyReadRequest): PtyReadResult + /** Signal the verified foreground process group. */ + signal(signal: PtySignal): Promise + /** Observe top-level process status. */ + status(): PtySessionStatus + /** Idempotently close the captured owned process tree and await quiescence. */ + close(reason: string): Promise +} +``` + +## 发送与保留输出 + +一个活跃会话同时只接受一个活动发送。该操作向通用后台任务公开一个消费式输出游标,并向前台调用方公开一个最终结果。`PtyReadResult` 则为有界的会话 scrollback 单独分页。 + +```ts type-equiv +/** Live backend-owned send; exactly one may be active per PTY session. */ +interface PtySendOperation { + /** Resolves after readiness, timeout, cancellation, or top-level process exit. */ + done: Promise + /** Consume output produced since the prior call. */ + readOutput(): PtySendRead + /** Request `SIGINT`; returns false after the operation settled. */ + cancel(): boolean +} +``` + +```ts type-equiv +/** Settled result for one foreground or background send. */ +interface PtySendResult { + /** Bounded rendered terminal delta remaining at settlement. */ + viewport: string + /** Why the wait returned; this does not imply arbitrary child-process exit. */ + waitReason: PtyWaitReason + /** Top-level session status observed at settlement. */ + sessionStatus: PtySessionStatus + /** Whether output was dropped from the operation or retained scrollback. */ + truncated: boolean +} +``` + +## 归属与持久性 + +`PtyService` 会将一项等待完成的清理附加到确切的拥有者作用域,拒绝其他拥有者的操作,并让会话在后端或工具插件重载期间保持存活。PTY 状态与原始字节仍局限在进程内。模型输入与有界的返回输出通过现有 `tool/call`、`tool/result` 和任务结果路径持久保存,而不是重复记录 PTY 会话事件。 diff --git a/docs/core-data-structures/session-reference.i18n.yaml b/docs/core-data-structures/session-reference.i18n.yaml new file mode 100644 index 0000000000..2fd211e4e2 --- /dev/null +++ b/docs/core-data-structures/session-reference.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 +session-reference.md: 4898cdd641427a023dde63bfc9759300964c7fac +session-reference.zh.md: 8e36d70241f2565a587bd3c1ee270d99dba47d71 diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md index d9a73c08c6..4898cdd641 100644 --- a/docs/core-data-structures/session-reference.md +++ b/docs/core-data-structures/session-reference.md @@ -1,5 +1,7 @@ # Session References +English | [中文](session-reference.zh.md) + Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) owns canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. Source: [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) diff --git a/docs/core-data-structures/session-reference.zh.md b/docs/core-data-structures/session-reference.zh.md new file mode 100644 index 0000000000..8e36d70241 --- /dev/null +++ b/docs/core-data-structures/session-reference.zh.md @@ -0,0 +1,67 @@ +# 会话引用 + +[English](session-reference.md) | 中文 + +结构化的跨会话引用请求与预备消息上下文。[包(package)契约](../../packages/context/session-reference) 负责规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 + +来源:[`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) + +## 输入与候选项 + +`SessionReferenceInput` 是与宿主无关的选择。id 具有权威性;label 是随快照携带的显示元数据。 + +```ts type-equiv +/** One source session selected by a host. */ +interface SessionReferenceInput { + /** Opaque source session identity. */ + sessionId: SessionId + /** Optional user-facing mention label. */ + label?: string +} +``` + +`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选仍只搜索 session id 和 cwd,绝不搜索 transcript(文本记录)。 + +```ts type-equiv +/** One host-facing candidate from exact session metadata. */ +interface SessionReferenceCandidate { + /** Opaque source session identity. */ + sessionId: SessionId + /** Latest log-backed title, falling back to the opaque session id. */ + label: string + /** Source session working directory, when recorded. */ + cwd?: string + /** Source session creation time in Unix epoch milliseconds. */ + createdAt: number +} +``` + +## 预备消息 + +预备过程保留可读的当前消息内容,并最多返回一个聚合上下文。宿主会把 `contexts` 绑定到该次确切的 `followup()` 或 `steer()` 调用。 + +```ts type-equiv +/** Message payload and the zero-or-one durable snapshot contexts bound to it. */ +interface PreparedReferencedMessage { + /** Readable message content after host mention tokens are removed. */ + content: ContentBlock[] + /** Empty without references; otherwise one aggregated untrusted context. */ + contexts: HookContext[] +} +``` + +## 错误 + +`SessionReferenceError.code` 区分无效配置或输入、自引用、数量限制、源读取失败、预算失败和取消。宿主协议会把这些 code 映射到各自的错误信封,无需检查提示词字节。 + +```ts type-equiv +/** Stable failure codes exposed to host adapters. */ +type SessionReferenceErrorCode = + | 'SESSION_REFERENCE_INVALID_CONFIG' + | 'SESSION_REFERENCE_INVALID_REFERENCE' + | 'SESSION_REFERENCE_SELF_REFERENCE' + | 'SESSION_REFERENCE_TOO_MANY' + | 'SESSION_REFERENCE_READ_FAILED' + | 'SESSION_REFERENCE_BUDGET_EXCEEDED' + | 'SESSION_REFERENCE_CANCELLED' +``` diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml new file mode 100644 index 0000000000..f31d9016ac --- /dev/null +++ b/docs/core-data-structures/session-title.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 +session-title.md: 6575bda5fdecf2be15ed7c3288efb0efa759ac8a +session-title.zh.md: 66ec567ac6cef32acef9c50ae3e67155097a76b6 diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md index 39e2b00ae5..6575bda5fd 100644 --- a/docs/core-data-structures/session-title.md +++ b/docs/core-data-structures/session-title.md @@ -1,5 +1,7 @@ # Session Titles +English | [中文](session-title.zh.md) + Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title). The shared LLM helper owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations. Sources: [`packages/session-title/session-title/src/index.ts`](../../packages/session-title/session-title/src/index.ts), [`packages/session-title/session-title-llm/src/index.ts`](../../packages/session-title/session-title-llm/src/index.ts) diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md new file mode 100644 index 0000000000..66ec567ac6 --- /dev/null +++ b/docs/core-data-structures/session-title.zh.md @@ -0,0 +1,142 @@ +# 会话标题 + +[English](session-title.md) | 中文 + +[`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title) 所拥有的持久化后写覆盖标题状态与可选异步提供方词汇。共享 LLM(大语言模型)辅助组件负责精确的辅助请求记录。各包(package)README 负责时序、回退、失败与 fork 行为;生成的[持久化日志事件目录](../persistence-catalog.md)负责完整的事件声明。 + +源码:[`packages/session-title/session-title/src/index.ts`](../../packages/session-title/session-title/src/index.ts)、[`packages/session-title/session-title-llm/src/index.ts`](../../packages/session-title/session-title-llm/src/index.ts) + +## 持久标题状态 + +提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 携带精确的人类消息来源信息,`SessionTitleSnapshot` 则加入 `foldSessionTitle()` 选出的持久事件信封事实。 + +```ts type-equiv +/** Identifies one session-title provider registration. */ +type SessionTitleProviderId = Branded<'SessionTitleProviderId'> +``` + +```ts type-equiv +/** Exact auxiliary model route that produced a title. */ +interface SessionTitleModelProvenance { + /** Registered LLM provider route. */ + readonly provider: string + /** Provider model id. */ + readonly model: string +} +``` + +```ts type-equiv +/** Durable ownership record for an accepted session title. */ +type SessionTitleSource = + | { readonly kind: 'fallback' } + | { + readonly kind: 'provider' + readonly provider: SessionTitleProviderId + readonly model?: SessionTitleModelProvenance + } +``` + +```ts type-equiv +/** Payload of the log-only `session/title` event. */ +interface SessionTitleEventData { + /** Normalized non-empty title text. */ + readonly title: string + /** Exact human `user/message` seqs used to derive this title. */ + readonly messageSeqs: number[] + /** Built-in fallback or registered-provider provenance. */ + readonly source: SessionTitleSource +} +``` + +```ts type-equiv +/** Latest folded title plus the title event's durable envelope facts. */ +interface SessionTitleSnapshot extends SessionTitleEventData { + /** Seq of the latest `session/title` event. */ + readonly eventSeq: number + /** Timestamp of the latest `session/title` event. */ + readonly updatedAt: number +} +``` + +## 辅助请求记录 + +共享 LLM 辅助组件会在调用模型前,记录每一项已经过验证且可分发的标题请求。即使后续生成失败,载荷仍会复现模型可见的系统输入与消息输入、路由、输出上限、提供方归属和源消息归因。 + +```ts type-equiv +/** Exact model-visible request recorded before one auxiliary title dispatch. */ +interface SessionTitleLlmRequestEventData { + /** Registered title-provider identity responsible for the request. */ + readonly titleProvider: SessionTitleProviderId + /** Exact human `user/message` seqs represented in `messages`. */ + readonly messageSeqs: number[] + /** Exact auxiliary LLM route. */ + readonly route: SessionTitleModelProvenance + /** Exact auxiliary system prompt. */ + readonly system: string + /** Exact auxiliary message list. */ + readonly messages: Message[] + /** Exact auxiliary output-token cap. */ + readonly maxTokens: number +} +``` + +## 提供方输入与输出 + +服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接受过程会验证顺序、规范化标题、强制执行字节上限并追加来源信息。 + +```ts type-equiv +/** One eligible human text message exposed to title providers. */ +interface SessionTitleUserMessage { + /** Source `user/message` event seq. */ + readonly seq: number + /** Exact concatenated text-block content. */ + readonly text: string +} +``` + +```ts type-equiv +/** Automatic generation cadence owned by a registered provider. */ +type SessionTitleAutomaticMode = 'first-message' | 'all-user-messages' +``` + +```ts type-equiv +/** Immutable input supplied to one title-provider call. */ +interface SessionTitleProviderRequest { + /** Live session being titled. */ + readonly session: Session + /** All eligible human messages through this generation revision. */ + readonly messages: readonly SessionTitleUserMessage[] + /** Exact current logged main-request route, when one has been recorded. */ + readonly route?: SessionTitleModelProvenance + /** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** Provider output before service-owned normalization and durable acceptance. */ +interface SessionTitleProviderResult { + /** Proposed title text. */ + readonly title: string + /** Exact seqs from `request.messages` used by this result. */ + readonly messageSeqs: readonly number[] + /** Auxiliary LLM route, when generation used a model. */ + readonly model?: SessionTitleModelProvenance +} +``` + +```ts type-equiv +/** One optional asynchronous title implementation registered with the service. */ +interface SessionTitleProvider { + /** Stable provider identity recorded in title provenance. */ + readonly id: SessionTitleProviderId + /** When new human prompts start automatic generation. */ + readonly automatic: SessionTitleAutomaticMode + /** + * Produce one title revision. + * @param request - message snapshot, current route, session, and cancellation. + * @returns proposed title plus exact input seqs and optional model provenance. + */ + generate(request: SessionTitleProviderRequest): Promise +} +``` diff --git a/docs/core-data-structures/spill.i18n.yaml b/docs/core-data-structures/spill.i18n.yaml new file mode 100644 index 0000000000..4cd4cc5e1e --- /dev/null +++ b/docs/core-data-structures/spill.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 +spill.md: a798d8143b2849dc0cf49d04e7019ce796cdee45 +spill.zh.md: 1af6939d1d8fd37958cae4f9cf2cbf706b17acd0 diff --git a/docs/core-data-structures/spill.md b/docs/core-data-structures/spill.md index a964064912..a798d8143b 100644 --- a/docs/core-data-structures/spill.md +++ b/docs/core-data-structures/spill.md @@ -1,5 +1,7 @@ # Spill Storage +English | [中文](spill.zh.md) + The spill storage seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) that persists a tool's oversized text and returns a model-facing locator plus retrieval guidance, split across packages: interface ([dsh-spill](../../packages/spill/spill), `ctx.spillStore`), implementation ([dsh-spill-local](../../packages/spill/spill-local), private session-scoped files on the host filesystem), and consumer ([dsh-spill-policy](../../packages/spill/spill-policy), the `tools/post-execute` policy). Spill is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Preview mechanics stay in [dsh-retention](../../packages/util/retention); this seam only saves the final text the policy hands it. Source: [`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts) diff --git a/docs/core-data-structures/spill.zh.md b/docs/core-data-structures/spill.zh.md new file mode 100644 index 0000000000..1af6939d1d --- /dev/null +++ b/docs/core-data-structures/spill.zh.md @@ -0,0 +1,85 @@ +# 落盘存储 + +[English](spill.md) | 中文 + +落盘存储 seam 是一项[能力 seam](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),它持久保存工具的超大文本,并返回面向模型的定位符与检索指引;该能力拆分到三个包(package):接口([dsh-spill](../../packages/spill/spill),`ctx.spillStore`)、实现([dsh-spill-local](../../packages/spill/spill-local),宿主文件系统中会话作用域的私有文件)和消费方([dsh-spill-policy](../../packages/spill/spill-policy),`tools/post-execute` 策略)。落盘是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇记录在此处,而不在 [core.md](core.md) 中。预览机制仍归 [dsh-retention](../../packages/util/retention) 所有;该 seam 只保存策略交给它的最终文本。 + +源码:[`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts) + +## 保存请求 + +`saveText` 是整个 seam:原样持久保存 `content`,并返回不透明的定位符、后端提供的检索提示和准确字节数。请求携带保存时的存储命名空间(`owner`)、内容来源(`source`,用于命名和检查的描述性来源信息,而非访问控制)以及后端可用作命名提示的 `suggestedName`(它不是路径)。 + +```ts type-equiv +/** One request to persist text to a spill artifact. */ +interface SaveTextSpill { + owner: SpillOwner + source: SpillSource + /** + * A caller-suggested base name (e.g. `web_fetch.txt`). The backend sanitizes + * it to a single safe path segment before use — it is a hint, never a path. + */ + suggestedName: string + /** The full text to persist (UTF-8). */ + content: string +} +``` + +```ts type-equiv +/** + * Save-time storage namespace for a spilled artifact. The session id lets a + * backend group storage under the producing session, but the returned + * {@link SpillLocator} is the model-facing handle. Forked sessions inherit + * locators already present in the seeded log; those artifacts are not copied or + * re-owned, and spills produced after the fork use the child session id. + */ +interface SpillOwner { + sessionId: SessionId +} +``` + +`SpillOwner.sessionId` 是保存时的存储命名空间。fork 后的会话会从种子日志继承已有的落盘定位符;这些产物不会被复制或重新取得所有权,fork 后产生的落盘则使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;落盘 seam 不定义逐会话的清理策略。 + +```ts type-equiv +/** + * Provenance of one spilled artifact — recorded by the backend for a readable + * filename and inspection. Not interpreted for access control; purely + * descriptive. + */ +interface SpillSource { + /** The tool whose result was spilled (e.g. `web_fetch`). */ + toolName: string + /** The model-issued call id the result belongs to. */ + callId: CallId + /** A short human label for the artifact (e.g. `result`). */ + label: string +} +``` + +## 结果 + +```ts type-equiv +/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */ +interface SpillRef { + locator: SpillLocator + bytes: number + retrievalHint: string +} +``` + +`SpillLocator` 是后端返回的[品牌化](core.md#branded-ids)面向模型句柄。本地后端将它渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方将它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。 + +```ts type-equiv +/** + * Opaque model-facing handle for one spilled artifact. A local backend may use a + * filesystem path; a remote or database backend may use a URI or key. Consumers + * render it with {@link SpillRef.retrievalHint}, but do not parse it. + */ +type SpillLocator = Branded<'SpillLocator'> +``` + +## 服务 + +`SpillStore`(`ctx.spillStore`,定义于 [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts))是只有一个方法的抽象服务:`saveText(input) → Promise`。它持久保存完整的 `content`,并在实际存储失败(权限、ENOSPC、后端不可用)时拒绝。该 seam 只负责存储:不负责保留策略、工具结果替换或检索/搜索 API。 + +本地后端([dsh-spill-local](../../packages/spill/spill-local))写入 `/session-/-`:根目录是已配置或延迟创建的私有(0700)目录,会话子目录采用 `sha256(sessionId)`,并通过排他的仅所有者可访问写入(`open(path, 'wx', 0o600)`)防止预先植入的符号链接重定向写入。其 `locator` 是本地路径,`retrievalHint` 则告知模型在该路径上使用 `read` 或 `grep`。策略消费方([dsh-spill-policy](../../packages/spill/spill-policy))会把超过 `maxInlineBytes` 的纯文本最终结果替换为保留库生成的首尾预览和落盘引用;该过程尽力而为:保存失败时保留原始内联结果,而不会把成功的调用变成 `isError`。 diff --git a/docs/core-data-structures/tasks.i18n.yaml b/docs/core-data-structures/tasks.i18n.yaml new file mode 100644 index 0000000000..f9d14f2163 --- /dev/null +++ b/docs/core-data-structures/tasks.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 +tasks.md: d1f5a6d7b369e6113132f60e493cf87757e20599 +tasks.zh.md: 1562d9401f0f55ac6d6260902b8b1c71d9664d48 diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index 2c7555b84d..d1f5a6d7b3 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -1,5 +1,7 @@ # Background Task Runtime +English | [中文](tasks.zh.md) + Types shared by long-running producers, `ctx.tasks`, and task control surfaces. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the literal shapes from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts). ## Ids and status diff --git a/docs/core-data-structures/tasks.zh.md b/docs/core-data-structures/tasks.zh.md new file mode 100644 index 0000000000..1562d9401f --- /dev/null +++ b/docs/core-data-structures/tasks.zh.md @@ -0,0 +1,154 @@ +# 后台任务运行时 + +[English](tasks.md) | 中文 + +长时间运行的生产方、`ctx.tasks` 与任务控制接口共用的类型。[运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的字面形状。 + +## ID 与状态 + +`TaskId` 是按 `-N` 生成的[品牌化 id](core.md#branded-ids)。访问控制依赖拥有者授权,而非 id 的保密性。`TaskKind` 派生自可合并扩展的 map;注册表将各个 kind 视为不透明的 id 命名空间。 + +```ts type-equiv +/** + * Producer-defined task kinds. Plugins extend this map by declaration merging; + * the registry treats every value as an opaque id namespace. + */ +interface TaskKindMap { + bash: 'bash' + subagent: 'subagent' +} +``` + +`TaskStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实归入 `TaskSnapshot.detail`。 + +## 生产方契约 + +`TaskStart` 声明身份和启动器。运行时会在调用 `run()` 前完成预检,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 + +```ts type-equiv +/** + * Producer declaration passed to {@link TaskService.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity and lifecycle state. + */ +interface TaskStart { + /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ + kind: TaskKind + /** One-line model-facing label (the command; the delegation description). */ + label: string + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including control-surface status metadata. + */ + outputLimitBytes?: number + /** + * Owning live agent. Access is fenced by its session id, and agent disposal + * cancels and awaits the task. The instance must be the one currently + * registered under its agent id. Omitting the owner creates an unowned task, + * open to any caller until service disposal. + */ + owner?: Agent + /** + * Start the work after preflight and synchronously return its hooks. Called + * once; a throw leaves nothing registered, and the producer must clean up any + * partially started resources. + */ + run(): TaskHooks +} +``` + +`TaskHooks.done` 是完全停稳边界。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 + +```ts type-equiv +/** Hooks through which the runtime controls and observes producer work. */ +interface TaskHooks { + /** + * Request termination. Must be synchronous, idempotent, and eventually settle + * {@link done}; throws propagate. The optional reason is forwarded verbatim. + */ + cancel(reason?: string): void + /** + * Resolves after the producer releases its resources, not merely when work + * finishes. Must not reject; the runtime converts a rejection to `failed`. + * If teardown cancellation throws, the runtime may force-fail only the + * registry record without claiming that the work stopped. + */ + done: Promise + /** + * Consume output produced since the previous call. The producer formats + * truncation and spill notices. Absence marks a final-output-only task; each + * task has one consuming cursor. + */ + readOutput?(): string +} +``` + +```ts type-equiv +/** Terminal result supplied by a producer through {@link TaskHooks.done}. */ +interface TaskOutcome { + /** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ + status: 'completed' | 'killed' | 'failed' + /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + detail?: string + /** Final output for tasks without `readOutput`; stream tasks leave it unset. */ + output?: string +} +``` + +## 消费方视图 + +快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知。 + +```ts type-equiv +/** + * A read-only projection of one task, safe to hand to listeners and tools — + * a fresh object per call, never live registry state. + */ +interface TaskSnapshot { + /** The registry-issued id (`-N`). */ + id: TaskId + /** The producer kind the task was registered with. */ + kind: TaskKind + /** The producer-supplied one-line label. */ + label: string + /** Producer-owned cap for complete model-facing notices and output reads. */ + outputLimitBytes?: number + /** + * Owner session id used for authorization and correlation; absent for + * unowned tasks. Completion listeners receive the exact {@link Agent} + * separately through {@link TaskDoneListener}. + */ + ownerSession?: SessionId + /** Current lifecycle state. */ + status: TaskStatus + /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ + detail?: string + /** Epoch ms when the task was registered. */ + startedAt: number + /** Epoch ms when the task settled; absent while `running`/`stopping`. */ + finishedAt?: number + /** + * True when a kill, read, or wait has reported or committed to report the + * terminal state. Completion surfaces suppress redundant notices when set. + */ + reported: boolean +} +``` + +```ts type-equiv +/** Output and post-read state returned by {@link TaskService.read}. */ +interface TaskRead { + /** + * Stream kinds: the consuming delta since the previous read. Final-output + * kinds: empty while live, the terminal {@link TaskOutcome.output} (or + * empty) once settled — idempotent, never consumed. + */ + text: string + /** The task's state at read time. */ + snapshot: TaskSnapshot +} +``` + +## 服务行为 + +[`TaskService`](../../packages/tasks/tasks/src/index.ts) 提供原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。包(package)契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 diff --git a/docs/core-data-structures/token-meter.i18n.yaml b/docs/core-data-structures/token-meter.i18n.yaml new file mode 100644 index 0000000000..ed05739725 --- /dev/null +++ b/docs/core-data-structures/token-meter.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 +token-meter.md: 05784e294485a11acf0e4c8972e4083b1786c943 +token-meter.zh.md: c0dc55274acf21186f7baa00c377f9135792f888 diff --git a/docs/core-data-structures/token-meter.md b/docs/core-data-structures/token-meter.md index 880ec79d7d..05784e2944 100644 --- a/docs/core-data-structures/token-meter.md +++ b/docs/core-data-structures/token-meter.md @@ -1,5 +1,7 @@ # Token Meter +English | [中文](token-meter.zh.md) + `@deepseek-ai/dsh-token-meter` exposes one detached replay snapshot for request pressure and positional surface pricing. `logRevision` is the number of durable events consumed for every field in the measurement. Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter/src/types.ts) diff --git a/docs/core-data-structures/token-meter.zh.md b/docs/core-data-structures/token-meter.zh.md new file mode 100644 index 0000000000..c0dc55274a --- /dev/null +++ b/docs/core-data-structures/token-meter.zh.md @@ -0,0 +1,43 @@ +# Token 计量 + +[English](token-meter.md) | 中文 + +`@deepseek-ai/dsh-token-meter` 公开一个独立的回放快照,用于表示请求压力与按位置计算的 surface 定价。`logRevision` 表示生成该计量中每个字段时所消费的持久事件数量。 + +来源:[`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter/src/types.ts) + +## `TokenMeasurement` + +```ts type-equiv +/** Detached immutable request-pressure and surface snapshot at one consumed log revision. */ +interface TokenMeasurement { + /** Number of durable events consumed; equal to the next unread event seq. */ + readonly logRevision: number + /** Provider or heuristic anchor used for this measurement. */ + readonly baseline: TokenMeasurementBaseline + /** Signed repricing of current surface content relative to the baseline anchor. */ + readonly surfaceDeltaTokens: number + /** Non-negative current request-and-response pressure. */ + readonly totalTokens: number + /** Total heuristic tokens across the current surface. */ + readonly surfaceTokens: number + /** Current surface nodes in positional head-to-tail order. */ + readonly nodes: readonly TokenSurfaceNode[] +} +``` + +`baseline.kind === 'usage'` 表示最近一次成功的提供方调用具有相同的规范请求信封,且当前总量不低于该调用的完整启发式锚点。`estimated` 表示不存在可复用的保守 usage 锚点,因此服务使用固定启发式规则对完整信封和 surface 定价。后续成功请求会替换早先的锚点;有符号的 `surfaceDeltaTokens` 会保留相对于匹配锚点的增长与缩减。`totalTokens` 仍表示请求与响应压力,`surfaceTokens` 则是仅针对 surface 的启发式总量,等于所有节点价格之和。 + +## `TokenSurfaceNode` + +```ts type-equiv +/** One token-priced node in the current ordered session surface. */ +interface TokenSurfaceNode { + /** Durable sequence number of the surface event. */ + readonly seq: number + /** Heuristic tokens for the exact message projected by this node. */ + readonly tokens: number +} +``` + +surface 顺序具有权威性;替换节点的持久 seq 可能高于位置排在其后的节点。该快照不可变,不会随底层回放折叠推进而增长。 diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml new file mode 100644 index 0000000000..5509012e3e --- /dev/null +++ b/docs/web-styling.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 +web-styling.md: af05faca30fc968828f5a850f59d9d48ae382b05 +web-styling.zh.md: d0838cd8a6ee4290cdddff16b979950bec396314 diff --git a/docs/web-styling.md b/docs/web-styling.md index 0b6aeb55d5..af05faca30 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -1,107 +1,109 @@ -# Web GUI 样式规范 +# Web GUI Style Guide -> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写),sheet 即权威、组件对账以它为准。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace。 +English | [中文](web-styling.zh.md) -> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。 +> **[The token system has been replaced—the table in § 1 is retained only for historical reference]** The `--bg-*`/`--text-*`/`--accent` token families documented here and their host package, `packages/client/web-ui`, were retired during the plugin refactor. The sole current token source is `packages/client/ui-theme/src/styles/`, which defines the `--dsw-*` system (a static color scale plus a semantic alias layer, with dark-mode overrides under `body[data-ds-dark-theme]`). The sheet is authoritative and component audits use it as the baseline. **The following rules remain in force**: CSS Modules + clsx, no component library, no Tailwind, no hard-coded color values in components, pair every font size with a line height, use spacing in multiples of 4, and do not put monospace last in the code font stack. -## 1. 设计 token 表(权威定义) +> Status: formerly a “living document” that evolved with `packages/client/web-ui`. The visual baseline came from empirical study of the deepseekchat frontend repository. The [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) owns the framework decisions and engineering constraints; this document does not repeat their rationale. -所有 token 住 `packages/client/web-ui/src/style/global.css`:`:root` 亮色实值,`[data-theme='dark']` 块覆盖同名变量(未补全前列为占位)。组件 CSS 只引 token,不出现字面量色值。 +## 1. Design token table (authoritative definitions) -### 1.1 颜色(两层:注释里是 base 色板出处,变量名即语义别名) +All tokens live in `packages/client/web-ui/src/style/global.css`: `:root` contains the light-theme values, and the `[data-theme='dark']` block overrides the same variables (columns that were not complete are marked as placeholders). Component CSS references tokens only and contains no literal color values. -| token | 亮色实值 | 暗色(占位) | 用途 | +### 1.1 Colors (two layers: comments identify the base-palette source, while variable names are semantic aliases) + +| token | Light value | Dark value (placeholder) | Purpose | | --- | --- | --- | --- | -| `--bg-base` | `#ffffff` | `#151517` | 页面底 | -| `--bg-layer` | `#ffffff` | `#232324` | 浮层/面板 | -| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | 侧边栏底 | -| `--text-primary` | `#0f1115` | `#f9fafb` | 正文 | -| `--text-secondary` | `#61666b` | `#cfd3d6` | 次要文字 | -| `--text-tertiary` | `#81858c` | `#adb2b8` | 辅助/说明 | -| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | 弱分隔(侧边栏右缘) | -| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | 常规边框 | -| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | hover 态底 | -| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | 按压/激活态底 | -| `--accent` | `#3964fe` | `#5686fe` | 品牌蓝(deepseek-500;暗提亮一档) | -| `--accent-soft` | `#edf3fe` | `#28313f` | 淡品牌底(强调块) | -| `--accent-item` | `#e4edfd` | `#35363a` | 侧边栏选中条目底 | -| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | 用户消息气泡底 | -| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | 同值 | 语义状态色 | -| `--text-on-solid` | `#ffffff` | 同值 | 实色底(accent/error 徽标等)上的文字 | -| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | 语义状态软底(徽章);green-100/red-100,暗为 900 档 | -| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | 同值 | RPC 调试面板方向色(自有,非基线) | -| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | 同色 `.24` | 方向色软底(徽章) | -| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | 滚动条(`.scrollable` 专用) | +| `--bg-base` | `#ffffff` | `#151517` | Page background | +| `--bg-layer` | `#ffffff` | `#232324` | Floating layer/panel | +| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | Sidebar background | +| `--text-primary` | `#0f1115` | `#f9fafb` | Body text | +| `--text-secondary` | `#61666b` | `#cfd3d6` | Secondary text | +| `--text-tertiary` | `#81858c` | `#adb2b8` | Supporting/descriptive text | +| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | Subtle separator (sidebar right edge) | +| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | Standard border | +| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | Hover-state background | +| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | Pressed/active-state background | +| `--accent` | `#3964fe` | `#5686fe` | Brand blue (deepseek-500; one step lighter in dark mode) | +| `--accent-soft` | `#edf3fe` | `#28313f` | Soft brand background (emphasis blocks) | +| `--accent-item` | `#e4edfd` | `#35363a` | Selected sidebar-item background | +| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | User-message bubble background | +| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | Same values | Semantic status colors | +| `--text-on-solid` | `#ffffff` | Same value | Text on solid backgrounds (accent/error badges, etc.) | +| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | Soft semantic status backgrounds (badges); green-100/red-100, with the 900 shades in dark mode | +| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | Same values | RPC debugger direction colors (project-specific, not part of the baseline) | +| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | Same colors at `.24` | Soft direction-color backgrounds (badges) | +| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | Scrollbar colors (for `.scrollable` only) | -### 1.2 非颜色 +### 1.2 Non-color tokens -| token | 值 | 说明 | +| token | Value | Description | | --- | --- | --- | -| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | 正文栈 | -| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | 代码栈;**末位不放 monospace**(防 Windows 中文回退宋体) | -| `--fw-strong` | `600` | 粗体统一权重 | -| `--ease` | `cubic-bezier(.4,0,.2,1)` | 唯一缓动曲线 | -| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | 过渡三档 | -| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | 圆角语义档:小控件 / 列表条目与面板内块 / 浮层 / 气泡 / 输入卡片(基线 inputWrapper 同值);胶囊直接写 `999px` | -| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | 浮层阴影(基线 lv3) | -| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | 强浮动面板(lv3 加强档,如 RPC 调试浮层) | -| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`;暗色 `none` | 输入卡片微阴影(基线:亮色同底靠边框+微影区分,暗色靠提亮底、阴影关闭) | +| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | Body-text stack | +| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | Code stack; **do not put monospace last** (prevents SimSun fallback for Chinese on Windows) | +| `--fw-strong` | `600` | Unified bold weight | +| `--ease` | `cubic-bezier(.4,0,.2,1)` | Sole easing curve | +| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | Three transition durations | +| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | Semantic radius steps: small controls / list items and blocks inside panels / floating layers / bubbles / input cards (same as the baseline inputWrapper); use `999px` directly for pills | +| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | Floating-layer shadow (baseline lv3) | +| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | Strong floating panel (enhanced lv3, such as the RPC debugger overlay) | +| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`; dark value `none` | Subtle input-card shadow (baseline: borders plus a subtle shadow distinguish same-color light surfaces; a lighter background distinguishes dark surfaces, with the shadow disabled) | -字号与间距**不 token 化**(基线仓同款决策):字号在组件里写 px 且**成对写行高**,常用对 16/24(气泡)、14/22(UI 默认)、12/18(辅助);间距用 4 的倍数。 +Font sizes and spacing are **not tokenized** (matching the baseline repository's decision): components specify font sizes in px and **always pair them with line heights**. Common pairs are 16/24 (bubbles), 14/22 (UI default), and 12/18 (supporting text); spacing uses multiples of 4. -## 2. 视觉基线(源自 deepseekchat) +## 2. Visual baseline (from deepseekchat) -- 侧边栏:宽 `260px + 1px` 右边框(`--border-l1`);底色 `--bg-sidebar`。 -- 侧边栏条目:高 `40px`、圆角 `--radius-m`、字号 14px;hover 底 `--hover-bg` 或 sidebar 专属灰、**选中底 `--accent-item` 且不改文字色**。 -- 侧边栏分组标题:12px / weight 500 / `--text-tertiary` / sticky 顶部(底色同侧边栏遮滚动内容)。 -- 会话列:`max-width: 840px` 居中,<1024px 降 712px。 -- 消息流:**仅用户侧有气泡**——`--bubble-bg` 底、圆角 `--radius-bubble`、padding `10px 16px`、字号 16px/24px、`max-width: calc(100% - 88px)`;**助手侧纯文档流无底色**。 -- 消息操作条:默认 `opacity: 0`,父块 hover/focus-within 淡入(`--dur` + `--ease`)。 -- 输入卡片:与会话列同宽(840px,<1024px 降 712px)居中悬浮(距底留白带);圆角 `--radius-xl`、边框 `--border-l2`、底 `--bg-base`、阴影 `--shadow-card`;内部上下两段=textarea(16px/24px,min 2 行 max 14 行=336px,镜像 div 自增高)+ 操作行(右下嵌 34px 主圆钮);focus 无边框/阴影变化(基线同款)。 -- 输入主按钮(拍板 2026-07-20 三连,视觉参照 Codex App):32px 实心正圆图标钮(内联 SVG)——空闲=`--accent` 底白↑箭头「发送」,运行中原地变 `--accent-soft` 底 accent ■「停止」(同色系不告警、不用红)。**运行中锁输入**(拍板 3,取代早先 hover 菜单方案):textarea disabled(灰、草稿内容保留可见)、无任何排队/插话菜单,停止是唯一动作;turn 结束解禁并 refocus。键盘 Enter=发送、Ctrl/Meta+Enter=换行(运行中键盘路径随锁失效)。 -- 滚动条:近隐形、hover 加深、`scrollbar-gutter: stable` 不占布局(统一走 `.scrollable`,见 §3-9)。 -- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE): +- Sidebar: width `260px + 1px` right border (`--border-l1`); background `--bg-sidebar`. +- Sidebar items: height `40px`, radius `--radius-m`, font size 14px; hover background `--hover-bg` or a sidebar-specific gray; **selected items use `--accent-item` without changing text color**. +- Sidebar group headings: 12px / weight 500 / `--text-tertiary` / sticky at the top (using the sidebar background to cover scrolling content). +- Conversation column: centered at `max-width: 840px`, reduced to 712px below 1024px. +- Message stream: **only user messages have bubbles**: background `--bubble-bg`, radius `--radius-bubble`, padding `10px 16px`, font size 16px/24px, and `max-width: calc(100% - 88px)`; **assistant messages are a plain document flow without a background**. +- Message action bar: `opacity: 0` by default; fades in when its parent is hovered or contains focus (`--dur` + `--ease`). +- Input card: floats centered at the same width as the conversation column (840px, reduced to 712px below 1024px) with bottom spacing; radius `--radius-xl`, border `--border-l2`, background `--bg-base`, shadow `--shadow-card`; two internal vertical sections = textarea (16px/24px, minimum 2 lines, maximum 14 lines = 336px, auto-growing through a mirror div) + action row (a 34px primary round button nested at bottom right); focus does not change the border or shadow (matching the baseline). +- Primary input button (the three decisions made on 2026-07-20, visually based on the Codex App): a 32px solid circular icon button (inline SVG). Idle = `--accent` background with a white ↑ “Send” arrow; while running it changes in place to an accent ■ “Stop” icon on `--accent-soft` (the same color family, not a warning, and not red). **Input is locked while running** (decision 3, replacing the earlier hover-menu design): the textarea is disabled (gray, with draft content still visible), there is no queue/interjection menu, and Stop is the only action. When the turn ends, input unlocks and regains focus. Enter sends; Ctrl/Meta+Enter inserts a newline (the keyboard path is disabled with the locked input while running). +- Scrollbars: nearly invisible, darkening on hover, with `scrollbar-gutter: stable` so they do not consume layout space (always use `.scrollable`; see § 3.9). +- Four-quadrant RPC direction symbols (the official visual vocabulary, using the spatial metaphor that up goes to the server, down comes from the server; single line = unary, double line = SSE): -| 符号 | 象限 | 徽章配色 | +| Symbol | Quadrant | Badge colors | | --- | --- | --- | -| `↑` | client-request(unary 出站) | `--accent` / `--accent-soft` | -| `↓` | server-response(unary 回包) | ok `--ok`/`--ok-soft`,error `--error`/`--error-soft` | -| `⇟` | server-request(SSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` | -| `⇞` | client-response(SSE 侧回应) | `--accent`/`--accent-soft` 降透明度 | +| `↑` | client-request (unary outbound) | `--accent` / `--accent-soft` | +| `↓` | server-response (unary response) | ok `--ok`/`--ok-soft`, error `--error`/`--error-soft` | +| `⇟` | server-request (SSE frame push) | mux `--color-frame-mux`/`--frame-mux-soft`, host `--color-frame-host`/`--frame-host-soft` | +| `⇞` | client-response (SSE-side response) | `--accent`/`--accent-soft` at reduced opacity | -## 3. 样式编码规范(review 对照打勾) +## 3. Style implementation rules (review checklist) -1. 颜色/圆角/动效/字体栈只引 §1 token;组件 CSS 出现字面量色值即打回(渐变遮罩等特效除外,须注释说明)。 -2. 组件 CSS 禁止出现 `[data-theme]` 选择器;暗色差异只在 global.css token 表做。确需按主题换非 token 值(渐变端点等),组件定义局部 CSS 变量、主题块只覆写变量(变量桥)。 -3. 类名 camelCase;状态类用单形容词(`.active` `.show`),由 clsx 挂载:`clsx(styles.x, cond && styles.active, className)`。 -4. 对外组件必须透传 `className` 并合入根元素。 -5. 禁用 `composes`;复用靠 token 与组件抽取。 -6. `:global` 仅用于穿透第三方/跨包类名;禁止用它定义新全局类。 -7. 交互过渡一律 `var(--dur*) var(--ease)`,只过渡 opacity / transform / 背景色 / 阴影;纯 hover 展示型元素包 `@media (hover: hover)`。 -8. hover/active 底色优先用透明度制 token(叠任意海拔底色都成立),不新造实色灰。 -9. 滚动容器统一挂 global.css 的 `.scrollable` 工具类;组件内禁写 `::-webkit-scrollbar`。 -10. 媒体查询写在组件 css 尾部、贴着被覆盖规则;断点当前仅 1024px 一档(会话列降档),加第二档需先记入本文档。 -11. 动态样式 JS 侧只写 CSS 变量(`style={{'--x': v}}`),规则留在 CSS;禁止在 TSX 里拼接样式对象做主题/状态分支。 -12. 文字灰阶只用 `--text-primary/secondary/tertiary` 三级,不新造灰色。 +1. Colors, radii, motion, and font stacks reference only the § 1 tokens. Reject literal color values in component CSS (except for special effects such as gradient masks, which require an explanatory comment). +2. Component CSS must not contain `[data-theme]` selectors; dark-mode differences belong only in the global.css token table. If a theme must change a non-token value such as a gradient endpoint, define a local CSS variable in the component and have the theme block override only that variable (a variable bridge). +3. Use camelCase class names; use a single adjective for state classes (`.active` `.show`) and attach them with clsx: `clsx(styles.x, cond && styles.active, className)`. +4. Public components must accept `className` and merge it into the root element. +5. Do not use `composes`; share through tokens and extracted components. +6. Use `:global` only to pierce third-party or cross-package class names; do not use it to define new global classes. +7. All interaction transitions use `var(--dur*) var(--ease)` and transition only opacity / transform / background color / shadow. Wrap hover-only reveal elements in `@media (hover: hover)`. +8. Prefer opacity-based tokens for hover/active backgrounds because they compose over any elevation background; do not add new solid grays. +9. Apply the `.scrollable` utility class from global.css to every scroll container; do not write `::-webkit-scrollbar` inside components. +10. Put media queries at the end of the component CSS, next to the rules they override. The only current breakpoint is 1024px (where the conversation column steps down); record a second breakpoint in this document before adding it. +11. Dynamic styles in JS set only CSS variables (`style={{'--x': v}}`), while rules remain in CSS; do not assemble style objects in TSX to branch by theme or state. +12. Use only the three `--text-primary/secondary/tertiary` levels for gray text; do not add another gray. -## 4. 文件组织 +## 4. File organization -- `src/style/global.css` 固定分区顺序:① token 表(`:root` + `[data-theme='dark']`)② 全局基础(box-sizing、body、button reset)③ 全局工具类(`.scrollable` 等,总数保持个位数)。 -- `*.module.css` 与组件同目录同名;一个组件一个 module 文件。 -- 类型声明用现有 `css-modules.d.ts` 通配;组件数超 20 再评估引入 tcm 生成精确 `.css.d.ts`。 -- PostCSS 特性白名单:当前**零插件**(平铺 CSS + 原生嵌套按需);引入 nested/custom-media 需先记入本文档。 +- `src/style/global.css` always uses this section order: ① token table (`:root` + `[data-theme='dark']`), ② global foundations (box-sizing, body, button reset), ③ global utility classes (`.scrollable`, etc.; keep the total in single digits). +- Place each `*.module.css` beside the component with the same name; use one module file per component. +- Use the existing `css-modules.d.ts` wildcard declaration. Reassess introducing tcm to generate exact `.css.d.ts` files only after the component count exceeds 20. +- PostCSS feature allowlist: currently **no plugins** (flat CSS plus native nesting when needed). Record nested/custom-media in this document before introducing either. -## 5. 演进规则与偏离记录 +## 5. Evolution rules and deviation log -- **加新 token**:先进 §1 表(含暗色占位列)再在组件使用;review 见到未入表的 `--` 新变量即打回(组件局部变量桥除外)。 -- **偏离基线**:与 §2 任一常数不一致的实现,须在下方偏离表记一行(日期/项/理由)。 -- **暗色表补全验收**:`[data-theme='dark']` 覆盖 §1 全部占位列后,用 RPC 面板 + 侧边栏 + 会话流三个界面人工/截图核对一遍,无组件级主题选择器即达标。 +- **Adding a token**: add it to the § 1 table first (including the dark-placeholder column), then use it in the component. Reject any new `--` variable that has not been added to the table (except for component-local variable bridges). +- **Deviating from the baseline**: if an implementation differs from any constant in § 2, add one row to the deviation table below (date / item / rationale). +- **Dark-table completion acceptance**: after `[data-theme='dark']` overrides every placeholder column in § 1, compare the RPC panel, sidebar, and conversation stream manually or with screenshots. Acceptance requires all three to match and no component-level theme selector to remain. -| 日期 | 偏离项 | 理由 | +| Date | Deviation | Rationale | | --- | --- | --- | -| (空) | | | +| (none) | | | -## 6. 相关文档 +## 6. Related documentation -- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)(框架五条与工程约束的裁决记录) -- 客户端消费架构与分层协议:[Web 客户端架构 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)、[GUI 分层与 RPC 协议 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) +- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) (decision record for the five framework rules and engineering constraints) +- Client consumption architecture and layered protocols: [Web client architecture RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md), [GUI layering and RPC protocol RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md new file mode 100644 index 0000000000..d0838cd8a6 --- /dev/null +++ b/docs/web-styling.zh.md @@ -0,0 +1,109 @@ +# Web GUI 样式规范 + +[English](web-styling.md) | 中文 + +> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写),sheet 即权威、组件对账以它为准。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace。 + +> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。 + +## 1. 设计 token 表(权威定义) + +所有 token 住 `packages/client/web-ui/src/style/global.css`:`:root` 亮色实值,`[data-theme='dark']` 块覆盖同名变量(未补全前列为占位)。组件 CSS 只引 token,不出现字面量色值。 + +### 1.1 颜色(两层:注释里是 base 色板出处,变量名即语义别名) + +| token | 亮色实值 | 暗色(占位) | 用途 | +| --- | --- | --- | --- | +| `--bg-base` | `#ffffff` | `#151517` | 页面底 | +| `--bg-layer` | `#ffffff` | `#232324` | 浮层/面板 | +| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | 侧边栏底 | +| `--text-primary` | `#0f1115` | `#f9fafb` | 正文 | +| `--text-secondary` | `#61666b` | `#cfd3d6` | 次要文字 | +| `--text-tertiary` | `#81858c` | `#adb2b8` | 辅助/说明 | +| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | 弱分隔(侧边栏右缘) | +| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | 常规边框 | +| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | hover 态底 | +| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | 按压/激活态底 | +| `--accent` | `#3964fe` | `#5686fe` | 品牌蓝(deepseek-500;暗提亮一档) | +| `--accent-soft` | `#edf3fe` | `#28313f` | 淡品牌底(强调块) | +| `--accent-item` | `#e4edfd` | `#35363a` | 侧边栏选中条目底 | +| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | 用户消息气泡底 | +| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | 同值 | 语义状态色 | +| `--text-on-solid` | `#ffffff` | 同值 | 实色底(accent/error 徽标等)上的文字 | +| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | 语义状态软底(徽章);green-100/red-100,暗为 900 档 | +| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | 同值 | RPC 调试面板方向色(自有,非基线) | +| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | 同色 `.24` | 方向色软底(徽章) | +| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | 滚动条(`.scrollable` 专用) | + +### 1.2 非颜色 + +| token | 值 | 说明 | +| --- | --- | --- | +| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | 正文栈 | +| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | 代码栈;**末位不放 monospace**(防 Windows 中文回退宋体) | +| `--fw-strong` | `600` | 粗体统一权重 | +| `--ease` | `cubic-bezier(.4,0,.2,1)` | 唯一缓动曲线 | +| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | 过渡三档 | +| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | 圆角语义档:小控件 / 列表条目与面板内块 / 浮层 / 气泡 / 输入卡片(基线 inputWrapper 同值);胶囊直接写 `999px` | +| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | 浮层阴影(基线 lv3) | +| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | 强浮动面板(lv3 加强档,如 RPC 调试浮层) | +| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`;暗色 `none` | 输入卡片微阴影(基线:亮色同底靠边框+微影区分,暗色靠提亮底、阴影关闭) | + +字号与间距**不 token 化**(基线仓同款决策):字号在组件里写 px 且**成对写行高**,常用对 16/24(气泡)、14/22(UI 默认)、12/18(辅助);间距用 4 的倍数。 + +## 2. 视觉基线(源自 deepseekchat) + +- 侧边栏:宽 `260px + 1px` 右边框(`--border-l1`);底色 `--bg-sidebar`。 +- 侧边栏条目:高 `40px`、圆角 `--radius-m`、字号 14px;hover 底 `--hover-bg` 或 sidebar 专属灰、**选中底 `--accent-item` 且不改文字色**。 +- 侧边栏分组标题:12px / weight 500 / `--text-tertiary` / sticky 顶部(底色同侧边栏遮滚动内容)。 +- 会话列:`max-width: 840px` 居中,<1024px 降 712px。 +- 消息流:**仅用户侧有气泡**——`--bubble-bg` 底、圆角 `--radius-bubble`、padding `10px 16px`、字号 16px/24px、`max-width: calc(100% - 88px)`;**助手侧纯文档流无底色**。 +- 消息操作条:默认 `opacity: 0`,父块 hover/focus-within 淡入(`--dur` + `--ease`)。 +- 输入卡片:与会话列同宽(840px,<1024px 降 712px)居中悬浮(距底留白带);圆角 `--radius-xl`、边框 `--border-l2`、底 `--bg-base`、阴影 `--shadow-card`;内部上下两段=textarea(16px/24px,min 2 行 max 14 行=336px,镜像 div 自增高)+ 操作行(右下嵌 34px 主圆钮);focus 无边框/阴影变化(基线同款)。 +- 输入主按钮(拍板 2026-07-20 三连,视觉参照 Codex App):32px 实心正圆图标钮(内联 SVG)——空闲=`--accent` 底白↑箭头「发送」,运行中原地变 `--accent-soft` 底 accent ■「停止」(同色系不告警、不用红)。**运行中锁输入**(拍板 3,取代早先 hover 菜单方案):textarea disabled(灰、草稿内容保留可见)、无任何排队/插话菜单,停止是唯一动作;turn 结束解禁并 refocus。键盘 Enter=发送、Ctrl/Meta+Enter=换行(运行中键盘路径随锁失效)。 +- 滚动条:近隐形、hover 加深、`scrollbar-gutter: stable` 不占布局(统一走 `.scrollable`,见 §3-9)。 +- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE): + +| 符号 | 象限 | 徽章配色 | +| --- | --- | --- | +| `↑` | client-request(unary 出站) | `--accent` / `--accent-soft` | +| `↓` | server-response(unary 回包) | ok `--ok`/`--ok-soft`,error `--error`/`--error-soft` | +| `⇟` | server-request(SSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` | +| `⇞` | client-response(SSE 侧回应) | `--accent`/`--accent-soft` 降透明度 | + +## 3. 样式编码规范(review 对照打勾) + +1. 颜色/圆角/动效/字体栈只引 §1 token;组件 CSS 出现字面量色值即打回(渐变遮罩等特效除外,须注释说明)。 +2. 组件 CSS 禁止出现 `[data-theme]` 选择器;暗色差异只在 global.css token 表做。确需按主题换非 token 值(渐变端点等),组件定义局部 CSS 变量、主题块只覆写变量(变量桥)。 +3. 类名 camelCase;状态类用单形容词(`.active` `.show`),由 clsx 挂载:`clsx(styles.x, cond && styles.active, className)`。 +4. 对外组件必须透传 `className` 并合入根元素。 +5. 禁用 `composes`;复用靠 token 与组件抽取。 +6. `:global` 仅用于穿透第三方/跨包类名;禁止用它定义新全局类。 +7. 交互过渡一律 `var(--dur*) var(--ease)`,只过渡 opacity / transform / 背景色 / 阴影;纯 hover 展示型元素包 `@media (hover: hover)`。 +8. hover/active 底色优先用透明度制 token(叠任意海拔底色都成立),不新造实色灰。 +9. 滚动容器统一挂 global.css 的 `.scrollable` 工具类;组件内禁写 `::-webkit-scrollbar`。 +10. 媒体查询写在组件 css 尾部、贴着被覆盖规则;断点当前仅 1024px 一档(会话列降档),加第二档需先记入本文档。 +11. 动态样式 JS 侧只写 CSS 变量(`style={{'--x': v}}`),规则留在 CSS;禁止在 TSX 里拼接样式对象做主题/状态分支。 +12. 文字灰阶只用 `--text-primary/secondary/tertiary` 三级,不新造灰色。 + +## 4. 文件组织 + +- `src/style/global.css` 固定分区顺序:① token 表(`:root` + `[data-theme='dark']`)② 全局基础(box-sizing、body、button reset)③ 全局工具类(`.scrollable` 等,总数保持个位数)。 +- `*.module.css` 与组件同目录同名;一个组件一个 module 文件。 +- 类型声明用现有 `css-modules.d.ts` 通配;组件数超 20 再评估引入 tcm 生成精确 `.css.d.ts`。 +- PostCSS 特性白名单:当前**零插件**(平铺 CSS + 原生嵌套按需);引入 nested/custom-media 需先记入本文档。 + +## 5. 演进规则与偏离记录 + +- **加新 token**:先进 §1 表(含暗色占位列)再在组件使用;review 见到未入表的 `--` 新变量即打回(组件局部变量桥除外)。 +- **偏离基线**:与 §2 任一常数不一致的实现,须在下方偏离表记一行(日期/项/理由)。 +- **暗色表补全验收**:`[data-theme='dark']` 覆盖 §1 全部占位列后,用 RPC 面板 + 侧边栏 + 会话流三个界面人工/截图核对一遍,无组件级主题选择器即达标。 + +| 日期 | 偏离项 | 理由 | +| --- | --- | --- | +| (空) | | | + +## 6. 相关文档 + +- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)(框架五条与工程约束的裁决记录) +- 客户端消费架构与分层协议:[Web 客户端架构 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)、[GUI 分层与 RPC 协议 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) From f32d3f207f84cb3e915fb9b2bfd3d2a74aba047d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:33:53 +0800 Subject: [PATCH 26/45] docs: require bilingual non-README documentation --- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 +- ...6-07-02-bilingual-docs-and-pairing-gate.md | 6 +-- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 6 +-- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 6 +-- docs/i18n/README.zh.md | 6 +-- docs/i18n/style-samples.md | 4 +- .../request-response.expected.json | 8 ++-- scripts/translation-pairing.manifest.json | 4 ++ scripts/translation-pairing.spec.ts | 38 +++++++++++++++++++ scripts/translation-pairing.ts | 33 ++++++++++++++++ scripts/verify-translation-pairing.ts | 35 +++++++---------- 12 files changed, 111 insertions(+), 43 deletions(-) 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 3bfc8414e7..c3430eb57d 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 -2026-07-02-bilingual-docs-and-pairing-gate.md: 4bc02878a0ea3f998e411ecc2b064c1626eacf3c -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 90a0c2f07f68b0fb4e26cd1c4b537a30a829b10f +2026-07-02-bilingual-docs-and-pairing-gate.md: 08e149ccc2342695d6dae4f1845896def6bf4388 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 91f25f33c20337ea688257829552795085e0a8c0 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 4bc02878a0..08e149ccc2 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 @@ -12,8 +12,8 @@ This repo's README and docs tree are read by people and agents inside and outsid - **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). - **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. -- **`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. -- **The enforcement frontier advances in coherent review batches.** A related set enters `required` only when reviewers can evaluate it as a unit. The core frontier groups [architecture](../../../../docs/architecture.md), the [Cordis primer](../../../../docs/cordis-primer.md), [defensive patterns](../../../../docs/defensive-patterns.md), the [glossary](../../../../docs/glossary.md), and [testing](../../../../docs/testing.md) because their terminology, links, and contributor contracts inform one another; admitting only part would leave the enforced corpus internally inconsistent. 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. +- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot. +- **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. 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. @@ -40,5 +40,5 @@ Paired sibling files with locale suffixes are the dominant Chinese big-tech conv - 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. - 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. - Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list. -- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. A date-named document dated on or after the manifest's `requiredSince` cutoff merges bilingual or not at all, so new date-named Agent Notes do not enlarge that backlog. +- Rollout remains incremental until a document class is complete: explicit `required` entries and the date cutoff prevent regression during review batches, while a closed class makes every current and future member mandatory. The non-README class is closed, so only the README class can still appear as backlog. - The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism. 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 90a0c2f07f..91f25f33c2 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 @@ -12,8 +12,8 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `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)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 -- **`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` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **执行红线按连贯的评审批次推进。** 一组相关文档只有在评审者能够将其作为整体评估时,才进入 `required`。核心红线将[架构](../../../../docs/architecture.md)、[Cordis 入门](../../../../docs/cordis-primer.md)、[防御性模式](../../../../docs/defensive-patterns.md)、[术语表](../../../../docs/glossary.md)和[测试](../../../../docs/testing.md)归为一组,因为它们的术语、链接和贡献者契约相互关联;只纳入其中一部分会使受门禁约束的文档集合内部不一致。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 +- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中的源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照。 +- **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。发布到文档站的配对使用 `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。 @@ -40,5 +40,5 @@ Status: implemented - 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。 - 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。 - 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。 -- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(待翻清单,`--list`),而非红色的 CI;因此配对按可评审的批次落地,无需一个巨型 PR。凡文件名以日期开头且日期不早于 manifest 中 `requiredSince` 分界日期的文档,都必须配齐双语文件,因此新建的日期命名 Agent Note 不会增加这份 backlog。 +- 在文档类别全部完成之前,推进仍然是渐进的:显式 `required` 条目与日期分界可在评审批次期间防止回退,已纳入强制范围的类别则将其当前及今后的每个成员都列为必选项。非 README 类别已纳入强制范围,因此只有 README 类别仍可能出现 backlog(待翻清单)。 - 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 602699a178..02f6151e29 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 -README.md: 77d7b3210216c7c12d7d06b1ed16396d02ef1d16 -README.zh.md: de15fc3b5f30c1280ce6b38c1afd2475be7f9671 +README.md: a60572b0691702c949b44b82a7b1d732a888ed93 +README.zh.md: 04ae032233dfa01c145b5d6bdbe7353a366e11f1 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 77d7b32102..a60572b069 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -23,7 +23,7 @@ This repo's documentation is read by people and agents both inside and outside t `pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically: -1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. +1. Every file listed as `required`, and every document whose class appears in `requiredClasses`, in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. The classes are `non-readme` and `readme`; class matching is case-insensitive on the basename, so `missions/readme.md` is a README. 2. 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. 3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. 4. 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. @@ -41,11 +41,11 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): - `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list. -- `docs/AGENTS.md` and `.agents/notes/**/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`. +- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. -**Rollout**: a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after the manifest's `requiredSince` cutoff must merge with its pair. Earlier dates are backlog, including files created on the cutoff's eve. An Agent Note filename records its first-proposed date, so backdating past the cutoff is a review-visible violation. The manifest's `required` list is the current enforcement frontier, not the goal of full coverage. Translation batches add paths to `required`, ratcheting the gate forward. Unlisted documents remain visible in `--list`, while every existing pair is governed by the full contract. Because later edits must update both sides, expand `required` only as fast as translation review can support. +**Enforcement frontier**: `requiredClasses` closes a whole document class after its back-catalog has been translated. `non-readme` is closed: every current or future in-scope non-README document must merge bilingual. README coverage remains an explicit-file rollout until `readme` joins the closed set. The manifest's `required` list retains already-admitted files, and a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after `requiredSince` must merge with its pair regardless of class. `--list` reports any unclosed-class backlog while every existing pair remains governed by the full contract. ## Division of labor diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index de15fc3b5f..04ae032233 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -23,7 +23,7 @@ `pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约: -1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。 +1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件,以及所属文档类别出现在 `requiredClasses` 中的每篇文档,都有完整配对。类别分为 `non-readme` 和 `readme`;判断类别时,basename 不区分大小写,因此 `missions/readme.md` 也属于 README。 2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。 3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。 4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。 @@ -41,11 +41,11 @@ **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): - `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 -- `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`:agent 指令,与根 `AGENTS.md` 一样只以英文维护。 +- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 -**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合并时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。Agent Note 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。 +**执行红线**:某个文档类别的存量文档全部翻译完成后,`requiredClasses` 会将整个类别纳入强制范围。`non-readme` 已纳入强制范围:当前及今后所有纳入范围的非 README 文档,合并时都必须配齐双语文件。README 覆盖仍按显式文件逐步推进,直到 `readme` 加入这一强制范围。manifest 的 `required` 列表保留已纳入的文件;以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note)只要日期不早于 `requiredSince`,就无论所属类别都必须与对侧文件一同合并。`--list` 会报告尚未纳入强制范围的类别中的任何 backlog(待翻清单),而每个已存在的配对仍受完整契约约束。 ## 分工 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index b20b2a4c90..c62ae259d0 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -70,9 +70,9 @@ ## ⑦ 推进策略(长段拆分示范) -> **Rollout**: date-named Agent Notes don't wait for a batch — one dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so each new date-named Agent Note is bilingual from birth. For the back-catalog, the `required` list in the manifest is the enforcement frontier, not the goal. […] Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so grow the frontier at the pace translation review is actually resourced, not ahead of it. +> **Enforcement frontier**: a document class enters the manifest's `requiredClasses` set only after its back-catalog has been translated and reviewed. The `non-readme` class is closed, so every current or future in-scope non-README document must merge bilingual; README coverage remains an explicit-file rollout until that class is ready to close. […] Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so close a class only when translation review can sustain it. -**推进**:日期命名的 Agent Note 无需等待批量翻译。只要文件名中的日期不早于 manifest(元数据清单)的 `requiredSince` 分界日期,合入时就必须配齐中英文,因此此类 Agent Note 从创建起就要求双语齐备。对于存量文档,manifest 中的 `required` 列表只是当前的执行红线,并非最终目标。(……)一旦文档完成配对,后续修改任一侧都必须同步更新另一侧。因此,应根据实际可投入的翻译评审能力逐步扩展执行红线,不能超前。 +**执行红线**:只有在某个文档类别的存量文档全部完成翻译和评审后,该类别才会进入 manifest(元数据清单)的 `requiredClasses` 集合。`non-readme` 类别已纳入强制范围,因此当前及今后所有纳入范围的非 README 文档,合入时都必须配齐双语文件;README 覆盖仍按显式文件逐步推进,直到该类别具备整体纳入强制范围的条件。(……)一旦文档完成配对,后续修改任一侧都必须同步更新另一侧。因此,只有在翻译评审能力足以持续支撑时,才应将整个类别纳入强制范围。 ## 从样例提炼的要点 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index fd9e0bec36..bc5355b931 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,11 +24,11 @@ }, { "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 `), 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 passes the gate; it must not pass review.\n\n## Scope, exclusions, and rollout\n\n**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\n- `docs/AGENTS.md` and `.agents/notes/**/AGENTS.md` — 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\n**Rollout**: a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after the manifest's `requiredSince` cutoff must merge with its pair. Earlier dates are backlog, including files created on the cutoff's eve. An Agent Note filename records its first-proposed date, so backdating past the cutoff is a review-visible violation. The manifest's `required` list is the current enforcement frontier, not the goal of full coverage. Translation batches add paths to `required`, ratcheting the gate forward. Unlisted documents remain visible in `--list`, while every existing pair is governed by the full contract. Because later edits must update both sides, expand `required` only as fast as translation review can support.\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, switchers, 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 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 `), 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`, and every document whose class appears in `requiredClasses`, in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. The classes are `non-readme` and `readme`; class matching is case-insensitive on the basename, so `missions/readme.md` is a README.\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 passes the gate; it must not pass review.\n\n## Scope, exclusions, and rollout\n\n**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\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\n**Enforcement frontier**: `requiredClasses` closes a whole document class after its back-catalog has been translated. `non-readme` is closed: every current or future in-scope non-README document must merge bilingual. README coverage remains an explicit-file rollout until `readme` joins the closed set. The manifest's `required` list retains already-admitted files, and a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after `requiredSince` must merge with its pair regardless of class. `--list` reports any unclosed-class backlog while every existing pair remains governed by the full contract.\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, switchers, 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(智能体)阅读,因此 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 `),所以失去同步的配对是「把被改的一侧与其上次确认状态做 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(`packages/**`)在后续批次加入范围。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\n- `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n\n**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合并时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。Agent Note 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。\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(智能体)阅读,因此 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 `),所以失去同步的配对是「把被改的一侧与其上次确认状态做 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` 列出的每个文件,以及所属文档类别出现在 `requiredClasses` 中的每篇文档,都有完整配对。类别分为 `non-readme` 和 `readme`;判断类别时,basename 不区分大小写,因此 `missions/readme.md` 也属于 README。\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(`packages/**`)在后续批次加入范围。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\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\n**执行红线**:某个文档类别的存量文档全部翻译完成后,`requiredClasses` 会将整个类别纳入强制范围。`non-readme` 已纳入强制范围:当前及今后所有纳入范围的非 README 文档,合并时都必须配齐双语文件。README 覆盖仍按显式文件逐步推进,直到 `readme` 加入这一强制范围。manifest 的 `required` 列表保留已纳入的文件;以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note)只要日期不早于 `requiredSince`,就无论所属类别都必须与对侧文件一同合并。`--list` 会报告尚未纳入强制范围的类别中的任何 backlog(待翻清单),而每个已存在的配对仍受完整契约约束。\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" }, { "role": "user", @@ -40,11 +40,11 @@ }, { "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- **The enforcement frontier advances in coherent review batches.** A related set enters `required` only when reviewers can evaluate it as a unit. The core frontier groups [architecture](../../../../docs/architecture.md), the [Cordis primer](../../../../docs/cordis-primer.md), [defensive patterns](../../../../docs/defensive-patterns.md), the [glossary](../../../../docs/glossary.md), and [testing](../../../../docs/testing.md) because their terminology, links, and contributor contracts inform one another; admitting only part would leave the enforced corpus internally inconsistent. 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** — 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 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 docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. A date-named document dated on or after the manifest's `requiredSince` cutoff merges bilingual or not at all, so new date-named Agent Notes do not enlarge that backlog.\n- The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), 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 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: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot.\n- **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. 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** — 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 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 docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- Rollout remains incremental until a document class is complete: explicit `required` entries and the date cutoff prevent regression during review batches, while a closed class makes every current and future member mandatory. The non-README class is closed, so only the README class can still appear as backlog.\n- The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), 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本仓库的 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- **执行红线按连贯的评审批次推进。** 一组相关文档只有在评审者能够将其作为整体评估时,才进入 `required`。核心红线将[架构](../../../../docs/architecture.md)、[Cordis 入门](../../../../docs/cordis-primer.md)、[防御性模式](../../../../docs/defensive-patterns.md)、[术语表](../../../../docs/glossary.md)和[测试](../../../../docs/testing.md)归为一组,因为它们的术语、链接和贡献者契约相互关联;只纳入其中一部分会使受门禁约束的文档集合内部不一致。发布到文档站的配对使用 `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- **英文为正典源、指纹放在译文内**:本 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`;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- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(待翻清单,`--list`),而非红色的 CI;因此配对按可评审的批次落地,无需一个巨型 PR。凡文件名以日期开头且日期不早于 manifest 中 `requiredSince` 分界日期的文档,都必须配齐双语文件,因此新建的日期命名 Agent Note 不会增加这份 backlog。\n- 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" + "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))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中的源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照。\n- **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。发布到文档站的配对使用 `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- **英文为正典源、指纹放在译文内**:本 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`;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- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 在文档类别全部完成之前,推进仍然是渐进的:显式 `required` 条目与日期分界可在评审批次期间防止回退,已纳入强制范围的类别则将其当前及今后的每个成员都列为必选项。非 README 类别已纳入强制范围,因此只有 README 类别仍可能出现 backlog(待翻清单)。\n- 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" }, { "role": "user", diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 4e08844dc9..ebed3a6851 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -1,4 +1,7 @@ { + "requiredClasses": [ + "non-readme" + ], "requiredSince": "2026-07-14", "required": [ ".agents/notes/README.md", @@ -215,6 +218,7 @@ "excluded": [ ".agents/notes/AGENTS.md", ".agents/notes/implemented/AGENTS.md", + ".agents/notes/implemented/CLAUDE.md", "docs/AGENTS.md", "docs/agent-lifecycle.md", "docs/capability-seams.md", diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index 2bdc4f4e15..b8b880565a 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -7,6 +7,8 @@ import { parseTranslationMarkdown, parseTranslationPairingManifest, requiresPairByDate, + requiresTranslationPair, + translationDocumentClass, translationStructureDiff, translationStructureSignature, } from './translation-pairing.ts' @@ -20,10 +22,12 @@ describe('translation pairing manifest', () => { expect(parseTranslationPairingManifest(JSON.stringify({ requiredSince: '2026-07-14', required: ['README.md'], + requiredClasses: ['non-readme'], excluded: ['docs/generated/'], }))).toEqual({ requiredSince: '2026-07-14', required: ['README.md'], + requiredClasses: ['non-readme'], excluded: ['docs/generated/'], }) }) @@ -33,6 +37,7 @@ describe('translation pairing manifest', () => { expect(() => parseTranslationPairingManifest(JSON.stringify({ requiredSince: cutoff, required: [], + requiredClasses: [], excluded: [], }))).toThrow('requiredSince must be a valid YYYY-MM-DD date') }) @@ -41,9 +46,42 @@ describe('translation pairing manifest', () => { expect(() => parseTranslationPairingManifest(JSON.stringify({ requiredSince: '2026-07-14', required: [42], + requiredClasses: [], excluded: [], }))).toThrow('required must be an array of strings') }) + + it('rejects unknown and duplicate document classes', () => { + const manifest = (requiredClasses: string[]) => JSON.stringify({ + requiredSince: '2026-07-14', + required: [], + requiredClasses, + excluded: [], + }) + expect(() => parseTranslationPairingManifest(manifest(['guide']))).toThrow('requiredClasses must contain only') + expect(() => parseTranslationPairingManifest(manifest(['readme', 'readme']))).toThrow('requiredClasses must not contain duplicates') + }) +}) + +describe('document-class pairing frontier', () => { + const manifest = parseTranslationPairingManifest(JSON.stringify({ + requiredSince: '2026-07-14', + required: ['docs/legacy/README.md'], + requiredClasses: ['non-readme'], + excluded: [], + })) + + it('classifies README basenames case-insensitively', () => { + expect(translationDocumentClass('packages/core/README.md')).toBe('readme') + expect(translationDocumentClass('missions/readme.md')).toBe('readme') + expect(translationDocumentClass('docs/readme-guide.md')).toBe('non-readme') + }) + + it('requires every non-README while retaining explicit README entries', () => { + expect(requiresTranslationPair('docs/guide.md', manifest)).toBe(true) + expect(requiresTranslationPair('docs/legacy/README.md', manifest)).toBe(true) + expect(requiresTranslationPair('docs/new/README.md', manifest)).toBe(false) + }) }) describe('date-based pairing frontier', () => { diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index b7238fa3aa..92c38e8896 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -12,11 +12,18 @@ import type { Nodes } from 'mdast' /** Validated shape of `scripts/translation-pairing.manifest.json`. */ export interface TranslationPairingManifest { required: string[] + /** Document classes whose complete in-scope population must be paired. */ + requiredClasses: TranslationDocumentClass[] excluded: string[] /** Date-named documents on or after this day must merge bilingual. */ requiredSince: string } +/** Stable classes used to close one translation rollout without enumerating files. */ +export type TranslationDocumentClass = 'readme' | 'non-readme' + +const TRANSLATION_DOCUMENT_CLASSES: TranslationDocumentClass[] = ['readme', 'non-readme'] + const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/ @@ -40,6 +47,19 @@ function stringArrayField(record: Record, field: 'required' | ' return entries } +/** Read and validate the manifest's closed document-class set. */ +function requiredClassesField(record: Record): TranslationDocumentClass[] { + const value = record.requiredClasses + if (!Array.isArray(value) || !value.every((entry): entry is TranslationDocumentClass => + typeof entry === 'string' && TRANSLATION_DOCUMENT_CLASSES.includes(entry as TranslationDocumentClass))) { + throw new Error('translation-pairing.manifest.json: requiredClasses must contain only "readme" and "non-readme"') + } + if (new Set(value).size !== value.length) { + throw new Error('translation-pairing.manifest.json: requiredClasses must not contain duplicates') + } + return value +} + /** Parse and validate the checked-in bilingual manifest. */ export function parseTranslationPairingManifest(content: string): TranslationPairingManifest { const value: unknown = JSON.parse(content) @@ -53,11 +73,24 @@ export function parseTranslationPairingManifest(content: string): TranslationPai } return { required: stringArrayField(record, 'required'), + requiredClasses: requiredClassesField(record), excluded: stringArrayField(record, 'excluded'), requiredSince, } } +/** Classify a Markdown source by whether its basename is README, case-insensitively. */ +export function translationDocumentClass(file: string): TranslationDocumentClass { + return /(?:^|\/)readme\.md$/i.test(file) ? 'readme' : 'non-readme' +} + +/** Whether the manifest requires this in-scope source to have a complete pair. */ +export function requiresTranslationPair(file: string, manifest: TranslationPairingManifest): boolean { + return manifest.required.includes(file) + || manifest.requiredClasses.includes(translationDocumentClass(file)) + || requiresPairByDate(file, manifest.requiredSince) +} + /** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */ export function datedDocumentDate(file: string): string | undefined { return DATED_DOCUMENT.exec(file)?.[1] diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 860e193b4f..9372064707 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -1,9 +1,10 @@ /** * Enforce complete English/Chinese pairs, matching structure, and recorded git * blob hashes under the bilingual manifest. Required files and date-named docs - * at or after `requiredSince` must be paired; excluded docs may have neither a - * counterpart nor sidecar. `--list` reports state and `--write` records both - * sides after human review. Translation quality remains a review responsibility. + * at or after `requiredSince`, plus every source in a required document class, + * must be paired; excluded docs may have neither a counterpart nor sidecar. + * `--list` reports state and `--write` records both sides after human review. + * Translation quality remains a review responsibility. * See `docs/i18n/README.md` for the owning contract. */ @@ -11,11 +12,11 @@ import { createHash } from 'node:crypto' import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' import { basename, join, resolve, sep } from 'node:path' import { - datedDocumentDate, linksTo, parseTranslationMarkdown, parseTranslationPairingManifest, - requiresPairByDate, + requiresTranslationPair, + translationDocumentClass, translationStructureDiff, translationStructureSignature, } from './translation-pairing.ts' @@ -118,29 +119,21 @@ if (writeMode) { const errors: string[] = [] const state = new Map() -// 1. Required pairs exist. +// 1. Explicit manifest entries name existing source documents. for (const req of manifest.required) { if (!existsSync(join(root, req))) { errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`) - continue - } - const { zh } = pairPaths(req) - if (!existsSync(join(root, zh))) { - errors.push(`${req}: required to have a translation, but ${zh} does not exist`) - state.set(req, 'missing') } } -// 2. Date-named documents (Agent Notes) dated on/after the requiredSince cutoff merge -// bilingual: a new Agent Note lands with its pair or not at all. Deterministic from -// the filename alone — no git history, so it holds on shallow CI checkouts. +// 2. Every source selected explicitly, by document class, or by the dated-document +// cutoff merges bilingual. Class enforcement closes a rollout for future files too. for (const source of sources) { if (isExcluded(source)) continue - const date = datedDocumentDate(source) - if (!requiresPairByDate(source, manifest.requiredSince) || date === undefined) continue + if (!requiresTranslationPair(source, manifest)) continue const { zh } = pairPaths(source) if (!existsSync(join(root, zh))) { - errors.push(`${source}: dated ${date} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`) + errors.push(`${source}: required to merge bilingual as a ${translationDocumentClass(source)} document (docs/i18n/README.md); add the counterpart and record the pair`) state.set(source, 'missing') } } @@ -214,8 +207,8 @@ if (listMode) { const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0])) for (const [file, status] of rows) { - const required = manifest.required.includes(file) - const tag = required ? ' (required)' : requiresPairByDate(file, manifest.requiredSince) ? ' (required by date)' : ' (backlog)' + const required = requiresTranslationPair(file, manifest) + const tag = required ? ` (required ${translationDocumentClass(file)})` : ' (backlog)' console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`) } const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 } @@ -225,7 +218,7 @@ if (listMode) { } if (errors.length === 0) { - console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`) + console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} explicit requirements and required classes [${manifest.requiredClasses.join(', ')}], all consistent.`) process.exit(0) } From 7953759aa2cda3062d6a2211e35948976ca80a22 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:33:58 +0800 Subject: [PATCH 27/45] docs: remove missions folder --- missions/readme.md | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 missions/readme.md diff --git a/missions/readme.md b/missions/readme.md deleted file mode 100644 index 66167719ad..0000000000 --- a/missions/readme.md +++ /dev/null @@ -1,36 +0,0 @@ -# Workspace GUI 收尾备忘 - -## 产品改动 - -- 用户要求“去掉功能”时,先拆开视觉入口、可访问性语义和响应行为分别确认。本次 composer 加号保留原样和 `Add attachment` 标签,只在组合层停止传入 Workspace 回调;不要删除按钮、改样式或把它禁用。 -- 临时交互不应上浮到 React 呈现层。Session/Workspace Intent、首次消息保留和 materialize 重试归 runtime 对象与 service;组件只接收标准 action、hooks 和纯呈现状态。 -- RFC、测试名称和 PR 描述只写最终产品语义,不保留 `reconcilePublishedDraft`、`pendingCwd` 等已经撤销的中间方案。 - -## Snapshot 与测试定位 - -- `apps/web/tests/**/*.snapshot.ts` 验证 built application,需用 `DSH_EXAMPLE_MODE=lib`,并确认相关 `lib/` 已由当前源码构建;普通 source-mode Vitest 通过不能替代它。 -- 对 runtime 管理的受控输入执行 `fireEvent.change` 后,必须 `waitFor` 输入值回显再点击发送,否则发送可能读取旧的空 prompt。 -- 页面中 Workspace 与 Session 可以同名,禁止用无作用域的 `findByText` 定位。先用 `within` 锁定 Sessions tree、计数或对应 group,再找目标行。 -- 新 push 后先看 assembled snapshot 是否真正跑过;本地 focused snapshot 通过后仍以 `gh pr checks` 的 artifact job 为准。 - -## Coverage 收口 - -- 测试筛选和 coverage 筛选是两件事。用 owning tests 配合逐个 `--coverage.include=''`,先拿到真实未覆盖行和分支,不要直接反复跑全仓 coverage。 -- 多个 coverage 进程并发时必须给不同的 `--coverage.reportsDirectory`,否则报告目录互相覆盖。各 worker 完成后再跑一次合并后的精确 coverage,确认共享 worktree 的改动组合起来仍为 100%。 -- 全仓 coverage 若先被无关测试超时打断,不能把它当作目标文件的结论;先用精确 include 修本分支缺口,再让 CI exhaustive coverage 验证整体。 -- Coverage 测试仍要描述行为,不写“为了覆盖某分支”的注释。不可达分支才使用已有规范允许的 `v8 ignore`,可达分支补真实行为测试。 - -## 并发与提交 - -- Coverage 适合按不相交写区并发:例如 Sidebar tests、Workspace picker tests、connection/storage tests。派工时明确“只改 tests、不改 src、不 commit、不得回滚他人改动”。 -- 不直接信任各 worker 的单独结果;主会话审查 diff、运行合并后的 focused coverage、清理生成报告,再统一 commit。 -- 推送前按 `dsh-pre-push-checks` 选择最小充分验证,不重复已经通过的检查;正常 push 让 pre-push typecheck 运行,并核对本地 HEAD 与远端 ref 一致。 -- 生成的 `.coverage/` 只属于本地诊断。环境拒绝 `rm -rf` 时,依次使用 `find .coverage -type f -delete` 和 `find .coverage -depth -type d -empty -delete`;不要让报告进入 commit。 - -## GitHub 与 CI - -- GitHub 操作统一走 `gh`,并从 git 配置注入代理:`proxy="$(git config --get http.https://github.com.proxy)"; https_proxy="$proxy" http_proxy="$proxy" GH_PAGER=cat ~/.local/bin/gh ...`。不要改用网页。 -- 每次 push 都会产生一轮新 checks;旧轮次的失败不能代表当前 HEAD。先确认 run 对应当前提交,再拉失败日志。 -- `gh run watch` 只监视一个 workflow。最终必须用 `gh pr checks` 汇总 CI、e2e、sandbox 和 Windows 等独立 workflow;偶发平台失败先等当前 HEAD 重跑结果,不预先修改无关代码。 -- PR base 和 description 在最终 push 后再次用 `gh pr edit --base ... --body-file ...` 同步。PR 描述应包含最终产品动线、架构边界和实际运行过的验证,不写仍待执行的承诺。 -- Review thread 用 GraphQL/`gh api` 检查 `isResolved` 和已有回复,避免对已经解决的旧实现评论重复修复。 From 5fad2fc93445ae2875048b7009ab2deef6161f9d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:45:56 +0800 Subject: [PATCH 28/45] docs: define extensible PR labels --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index eae3007c86..e68ef8086a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- Pick matching existing GitHub labels such as `documentation`, `web`, `tui`, or `core`; never create new labels. +- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the taxonomy remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 23a60ade67215b2ad6da00e24921fba8dafda4b9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:51:36 +0800 Subject: [PATCH 29/45] refactor(gui): features register their own settings surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings collaboration direction (recorded in the note): the shell only provides composition faces — feature plugins register themselves. The General section moves into the ui-settings shell (order 0, skeleton rows) and declares the settings.general.item list slot; locale registers the Language row and ui-theme the Appearance row (each with its own store mirror, dictionaries, and ledger-judged deferral); the ui-settings-general package is gone. ui-settings-models becomes ui-models — a feature package that contributes its Settings section rather than a settings-owned satellite. The item-slot SlotMap entry is authored in the ui-settings contract and repeated verbatim in locale/ui-theme (reference-cycle avoidance; declaration merging keeps the copies identical). --- ...2026-07-25-client-settings-locale-theme.md | 42 ++++-- ...6-07-25-client-settings-locale-theme.zh.md | 44 +++--- apps/cli/cordis.yml | 7 +- apps/cli/package.json | 3 +- apps/cli/tsconfig.json | 5 +- apps/web/tests/session-title.snapshot.ts | 3 +- apps/web/tests/workspace-flow.snapshot.ts | 3 +- packages/client/locale/package.json | 22 ++- .../locale/src/client/LanguageRow.module.css | 47 ++++++ .../client/locale/src/client/LanguageRow.tsx | 68 +++++++++ packages/client/locale/src/client/index.ts | 72 ++++++++- .../locale/src/client/settings-contract.ts | 18 +++ .../locale/src/client/settings-store.ts | 47 ++++++ .../src/css-modules.d.ts | 0 .../client/locale/tests/invariant.spec.ts | 6 +- packages/client/locale/tsconfig.json | 9 ++ .../README.md | 2 +- .../package.json | 4 +- .../src/client/ModelsSection.tsx | 0 .../src/client/index.ts | 4 +- .../src/css-modules.d.ts | 0 .../src/index.ts | 0 .../src/invariant.ts | 8 +- .../tests/apply.spec.ts | 4 +- .../tests/invariant.spec.ts | 4 +- .../tsconfig.json | 0 packages/client/ui-models/tsdown.config.ts | 3 + packages/client/ui-settings-general/README.md | 15 -- .../client/ui-settings-general/package.json | 70 --------- .../src/client/GeneralSection.tsx | 118 --------------- .../src/client/contract.ts | 66 -------- .../ui-settings-general/src/client/index.ts | 117 --------------- .../ui-settings-general/src/client/store.ts | 43 ------ .../client/ui-settings-general/src/index.ts | 4 - .../ui-settings-general/src/invariant.ts | 32 ---- .../ui-settings-general/tests/apply.spec.ts | 139 ----------------- .../tests/general-section.spec.tsx | 112 -------------- .../tests/invariant.spec.ts | 18 --- .../ui-settings-general/tests/store.spec.ts | 56 ------- .../client/ui-settings-general/tsconfig.json | 36 ----- .../ui-settings-general/tsdown.config.ts | 3 - .../ui-settings-models/tsdown.config.ts | 3 - .../src/client/GeneralSection.module.css | 38 ++--- .../ui-settings/src/client/GeneralSection.tsx | 51 +++++++ .../ui-settings/src/client/contract/slots.ts | 38 ++++- .../client/ui-settings/src/client/index.ts | 59 +++++++- .../src/client/locales.ts | 26 ++-- .../client/ui-settings/tests/apply.spec.ts | 81 +++++++++- .../tests/general-section.spec.tsx | 47 ++++++ packages/client/ui-theme/package.json | 25 +++- .../src/client/AppearanceRow.module.css | 51 +++++++ .../ui-theme/src/client/AppearanceRow.tsx | 63 ++++++++ packages/client/ui-theme/src/client/index.ts | 89 ++++++++++- .../ui-theme/src/client/settings-contract.ts | 17 +++ .../ui-theme/src/client/settings-store.ts | 37 +++++ packages/client/ui-theme/src/css-modules.d.ts | 6 + .../client/ui-theme/tests/invariant.spec.ts | 10 +- packages/client/ui-theme/tsconfig.json | 12 ++ pnpm-lock.yaml | 141 +++++++++--------- .../verify-package-readme-model-experience.ts | 3 +- tsconfig.base.json | 3 +- tsconfig.client.json | 3 +- 62 files changed, 1008 insertions(+), 1049 deletions(-) create mode 100644 packages/client/locale/src/client/LanguageRow.module.css create mode 100644 packages/client/locale/src/client/LanguageRow.tsx create mode 100644 packages/client/locale/src/client/settings-contract.ts create mode 100644 packages/client/locale/src/client/settings-store.ts rename packages/client/{ui-settings-general => locale}/src/css-modules.d.ts (100%) rename packages/client/{ui-settings-models => ui-models}/README.md (92%) rename packages/client/{ui-settings-models => ui-models}/package.json (89%) rename packages/client/{ui-settings-models => ui-models}/src/client/ModelsSection.tsx (100%) rename packages/client/{ui-settings-models => ui-models}/src/client/index.ts (96%) rename packages/client/{ui-settings-models => ui-models}/src/css-modules.d.ts (100%) rename packages/client/{ui-settings-models => ui-models}/src/index.ts (100%) rename packages/client/{ui-settings-models => ui-models}/src/invariant.ts (81%) rename packages/client/{ui-settings-models => ui-models}/tests/apply.spec.ts (96%) rename packages/client/{ui-settings-models => ui-models}/tests/invariant.spec.ts (82%) rename packages/client/{ui-settings-models => ui-models}/tsconfig.json (100%) create mode 100644 packages/client/ui-models/tsdown.config.ts delete mode 100644 packages/client/ui-settings-general/README.md delete mode 100644 packages/client/ui-settings-general/package.json delete mode 100644 packages/client/ui-settings-general/src/client/GeneralSection.tsx delete mode 100644 packages/client/ui-settings-general/src/client/contract.ts delete mode 100644 packages/client/ui-settings-general/src/client/index.ts delete mode 100644 packages/client/ui-settings-general/src/client/store.ts delete mode 100644 packages/client/ui-settings-general/src/index.ts delete mode 100644 packages/client/ui-settings-general/src/invariant.ts delete mode 100644 packages/client/ui-settings-general/tests/apply.spec.ts delete mode 100644 packages/client/ui-settings-general/tests/general-section.spec.tsx delete mode 100644 packages/client/ui-settings-general/tests/invariant.spec.ts delete mode 100644 packages/client/ui-settings-general/tests/store.spec.ts delete mode 100644 packages/client/ui-settings-general/tsconfig.json delete mode 100644 packages/client/ui-settings-general/tsdown.config.ts delete mode 100644 packages/client/ui-settings-models/tsdown.config.ts rename packages/client/{ui-settings-general => ui-settings}/src/client/GeneralSection.module.css (75%) create mode 100644 packages/client/ui-settings/src/client/GeneralSection.tsx rename packages/client/{ui-settings-general => ui-settings}/src/client/locales.ts (64%) create mode 100644 packages/client/ui-settings/tests/general-section.spec.tsx create mode 100644 packages/client/ui-theme/src/client/AppearanceRow.module.css create mode 100644 packages/client/ui-theme/src/client/AppearanceRow.tsx create mode 100644 packages/client/ui-theme/src/client/settings-contract.ts create mode 100644 packages/client/ui-theme/src/client/settings-store.ts create mode 100644 packages/client/ui-theme/src/css-modules.d.ts diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index e1245a9e1f..0dfcb4ea90 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -10,41 +10,48 @@ The browser client's existing Settings is written directly inside the Sidebar, a ## Proposal -The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares the `settings.section` list slot. Each section is contributed by an independent plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. +**Collaboration doctrine (how every later module joins Settings): feature owners self-register.** The Settings shell provides only the composition surface (the top-level section list plus the item list inside General) and neither imports nor enumerates any feature; for a feature to appear in Settings, its own plugin registers into the corresponding slot — locale registers the Language row, ui-theme registers the Appearance row, ui-models registers the Models top-level panel. No separate `ui-settings-*` package is created for "a feature's settings page": the settings surface belongs to the feature package itself (shipping the Theme feature means Theme's settings choices ship with ui-theme). The only content the shell carries itself is the first top-level directory, General (skeleton rows plus the item slot declaration), because it belongs to no single feature. + +The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares the `settings.section` list slot. Each section is contributed by a feature plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. General is registered by the shell itself (order 0) and declares the `settings.general.item` list slot, into which the feature plugins' preference rows slot by order. The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind. `@deepseek-ai/dsh-client-locale` provides `ctx.locale`; `ui-theme` provides `ctx.theme`. Both services read through a getter, write through a setter, and publish immutable snapshots via typed Cordis change events; each service persists its own preference (storing only the id, with bad values falling back to the default). -General's apply layer subscribes to `locale/change` and `theme/change` and projects the snapshots into the Zustand store declared by that section. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services. +Each feature row's apply layer subscribes to its own change event (locale to `locale/change`, ui-theme to `theme/change`) and projects the snapshot into the slot store declared when that row registered. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services. The theme preference has three states — `light`, `dark`, `system` — defaulting to `system` (when no persisted preference exists or the value is bad). Resolving system belongs to the theme domain: ThemeService holds the `prefers-color-scheme` matchMedia listener (environment sensing, not DOM presentation) and re-emits the snapshot when the preference is system and the system color scheme changes; the snapshot carries both `preference` and the resolved `active` definition. The theme service never touches the DOM. `ui-layout` reads the Theme getter initially and then subscribes to `theme/change`; the presenter owned by Layout updates `body[data-ds-dark-theme]` and the theme tokens according to `active`. The presenter has no notion of system — it consumes only resolved results. -### First-phase section scope +### First-phase registration surfaces -| section | Plugin | First-phase content | +| Registration surface | Owning plugin | First-phase content | |---|---|---| -| General | `ui-settings-general` | Language (Selector dropdown) and Appearance (Light/Dark/System three cubes) genuinely switch; Permission and Tool Call are visual skeletons only, with no write operations | -| Models | `ui-settings-models` | Navigation item only; the content area is empty | -| Plugin | no package | Not built this phase, and the navigation does not show the item (an external-link entry with no target never renders; once a later plugin registers the section it appears automatically) | +| General section (order 0) | built into the `ui-settings` shell | Permission and Tool Call visual skeletons (no write operations) plus the `settings.general.item` slot declaration | +| Language row (item order 0) | `locale` | Selector dropdown; 中文/English genuinely switch | +| Appearance row (item order 10) | `ui-theme` | Light/Dark/System three cubes genuinely switch (the selected state reflects preference) | +| Models section (order 10) | `ui-models` | Navigation item only, with an empty content area; later model-management features land in that package | +| Plugin | none | Not built this phase, and the navigation does not show the item (once a later plugin feature package registers the section it appears automatically) | -The first phase localizes only the copy inside the Settings overlay (the General rows plus the navigation); copy on other pages is untouched. +The first phase localizes only the copy inside the Settings overlay; dictionaries stay close to their owners — shell copy (the chrome plus the General skeletons) lives in the `settings` namespace, and feature-row copy lives in each feature package (`settings.locale`, `settings.theme`, `settings.models`). ### Slot topology ```text root └─ sidebar - └─ sidebar.settings single/root - └─ ui-settings - └─ settings.section list/root - ├─ general ui-settings-general - └─ models ui-settings-models + └─ sidebar.settings single/root + └─ ui-settings(壳) + └─ settings.section list/root + ├─ general (order 0) ui-settings 壳自带 + │ └─ settings.general.item list/root + │ ├─ language (0) locale 注册 + │ └─ appearance (10) ui-theme 注册 + └─ models (order 10) ui-models 注册 ``` -Section contributions use declaration-aware deferral and do not depend on the client manifest's apply order. +Section and item contributions both use declaration-aware deferral and do not depend on the client manifest's apply order. The `settings.general.item` SlotMap entry's canonical home is the ui-settings contract; locale/ui-theme, because of the reference cycle (the shell consumes ctx.locale), consume that entry as verbatim duplicated merges, with declaration merging guaranteeing the copies agree. ### Service contracts @@ -95,13 +102,16 @@ Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the on **Settings importing and enumerating the sections.** Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin. +**One `ui-settings-*` package per section (the first-cut implementation).** It divorces the settings surface from the feature itself: changing Theme behavior touches two packages, the package count grows linearly with settings items, and settings-general depending back on the locale/theme services forms an intermediate layer that exists purely for the package split. After converging on feature-owner self-registration, General belongs to the shell (it belongs to no single feature) and preference rows ship with their feature packages. + **Injecting the Locale/Theme snapshots into React directly.** Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding. ## Acceptance criteria -- The Settings shell depends only on the slot ledger, never on any section implementation. +- The Settings shell depends only on the slot ledger, never on any feature implementation; General's item list likewise depends only on the ledger. +- Adding a settings item = the feature package registering it itself (a section or a general item), with zero shell changes. - Locale and Theme writes go only through the setters; ongoing synchronization goes only through the change events. -- The General store initializes from the getters and is thereafter updated by the two events with local re-renders. +- Each feature row's store initializes from the getter and is thereafter updated by its own change event with local re-renders. - Layout applies the theme snapshot on its own and the theme service never accesses the DOM; no system branch appears in the presenter. - 中文/English and Light/Dark/System switch and are restored after a refresh; with the preference on system, a system color-scheme change takes effect immediately. - Models has only a navigation item and an empty content area; the Permission and Tool Call skeletons perform no writes. diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index 195b2e5ffa..dfe93a0ca1 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -10,41 +10,48 @@ Status: proposed ## Proposal -Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由独立插件贡献;Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。 +**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳只提供组合面(一级 section 列表 + General 内的 item 列表),不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。壳自带的唯一内容是第一个一级目录 General(骨架行 + item 坑位声明),因为它不属于任何单一功能。 + +Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由功能插件贡献;Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由壳自己注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。 Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。 `@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`,`ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。 -General 的 apply 层订阅 `locale/change` 和 `theme/change`,把 snapshot 投影到该 section 声明的 Zustand store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。 +功能行的 apply 层各自订阅自家 change event(locale 订 `locale/change`,ui-theme 订 `theme/change`),把 snapshot 投影到该行注册时声明的 slot store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。 Theme 偏好三态:`light`、`dark`、`system`,默认 `system`(无持久化偏好或坏值时)。system 的解析属主题领域:ThemeService 持有 `prefers-color-scheme` matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshot;snapshot 同时携带 `preference` 与解析后的 `active` 定义。 Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。 -### 首期 section 范围 +### 首期注册面 -| section | 插件 | 首期内容 | +| 注册面 | 属主插件 | 首期内容 | |---|---|---| -| General | `ui-settings-general` | Language(Selector 下拉)与 Appearance(Light/Dark/System 三 cube)真实可切;Permission、Tool Call 仅视觉骨架,无写操作 | -| Models | `ui-settings-models` | 仅导航项,内容区为空 | -| Plugin | 不建包 | 首期不做,导航不出现该项(无目标的外链入口不上屏;后续插件注册 section 即自动出现) | +| General section(order 0)| `ui-settings` 壳自带 | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 | +| Language 行(item order 0)| `locale` | Selector 下拉,中文/English 真实可切 | +| Appearance 行(item order 10)| `ui-theme` | Light/Dark/System 三 cube 真实可切(选中态看 preference) | +| Models section(order 10)| `ui-models` | 仅导航项,内容区为空;后续模型管理功能落在该包 | +| Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) | -首期只翻译 Settings 浮层内文案(General 各行 + 导航);其他页面文案不动。 +首期只翻译 Settings 浮层内文案;字典就近——壳文案(chrome + General 骨架)归 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。 ### Slot topology ```text root └─ sidebar - └─ sidebar.settings single/root - └─ ui-settings - └─ settings.section list/root - ├─ general ui-settings-general - └─ models ui-settings-models + └─ sidebar.settings single/root + └─ ui-settings(壳) + └─ settings.section list/root + ├─ general (order 0) ui-settings 壳自带 + │ └─ settings.general.item list/root + │ ├─ language (0) locale 注册 + │ └─ appearance (10) ui-theme 注册 + └─ models (order 10) ui-models 注册 ``` -section contribution 使用 declaration-aware deferral,不依赖 client manifest 的 apply 顺序。 +section/item contribution 均使用 declaration-aware deferral,不依赖 client manifest 的 apply 顺序。`settings.general.item` 的 SlotMap 条目正家在 ui-settings contract;locale/ui-theme 因引用环(壳消费 ctx.locale)以逐字重复合并的方式消费该条目,declaration merging 保证副本一致。 ### Service contracts @@ -95,13 +102,16 @@ Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未 **Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。 +**每个 section 单开 `ui-settings-*` 包(首版实现)。** 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且 settings-general 反向依赖 locale/theme 服务形成纯粹为拆包而生的中间层。收敛为功能属主自注册后,General 归壳(不属任何单一功能),preference 行随功能包交付。 + **把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。 ## Acceptance criteria -- Settings 壳只依赖 slot ledger,不依赖任一 section 实现。 +- Settings 壳只依赖 slot ledger,不依赖任一功能实现;General 的 item 列表同样只依赖 ledger。 +- 新增一个设置项 = 功能包自己注册(section 或 general item),零壳改动。 - Locale 与 Theme 的写入只走 setter,持续同步只走 change event。 -- General store 初始化走 getter,后续由两个 event 更新并局部重渲染。 +- 功能行 store 初始化走 getter,后续由自家 change event 更新并局部重渲染。 - Layout 独立应用 Theme snapshot,Theme service 不访问 DOM;presenter 不出现 system 分支。 - 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。 - Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。 @@ -109,4 +119,4 @@ Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未 ## Risks -slot 声明与 contribution 的 apply 顺序不固定,所有新 section 必须保留 declaration-aware registration 和幂等防护。service event 可能早于 section 首次渲染,General store 的 init 与 controller attach 都必须从 getter 对齐当前 snapshot。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 +slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger(而非本地 disposer)判定在位。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 8412a313b4..39803aa603 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -233,11 +233,8 @@ - id: ui-settings name: '@deepseek-ai/dsh-client-ui-settings' -- id: ui-settings-general - name: '@deepseek-ai/dsh-client-ui-settings-general' - -- id: ui-settings-models - name: '@deepseek-ai/dsh-client-ui-settings-models' +- id: ui-models + name: '@deepseek-ai/dsh-client-ui-models' - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/apps/cli/package.json b/apps/cli/package.json index 74a6e417e1..ddf8a08a87 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -31,8 +31,7 @@ "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-models": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 7614ae2e31..3000fd1f8d 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -45,10 +45,7 @@ "path": "../../packages/client/ui-settings" }, { - "path": "../../packages/client/ui-settings-general" - }, - { - "path": "../../packages/client/ui-settings-models" + "path": "../../packages/client/ui-models" }, { "path": "../../packages/client/locale" diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index b8ee3408a3..35356fa0b3 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -14,8 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, - { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, - { id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, + { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e232d8e123..738357a551 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -14,8 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, - { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, - { id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, + { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 340efe042e..d53d2e4269 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)", + "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", "version": "0.0.1", "private": true, "type": "module", @@ -23,18 +23,29 @@ "./package.json": "./package.json" }, "dshClient": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-runtime" + ], "platform": "web", "immediately": true }, "license": "BSD-3-Clause", "peerDependencies": { + "@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", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", @@ -47,5 +58,8 @@ "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" + }, + "dependencies": { + "clsx": "^2.0.0" } } diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css new file mode 100644 index 0000000000..f17a67d279 --- /dev/null +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -0,0 +1,47 @@ +/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator; + * the section column removes the separator on its last child). */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.chevron { + flex: none; +} diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx new file mode 100644 index 0000000000..a824bc6752 --- /dev/null +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -0,0 +1,68 @@ +/** + * Language preference row registered into the General section item slot + * (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale + * menu. Registered by this package — the locale feature owns its own + * settings surface. + */ +import { useState } from 'react' +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from './settings-contract.ts' +import type { createLanguageRowStore } from './settings-store.ts' +import css from './LanguageRow.module.css' + +/** Injected business face: namespace-bound translate + the preference write. */ +export interface LanguageRowInjected { + /** Translate a `settings.locale` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the active locale (a registered locale id). */ + setLocale: (id: string) => void +} + +/** Full component props: runtime share + store share + injected face. */ +export type LanguageRowComponentProps = + PropsRuntime<'settings.general.item'> & PropsStore> & LanguageRowInjected + +/** + * Render the Language row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) { + const active = useStore(s => s.active) + const options = useStore(s => s.options) + const [open, setOpen] = useState(false) + const activeLabel = options.find(o => o.id === active)?.label ?? active + + return ( +
+
+
{t('language.title')}
+
+ { setOpen(false) }} + items={options.map(o => ({ id: o.id, label: o.label }))} + selectedId={active} + onSelect={(id) => { + setLocale(id) + setOpen(false) + }} + align="end" + portal + anchor={( + + )} + /> +
+ ) +} diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 35f29a0e52..444594f9b8 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -1,10 +1,20 @@ /** * Browser-side locale registry. Bound translation functions retain stable - * identity for injected consumers. + * identity for injected consumers. The plugin also registers the Language + * preference row into the settings General section — the locale 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 { en } from '../locales/en.ts' import { zh } from '../locales/zh.ts' +import type { LanguageRowInjected } from './LanguageRow.tsx' +import { LanguageRow } from './LanguageRow.tsx' +import { createLanguageRowStore } from './settings-store.ts' + +export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx' +export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' /** Translate a key with optional params. */ export type Translate = (key: string, params?: Record) => string @@ -53,6 +63,9 @@ export const FALLBACK_LOCALE: LocaleId = 'zh' /** Shared namespace for shell-level texts. */ 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' @@ -183,16 +196,65 @@ function persistPreference(id: LocaleId): void { } } -/** Required services (none; the loader passes the export surface as an object plugin). */ -export const inject: string[] = [] +/** Required services: the slot registry (the feature registers its own settings row). */ +export const inject = ['slots'] /** - * Client plugin body: provide the locale service with base dictionaries. + * Client plugin body: provide the locale service with base dictionaries and + * register the feature-owned Language preference row into the General + * section's item slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export function apply(ctx: Context): void { +export function apply(ctx: ClientContext): void { const locale = new LocaleService(ctx) locale.register(COMMON_NS, 'zh', zh) locale.register(COMMON_NS, 'en', en) + locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' }) + locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' }) ctx.provide('locale', locale) + + const store = createLanguageRowStore() + let bound: BoundActions | undefined + const sync = (snapshot: LocaleSnapshot): void => { + bound?.sync( + snapshot.active, + snapshot.locales.map(l => ({ id: l.id, label: l.label })), + snapshot.revision, + ) + } + ctx.on('locale/change', sync) + const injected = (actions: BoundActions): LanguageRowInjected => { + bound = actions + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(locale.getLocale()) + return { + t: locale.bind(SETTINGS_NS), + setLocale: (id) => { locale.setLocale(id) }, + } + } + // Declaration-aware registration; the LEDGER is the has-registered judge + // (not a local flag): after an HMR collapse re-declares the slot, the + // cascade already removed our entry, and a stale disposer must not block + // the re-registration. + ctx.effect(() => { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (ctx.slots.spec('settings.general.item') === undefined) return + if (ctx.slots.entries('settings.general.item').some(e => e.component === LanguageRow)) return + dispose = ctx.slots.register({ + name: 'settings.general.item', + id: 'language', + order: 0, + store, + inject: injected, + }, LanguageRow) + } + const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) + tryRegister() + return () => { + unsubscribe() + dispose?.() + } + }, 'locale: language settings row registration') } diff --git a/packages/client/locale/src/client/settings-contract.ts b/packages/client/locale/src/client/settings-contract.ts new file mode 100644 index 0000000000..add314122c --- /dev/null +++ b/packages/client/locale/src/client/settings-contract.ts @@ -0,0 +1,18 @@ +/** + * Settings-surface slot merge consumed by this package's Language row. The + * AUTHORITATIVE home for 'settings.general.item' is the ui-settings contract + * (declaring is claiming: the shell's General entry declares the slot); this + * file repeats the entry verbatim because the shell consumes ctx.locale + * (project reference ui-settings -> locale), so importing the shell's types + * from here would close a reference cycle. TypeScript declaration merging + * rejects diverging duplicates, so every program that sees both copies (the + * shell's own build, the client aggregate) enforces identity. + */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** One preference row inside the General section (duplicate-identical merge; authority: ui-settings contract). */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } } + } +} + +export {} diff --git a/packages/client/locale/src/client/settings-store.ts b/packages/client/locale/src/client/settings-store.ts new file mode 100644 index 0000000000..485fd409f0 --- /dev/null +++ b/packages/client/locale/src/client/settings-store.ts @@ -0,0 +1,47 @@ +/** + * Language row slot store: a mirror of the locale service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' + +/** One selectable locale row (id + self-described label). */ +export interface LanguageOptionRow { + /** Locale id (the setLocale argument). */ + id: string + /** Display name in its own language (中文 / English). */ + label: string +} + +/** Store state mirrored from the locale snapshot. */ +export interface LanguageRowState { + /** Active locale id. */ + active: string + /** Selectable locales in display order. */ + options: LanguageOptionRow[] + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number +} + +/** Declared action shape giving the exported factory a stable return type. */ +type LanguageRowActions = { + sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void +} + +/** + * Declares the Language row state and write surface. + * @returns the store handle. + */ +export function createLanguageRowStore(): EngineStoreHandle { + return defineStore({ + init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }), + actions: { + sync: (d, active: string, options: LanguageOptionRow[], revision: number) => { + if (revision <= d.revision) return + d.active = active + d.options = options + d.revision = revision + }, + }, + }) +} diff --git a/packages/client/ui-settings-general/src/css-modules.d.ts b/packages/client/locale/src/css-modules.d.ts similarity index 100% rename from packages/client/ui-settings-general/src/css-modules.d.ts rename to packages/client/locale/src/css-modules.d.ts diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts index e9f4fdee36..fa62ca79f6 100644 --- a/packages/client/locale/tests/invariant.spec.ts +++ b/packages/client/locale/tests/invariant.spec.ts @@ -1,8 +1,10 @@ +// @vitest-environment jsdom import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client' import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' describe('invariant companion', () => { @@ -18,8 +20,10 @@ describe('invariant companion', () => { }) it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { - expect(inject).toEqual([]) + // The feature registers its own Language settings row, hence the slots edge. + expect(inject).toEqual(['slots']) const ctx = new Context() + new SlotsService(ctx) await ctx.plugin({ inject, apply: clientApply }).await() const locale = ctx.get('locale') expect(locale).toBeInstanceOf(LocaleService) diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 51f9171643..8585ba74ca 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -8,6 +8,15 @@ "src" ], "references": [ + { + "path": "../runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-models/README.md similarity index 92% rename from packages/client/ui-settings-models/README.md rename to packages/client/ui-models/README.md index c96e2843e4..b5f6c9bea9 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-models/README.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-client-ui-settings-models +# @deepseek-ai/dsh-client-ui-models Models settings section plugin: registers the `models` nav entry into `settings.section` with an intentionally empty content column — model management lands in a later phase. diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-models/package.json similarity index 89% rename from packages/client/ui-settings-models/package.json rename to packages/client/ui-models/package.json index da8fa8c18d..df8cba7c8b 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-models/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-ui-settings-models", - "description": "Models settings section plugin: nav entry with an empty content column (model management lands later)", + "name": "@deepseek-ai/dsh-client-ui-models", + "description": "Models feature plugin: registers its Settings section (nav entry, empty content column; model management lands later)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx similarity index 100% rename from packages/client/ui-settings-models/src/client/ModelsSection.tsx rename to packages/client/ui-models/src/client/ModelsSection.tsx diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts similarity index 96% rename from packages/client/ui-settings-models/src/client/index.ts rename to packages/client/ui-models/src/client/index.ts index c35946fcc8..4f080ec928 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void { ctx.locale.register('settings.models', 'en', { nav: 'Models' }), ] return () => { for (const dispose of disposers) dispose() } - }, 'ui-settings-models: nav copy dictionaries') + }, 'ui-models: nav copy dictionaries') // Declaration-aware registration; the LEDGER is the has-registered judge // (not a local flag): after an HMR collapse re-declares the slot, the // cascade already removed our entry, and a stale disposer must not block @@ -63,5 +63,5 @@ export function apply(ctx: ClientContext): void { unsubscribe() dispose?.() } - }, 'ui-settings-models: section registration') + }, 'ui-models: settings section registration') } diff --git a/packages/client/ui-settings-models/src/css-modules.d.ts b/packages/client/ui-models/src/css-modules.d.ts similarity index 100% rename from packages/client/ui-settings-models/src/css-modules.d.ts rename to packages/client/ui-models/src/css-modules.d.ts diff --git a/packages/client/ui-settings-models/src/index.ts b/packages/client/ui-models/src/index.ts similarity index 100% rename from packages/client/ui-settings-models/src/index.ts rename to packages/client/ui-models/src/index.ts diff --git a/packages/client/ui-settings-models/src/invariant.ts b/packages/client/ui-models/src/invariant.ts similarity index 81% rename from packages/client/ui-settings-models/src/invariant.ts rename to packages/client/ui-models/src/invariant.ts index 9ffdc668d7..c7c4996748 100644 --- a/packages/client/ui-settings-models/src/invariant.ts +++ b/packages/client/ui-models/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-models`. - * @module @deepseek-ai/dsh-client-ui-settings-models/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-models`. + * @module @deepseek-ai/dsh-client-ui-models/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-models' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-models' /** Cordis companion plugin name. */ -export const name = 'client-ui-settings-models-invariant' +export const name = 'client-ui-models-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/client/ui-settings-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts similarity index 96% rename from packages/client/ui-settings-models/tests/apply.spec.ts rename to packages/client/ui-models/tests/apply.spec.ts index b7aa3cf2ab..1842000675 100644 --- a/packages/client/ui-settings-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-models/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-models/client' import { ModelsSection } from '../src/client/ModelsSection.tsx' async function bench() { @@ -21,7 +21,7 @@ function declare(slots: SlotsService): () => void { ) } -describe('ui-settings-models apply', () => { +describe('ui-models apply', () => { it('declares the services it uses', () => { expect(inject).toEqual(['slots', 'locale']) }) diff --git a/packages/client/ui-settings-models/tests/invariant.spec.ts b/packages/client/ui-models/tests/invariant.spec.ts similarity index 82% rename from packages/client/ui-settings-models/tests/invariant.spec.ts rename to packages/client/ui-models/tests/invariant.spec.ts index 65c7c1094a..05fb52ee1b 100644 --- a/packages/client/ui-settings-models/tests/invariant.spec.ts +++ b/packages/client/ui-models/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant' +import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-models/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' import { ModelsSection } from '../src/client/ModelsSection.tsx' @@ -12,7 +12,7 @@ describe('invariant companion', () => { }) it('node-half apply is a no-op host placeholder', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-models') + const { apply } = await import('@deepseek-ai/dsh-client-ui-models') apply() expect(true).toBe(true) // reaching here without throw is the contract }) diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-models/tsconfig.json similarity index 100% rename from packages/client/ui-settings-models/tsconfig.json rename to packages/client/ui-models/tsconfig.json diff --git a/packages/client/ui-models/tsdown.config.ts b/packages/client/ui-models/tsdown.config.ts new file mode 100644 index 0000000000..fc044b07d6 --- /dev/null +++ b/packages/client/ui-models/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-models', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md deleted file mode 100644 index 435dd8c1b4..0000000000 --- a/packages/client/ui-settings-general/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# @deepseek-ai/dsh-client-ui-settings-general - -General settings section plugin: registers the `general` entry into `settings.section`. Language (中文/English) and Appearance (Light/Dark/System) are live preferences wired to `ctx.locale` / `ctx.theme`; Permission and Tool Call rows are visual skeletons with no write surface. - -## Model Experience - -None, as the section renders browser preference UI; nothing here reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json deleted file mode 100644 index 9b3d83e472..0000000000 --- a/packages/client/ui-settings-general/package.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-client-ui-settings-general", - "description": "General settings section plugin: Language and Appearance preferences (live), Permission and Tool Call skeleton rows", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-theme" - ], - "platform": "web" - }, - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, - "license": "BSD-3-Clause", - "dependencies": { - "clsx": "^2.0.0" - }, - "peerDependencies": { - "@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", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ] -} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx deleted file mode 100644 index d2947c0435..0000000000 --- a/packages/client/ui-settings-general/src/client/GeneralSection.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * General settings section: Permission and Tool Call skeleton rows (visual - * only, no interaction), live Language and Appearance preference rows wired - * through the injected setLocale/setTheme callbacks and the snapshot-mirror - * store. Figma: Settings > Content > Options (501:29983). - */ -import { useState } from 'react' -import clsx from 'clsx' -import { - IconChevronDownOutline14, IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, - Menu, -} from '@deepseek-ai/dsh-client-ui-primitives' -import type { GeneralSectionComponentProps, ThemePreferenceId } from './contract.ts' -import css from './GeneralSection.module.css' - -/** Appearance cube order and icons (figma 501:30015-30017: Light, Dark, System). */ -const THEME_CUBES: readonly { id: ThemePreferenceId; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ - { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, - { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, - { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, -] - -/** - * Render the General section content column. - * @param props - composed slot props (contract.ts). - * @returns the section element tree. - */ -export function GeneralSection(props: GeneralSectionComponentProps) { - const { t, setLocale, setTheme, useStore } = props - const localeActive = useStore(s => s.localeActive) - const localeOptions = useStore(s => s.localeOptions) - const themePreference = useStore(s => s.themePreference) - const [languageOpen, setLanguageOpen] = useState(false) - - const activeLocaleLabel = localeOptions.find(l => l.id === localeActive)?.label ?? localeActive - - return ( -
- {/* Permission (skeleton): disabled selector pill. */} -
-
-
{t('permission.title')}
-
{t('permission.desc')}
-
- -
- - {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} -
-
{t('toolcall.title')}
-
-
-
{t('toolcall.schema.title')}
-
{t('toolcall.schema.desc')}
-
-
-
{t('toolcall.code.title')}
-
{t('toolcall.code.desc')}
-
-
-
- - {/* Language: selector pill opens the locale menu. */} -
-
-
{t('language.title')}
-
- { setLanguageOpen(false) }} - items={localeOptions.map(l => ({ id: l.id, label: l.label }))} - selectedId={localeActive} - onSelect={(id) => { - setLocale(id) - setLanguageOpen(false) - }} - align="end" - portal - anchor={( - - )} - /> -
- - {/* Appearance: three preference cubes; selection follows the persisted - * preference, never the resolved active theme. */} -
-
{t('appearance.title')}
-
- {THEME_CUBES.map(({ id, labelKey, Icon }) => ( - - ))} -
-
-
- ) -} diff --git a/packages/client/ui-settings-general/src/client/contract.ts b/packages/client/ui-settings-general/src/client/contract.ts deleted file mode 100644 index 912d925615..0000000000 --- a/packages/client/ui-settings-general/src/client/contract.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * General section component contract: the slot-store state shape, the - * injected business face, and the composed props type. The component imports - * only from here; service snapshot shapes are mirrored as plain rows so the - * presentation layer stays decoupled from the locale/theme packages. - */ -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). -import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -import type { createGeneralSettingsStore } from './store.ts' - -/** One selectable locale row projected into the store (id + self-described label). */ -export interface LocaleOptionRow { - /** Locale id (the setLocale argument). */ - id: string - /** Display name in its own language (中文 / English). */ - label: string -} - -/** Theme preference union mirrored from the theme service snapshot. */ -export type ThemePreferenceId = 'light' | 'dark' | 'system' - -/** - * Store state: mirrors of the locale/theme service snapshots, written only by - * the plugin's apply-world change listeners (components have no write path — - * preference writes go through the injected callbacks to the services, and - * the resulting change events flow back into this mirror). - */ -export interface GeneralSettingsState { - /** Active locale id. */ - localeActive: string - /** Selectable locales in display order. */ - localeOptions: LocaleOptionRow[] - /** Locale service revision (re-renders translated copy on dictionary/locale changes); -1 until first sync. */ - localeRevision: number - /** Persisted theme preference (selection state reads this, never the resolved active theme). */ - themePreference: ThemePreferenceId - /** Theme service revision; -1 until first sync. */ - themeRevision: number -} - -/** - * Registrant-private injected share of the General section (assembled in - * apply): the namespace-bound translate function (stable identity — re-render - * on locale change comes from the store revision, not from `t`) and the two - * preference write callbacks. - */ -export interface GeneralSectionInjected { - /** Translate a `settings.general` dictionary key to the active-locale text. */ - t: (key: string) => string - /** Switch the active locale (a registered locale id). */ - setLocale: (id: string) => void - /** Switch the theme preference. */ - setTheme: (id: ThemePreferenceId) => void -} - -/** Store handle type for the props share (type-only; the factory stays internal to apply and tests). */ -export type GeneralSettingsStoreHandle = ReturnType - -/** - * Full component props of the General section: the section owner share - * (empty marker) plus the store share and the injected face. No child slots - * are declared; menu open state is component-local viewing state. - */ -export type GeneralSectionComponentProps = - PropsRuntime<'settings.section'> & PropsStore & GeneralSectionInjected diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts deleted file mode 100644 index 52655be7fa..0000000000 --- a/packages/client/ui-settings-general/src/client/index.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * General settings section plugin, browser half. Registers the `general` - * entry into the shell-declared `settings.section` list slot; Language and - * Appearance are live preferences projected from ctx.locale / ctx.theme - * through this entry's slot store. Export discipline: packages/client/AGENTS.md. - */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). -import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -// Type-only: the locale/theme Context+Events merges and snapshot shapes. -import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' -import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' -import type { GeneralSectionInjected } from './contract.ts' -import { createGeneralSettingsStore } from './store.ts' -import { en, zh } from './locales.ts' -import { GeneralSection } from './GeneralSection.tsx' - -export type { - GeneralSectionComponentProps, GeneralSectionInjected, GeneralSettingsState, - GeneralSettingsStoreHandle, LocaleOptionRow, ThemePreferenceId, -} from './contract.ts' - -/** Dictionary namespace owned by this section (also the nav-label reference prefix). */ -const NS = 'settings.general' - -/** - * Required services (cordis fiber inject). The target slot is declared by - * ui-settings' apply, whose activation order relative to this one is NOT - * constrained; registration goes through declaration-aware deferral. - */ -export const inject = ['slots', 'locale', 'theme'] - -/** - * Register the `settings.general` dictionaries and the General section entry - * once the `settings.section` declaration is on the ledger. The slot store - * mirrors the locale/theme snapshots: change listeners attach here in apply, - * write through the bound actions captured at inject time, and the inject - * factory re-syncs from the getters so no event is lost between registration - * and first render (the store's revision guard drops stale duplicates). - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - ctx.effect(() => { - const disposeZh = ctx.locale.register(NS, 'zh', zh) - const disposeEn = ctx.locale.register(NS, 'en', en) - return () => { - disposeZh() - disposeEn() - } - }, 'ui-settings-general: dictionaries') - - const store = createGeneralSettingsStore() - let bound: BoundActions | undefined - - const syncLocale = (snapshot: LocaleSnapshot): void => { - bound?.syncLocale( - snapshot.active, - snapshot.locales.map(l => ({ id: l.id, label: l.label })), - snapshot.revision, - ) - } - const syncTheme = (snapshot: ThemeSnapshot): void => { - bound?.syncTheme(snapshot.preference, snapshot.revision) - } - ctx.on('locale/change', syncLocale) - ctx.on('theme/change', syncTheme) - - const injected = (actions: BoundActions): GeneralSectionInjected => { - bound = actions - syncLocale(ctx.locale.getLocale()) - syncTheme(ctx.theme.getTheme()) - return { - t: ctx.locale.bind(NS), - setLocale: (id) => { ctx.locale.setLocale(id) }, - setTheme: (id) => { ctx.theme.setTheme(id) }, - } - } - - ctx.effect(() => { - let dispose: (() => void) | undefined - // Presence is judged on the ledger, not on the local disposer: an HMR - // collapse of the declaring entry removes this entry from the slot core - // while `dispose` stays set (the stale disposer is a no-op), so a local - // guard would block the re-registration when the declaration returns. - const registered = (): boolean => - ctx.slots.entries('settings.section').some(e => e.component === GeneralSection) - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined || registered()) return - dispose = ctx.slots.register({ - name: 'settings.section', - id: 'general', - order: 0, - label: ctx.locale.bind(NS)('nav'), - store, - inject: injected, - }, GeneralSection) - } - // Nav labels are registrant-localized: re-register on locale change so - // the ledger carries fresh text (the version bump re-renders the shell). - // The ledger check mirrors tryRegister: after an HMR collapse `dispose` - // stays set while the entry is gone — relabeling then must stay quiet. - const offLocale = ctx.on('locale/change', () => { - if (dispose === undefined || !registered()) return - dispose() - dispose = undefined - tryRegister() - }) - const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) - tryRegister() - return () => { - offLocale() - unsubscribe() - dispose?.() - } - }, 'ui-settings-general: section registration') -} diff --git a/packages/client/ui-settings-general/src/client/store.ts b/packages/client/ui-settings-general/src/client/store.ts deleted file mode 100644 index 121e3f52a9..0000000000 --- a/packages/client/ui-settings-general/src/client/store.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * General section slot store: locale/theme snapshot mirrors. The plugin - * creates the handle at apply time (identity follows the fiber) and its - * change listeners are the only writers; components read via props.useStore. - */ -import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' -import type { GeneralSettingsState, LocaleOptionRow, ThemePreferenceId } from './contract.ts' - -/** Declared action shape used to give the exported factory a stable return type. */ -type GeneralSettingsActions = { - syncLocale: (draft: GeneralSettingsState, active: string, options: LocaleOptionRow[], revision: number) => void - syncTheme: (draft: GeneralSettingsState, preference: ThemePreferenceId, revision: number) => void -} - -/** - * Declares the General section state and write surface. Revisions start at -1 - * so the apply-time initial sync (revision 0) always lands as a change. - * @returns the store handle. - */ -export function createGeneralSettingsStore(): EngineStoreHandle { - return defineStore({ - init: (): GeneralSettingsState => ({ - localeActive: '', - localeOptions: [], - localeRevision: -1, - themePreference: 'system', - themeRevision: -1, - }), - actions: { - syncLocale: (d, active: string, options: LocaleOptionRow[], revision: number) => { - if (revision <= d.localeRevision) return - d.localeActive = active - d.localeOptions = options - d.localeRevision = revision - }, - syncTheme: (d, preference: ThemePreferenceId, revision: number) => { - if (revision <= d.themeRevision) return - d.themePreference = preference - d.themeRevision = revision - }, - }, - }) -} diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts deleted file mode 100644 index 94b9bdf674..0000000000 --- a/packages/client/ui-settings-general/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ - -/** Host plugin body — no host-side behavior for the general settings plugin. */ -export function apply(): void {} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts deleted file mode 100644 index a40cc3cc00..0000000000 --- a/packages/client/ui-settings-general/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-general`. - * @module @deepseek-ai/dsh-client-ui-settings-general/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-settings-general-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a section plugin projecting two service change events - * into its own slot store — it emits no cordis events of its own and owns no - * cross-plugin mutable relation; snapshot/store agreement is asserted by this - * package's behavior specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts deleted file mode 100644 index 8654275831..0000000000 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** apply wiring: dictionary registration, declaration-aware section entry, - * snapshot projection into the slot store, locale-driven relabeling, and - * recovery after an HMR collapse of the declaring entry. */ -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' -import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client' -import { GeneralSection } from '../src/client/GeneralSection.tsx' -import type { createGeneralSettingsStore } from '../src/client/store.ts' - -const NS = 'settings.general' - -async function bench() { - const ctx = new Context() - await ctx.plugin(SlotsService).await() - const locale = new LocaleService(ctx) - const theme = new ThemeService(ctx) - ctx.provide('locale', locale) - ctx.provide('theme', theme) - return { ctx, slots: ctx.get('slots') as SlotsService, locale, theme } -} - -/** Stand in for the settings shell: declare the section list slot from root. */ -function declareSection(slots: SlotsService): () => void { - return slots.register( - { name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never, - () => null, - ) -} - -/** Mirror the framework's inject choreography: bake a real instance from the - * declared handle and hand its actions to the entry's inject factory. */ -function faceOf(slots: SlotsService) { - const entry = slots.entries('settings.section')[0]! - const handle = entry.store as ReturnType - const instance = handle.create() - const face = (entry.inject as unknown as (a: typeof instance.actions) => GeneralSectionInjected)(instance.actions) - return { entry, instance, face } -} - -describe('ui-settings-general apply', () => { - it('declares the slot, locale, and theme services', () => { - expect(inject).toEqual(['slots', 'locale', 'theme']) - }) - - it('registers dictionaries and the section entry for declarations before or after apply', async () => { - const before = await bench() - declareSection(before.slots) - await before.ctx.plugin({ inject: [...inject], apply }).await() - const entry = before.slots.entries('settings.section')[0]! - expect(entry.component).toBe(GeneralSection) - expect(entry.options).toMatchObject({ id: 'general', order: 0, label: '通用设置' }) - expect(before.locale.bind(NS)('nav')).toBe('通用设置') - - const after = await bench() - const fiber = after.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(after.slots.entries('settings.section')).toHaveLength(0) - declareSection(after.slots) - await Promise.resolve() - expect(after.slots.entries('settings.section')[0]!.component).toBe(GeneralSection) - // Teardown without a live registration exercises the undefined-disposer arm. - await fiber.dispose() - expect(after.slots.entries('settings.section')).toHaveLength(0) - }) - - it('projects service snapshots into the store and routes face writes back', async () => { - const b = await bench() - declareSection(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - // Events ahead of any inject hit the unbound-actions arm without a store. - b.theme.setTheme('dark') - - const { instance, face } = faceOf(b.slots) - // The inject-time re-sync sealed the init window: both mirrors are current. - expect(instance.getSnapshot().localeActive).toBe('zh') - expect(instance.getSnapshot().localeOptions.map(l => l.id)).toEqual(['zh', 'en']) - expect(instance.getSnapshot().themePreference).toBe('dark') - expect(face.t('nav')).toBe('通用设置') - - face.setLocale('en') - expect(b.locale.getLocale().active).toBe('en') - expect(instance.getSnapshot().localeActive).toBe('en') - expect(face.t('nav')).toBe('General') - - face.setTheme('system') - expect(b.theme.getTheme().preference).toBe('system') - expect(instance.getSnapshot().themePreference).toBe('system') - }) - - it('re-registers with a fresh ledger label when the locale changes', async () => { - const b = await bench() - declareSection(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置') - b.locale.setLocale('en') - const entry = b.slots.entries('settings.section')[0]! - expect(entry.options.label).toBe('General') - expect(entry.component).toBe(GeneralSection) - }) - - it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { - const b = await bench() - const host = declareSection(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - expect(b.slots.entries('settings.section')).toHaveLength(1) - - // Collapse: the declarer dies, the cascade removes our entry while the - // apply closure still holds its (now stale) disposer. - host() - expect(b.slots.entries('settings.section')).toHaveLength(0) - - // A locale change inside the collapsed window must stay quiet. - b.locale.setLocale('en') - expect(b.slots.entries('settings.section')).toHaveLength(0) - - // Redeclaration restores the entry — with the current locale's label. - declareSection(b.slots) - await Promise.resolve() - const entry = b.slots.entries('settings.section')[0]! - expect(entry.component).toBe(GeneralSection) - expect(entry.options.label).toBe('General') - }) - - it('removes the entry and the dictionaries on teardown', async () => { - const b = await bench() - declareSection(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.entries('settings.section')).toHaveLength(1) - await fiber.dispose() - expect(b.slots.entries('settings.section')).toHaveLength(0) - // Dictionary disposal: translation falls back to the bare key. - expect(b.locale.bind(NS)('nav')).toBe('nav') - }) -}) diff --git a/packages/client/ui-settings-general/tests/general-section.spec.tsx b/packages/client/ui-settings-general/tests/general-section.spec.tsx deleted file mode 100644 index e82ed0b1fc..0000000000 --- a/packages/client/ui-settings-general/tests/general-section.spec.tsx +++ /dev/null @@ -1,112 +0,0 @@ -// @vitest-environment jsdom -/** GeneralSection behavior: skeleton rows stay inert, Language menu drives - * setLocale, Appearance cubes follow the preference and drive setTheme. */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { GeneralSection } from '../src/client/GeneralSection.tsx' -import { createGeneralSettingsStore } from '../src/client/store.ts' -import { en } from '../src/client/locales.ts' -import type { GeneralSectionComponentProps } from '../src/client/contract.ts' - -afterEach(cleanup) - -const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] - -/** Empty global standard-kit hooks (the section reads neither). */ -function emptySessions() { - const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) - return bindSnapshotSelector(store) -} -function emptyWorkspaces() { - const store = createSnapshotStore({ - items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) - return bindSnapshotSelector(store) -} - -function mount(init?: { active?: string; preference?: 'light' | 'dark' | 'system' }) { - // Real store instance — the sanctioned zero-machinery path for tests. - const store = createGeneralSettingsStore().create() - store.actions.syncLocale(init?.active ?? 'en', LOCALES, 0) - store.actions.syncTheme(init?.preference ?? 'system', 0) - const setLocale = vi.fn() - const setTheme = vi.fn() - const props: GeneralSectionComponentProps = { - useSessions: emptySessions(), - useWorkspaces: emptyWorkspaces(), - useStore: bindSnapshotSelector(store), - actions: store.actions, - t: (key: string) => en[key] ?? key, - setLocale, - setTheme, - } - render() - return { store, setLocale, setTheme } -} - -const pressed = (name: RegExp): string | null => - screen.getByRole('button', { name }).getAttribute('aria-pressed') - -describe('GeneralSection', () => { - it('renders the four groups with skeleton rows inert', () => { - const b = mount() - // Permission: disabled selector showing the fixed value. - const permission = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement - expect(permission.disabled).toBe(true) - fireEvent.click(permission) - // Tool Call: both mode cubes render as plain text, no buttons. - expect(screen.getByText('Schema mode')).toBeDefined() - expect(screen.getByText('Code mode')).toBeDefined() - expect(screen.queryByRole('button', { name: /Schema mode/ })).toBeNull() - expect(b.setLocale).not.toHaveBeenCalled() - expect(b.setTheme).not.toHaveBeenCalled() - }) - - it('opens the language menu, selects a locale, and closes', () => { - const b = mount({ active: 'en' }) - const trigger = screen.getByRole('button', { name: /English/ }) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(trigger) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - fireEvent.click(screen.getByRole('menuitem', { name: '中文' })) - expect(b.setLocale).toHaveBeenCalledWith('zh') - expect(trigger.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() - }) - - it('closes the language menu on outside pointerdown without selecting', () => { - const b = mount({ active: 'en' }) - const trigger = screen.getByRole('button', { name: /English/ }) - fireEvent.click(trigger) - expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined() - fireEvent.pointerDown(document.body) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() - expect(b.setLocale).not.toHaveBeenCalled() - }) - - it('reflects a store locale change in the trigger label (unknown id falls back to the id)', () => { - const b = mount({ active: 'en' }) - act(() => { b.store.actions.syncLocale('zh', LOCALES, 1) }) - expect(screen.getByRole('button', { name: /中文/ })).toBeDefined() - act(() => { b.store.actions.syncLocale('fr', LOCALES, 2) }) - expect(screen.getByRole('button', { name: /fr/ })).toBeDefined() - }) - - it('marks the appearance cube matching the preference and switches on click', () => { - const b = mount({ preference: 'dark' }) - expect(pressed(/Dark/)).toBe('true') - expect(pressed(/Light/)).toBe('false') - expect(pressed(/System/)).toBe('false') - fireEvent.click(screen.getByRole('button', { name: /Light/ })) - expect(b.setTheme).toHaveBeenCalledWith('light') - // Selection follows the store mirror, not the click echo. - act(() => { b.store.actions.syncTheme('light', 1) }) - expect(pressed(/Light/)).toBe('true') - expect(pressed(/Dark/)).toBe('false') - }) -}) diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts deleted file mode 100644 index 7b0527c0ff..0000000000 --- a/packages/client/ui-settings-general/tests/invariant.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' -import InvariantService from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined() - }) - - it('node-half apply is a no-op host placeholder', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general') - apply() - expect(true).toBe(true) // reaching here without throw is the contract - }) -}) diff --git a/packages/client/ui-settings-general/tests/store.spec.ts b/packages/client/ui-settings-general/tests/store.spec.ts deleted file mode 100644 index b291418471..0000000000 --- a/packages/client/ui-settings-general/tests/store.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** General settings store: snapshot-mirror actions and the revision guard. */ -import { describe, expect, it } from 'vitest' -import { createGeneralSettingsStore } from '../src/client/store.ts' - -const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] - -describe('createGeneralSettingsStore', () => { - it('init shape: empty mirrors with revisions at -1', () => { - const store = createGeneralSettingsStore().create() - expect(store.getSnapshot()).toEqual({ - localeActive: '', - localeOptions: [], - localeRevision: -1, - themePreference: 'system', - themeRevision: -1, - }) - }) - - it('syncLocale mirrors the snapshot and advances the revision', () => { - const store = createGeneralSettingsStore().create() - store.actions.syncLocale('zh', LOCALES, 0) - expect(store.getSnapshot().localeActive).toBe('zh') - expect(store.getSnapshot().localeOptions).toEqual(LOCALES) - expect(store.getSnapshot().localeRevision).toBe(0) - - store.actions.syncLocale('en', LOCALES, 1) - expect(store.getSnapshot().localeActive).toBe('en') - expect(store.getSnapshot().localeRevision).toBe(1) - }) - - it('syncLocale revision guard drops stale and duplicate writes', () => { - const store = createGeneralSettingsStore().create() - store.actions.syncLocale('en', LOCALES, 5) - // Stale (lower) and duplicate (equal) revisions leave the mirror intact. - store.actions.syncLocale('zh', LOCALES, 4) - store.actions.syncLocale('zh', LOCALES, 5) - expect(store.getSnapshot().localeActive).toBe('en') - expect(store.getSnapshot().localeRevision).toBe(5) - }) - - it('syncTheme mirrors the preference and guards its revision independently', () => { - const store = createGeneralSettingsStore().create() - store.actions.syncTheme('dark', 0) - expect(store.getSnapshot().themePreference).toBe('dark') - expect(store.getSnapshot().themeRevision).toBe(0) - - store.actions.syncTheme('light', 2) - expect(store.getSnapshot().themePreference).toBe('light') - - // Stale theme write is dropped; the locale revision axis is untouched. - store.actions.syncTheme('system', 1) - expect(store.getSnapshot().themePreference).toBe('light') - expect(store.getSnapshot().themeRevision).toBe(2) - expect(store.getSnapshot().localeRevision).toBe(-1) - }) -}) diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json deleted file mode 100644 index 9b2cc5b838..0000000000 --- a/packages/client/ui-settings-general/tsconfig.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../ui-slots" - }, - { - "path": "../ui-primitives" - }, - { - "path": "../runtime" - }, - { - "path": "../ui-settings" - }, - { - "path": "../locale" - }, - { - "path": "../ui-theme" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts deleted file mode 100644 index bf67c4f10f..0000000000 --- a/packages/client/ui-settings-general/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings-models/tsdown.config.ts b/packages/client/ui-settings-models/tsdown.config.ts deleted file mode 100644 index 7a2688a097..0000000000 --- a/packages/client/ui-settings-models/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings/src/client/GeneralSection.module.css similarity index 75% rename from packages/client/ui-settings-general/src/client/GeneralSection.module.css rename to packages/client/ui-settings/src/client/GeneralSection.module.css index dffafc419d..5e053a49c5 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings/src/client/GeneralSection.module.css @@ -1,6 +1,7 @@ -/* General section rows (figma 501:29983 'Options'): four groups, 16px - * vertical padding each, hairline separator under all but the last. The - * shell's content column owns the outer horizontal padding. */ +/* General section rows (figma 501:29983 'Options'): stacked groups, 16px + * vertical padding each, hairline separator under all but the last child + * (feature-contributed rows carry their own row chrome and separators; the + * :last-child rule strips the trailing one wherever the column ends). */ .section { display: flex; @@ -8,6 +9,10 @@ width: 100%; } +.section > :last-child { + border-bottom: none; +} + /* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */ .row { display: flex; @@ -26,10 +31,6 @@ border-bottom: 1px solid var(--dsw-alias-border-l2); } -.last { - border-bottom: none; -} - /* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */ .rowText { flex: 1; @@ -79,7 +80,7 @@ flex: none; } -/* Cube rows share an 8px gap; cubes stretch to equal height. */ +/* Tool Call mode cubes share an 8px gap. */ .cubeRow { display: flex; align-items: stretch; @@ -102,27 +103,6 @@ text-align: left; } -/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered - * icon-over-label column, gap 4). */ -.themeCube { - box-sizing: border-box; - width: 276px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - padding: 20px 32px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 16px; - background: transparent; - font: inherit; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-primary); - cursor: pointer; -} - /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 * step has no alias-layer name). */ .selected { diff --git a/packages/client/ui-settings/src/client/GeneralSection.tsx b/packages/client/ui-settings/src/client/GeneralSection.tsx new file mode 100644 index 0000000000..31714a9238 --- /dev/null +++ b/packages/client/ui-settings/src/client/GeneralSection.tsx @@ -0,0 +1,51 @@ +/** + * Shell-owned General section (figma 501:29983 'Options'): Permission and + * Tool Call skeleton rows, then the feature-contributed preference rows from + * the `settings.general.item` slot (locale → Language, ui-theme → + * Appearance). The section column stacks rows; each row draws its own + * internals and separator. + */ +import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { GeneralSectionComponentProps } from './contract/slots.ts' +import css from './GeneralSection.module.css' + +/** + * Render the General section content column. + * @param props - composed slot props (contract/slots.ts). + * @returns the section element tree. + */ +export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) { + return ( +
+ {/* Permission (skeleton): disabled selector pill. */} +
+
+
{t('permission.title')}
+
{t('permission.desc')}
+
+ +
+ + {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} +
+
{t('toolcall.title')}
+
+
+
{t('toolcall.schema.title')}
+
{t('toolcall.schema.desc')}
+
+
+
{t('toolcall.code.title')}
+
{t('toolcall.code.desc')}
+
+
+
+ + {/* Feature-owned preference rows (Language, Appearance, …). */} + {renderSlot('settings.general.item', {})} +
+ ) +} diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 10e4e0ecbe..87db2f8999 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -1,7 +1,11 @@ /** - * Settings shell slot contract: the shell occupies the sidebar-owned - * `sidebar.settings` hole and declares the `settings.section` list slot that - * section plugins (General, Models, …) contribute pages into. + * Settings shell slot contract. The shell occupies the sidebar-owned + * `sidebar.settings` hole, declares the `settings.section` list slot that + * feature plugins contribute top-level pages into, and ships the first + * section itself: General, whose `settings.general.item` list slot receives + * preference rows from the features that own them (locale → Language, + * ui-theme → Appearance). A feature owns its settings surface — adding a + * setting never means editing the shell. */ import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) @@ -19,6 +23,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * re-render trigger). Sections render inside the panel content column. */ 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } + /** + * One preference row inside the General section, contributed by the + * feature plugin that owns the preference (locale → Language, ui-theme → + * Appearance). Options: `id` (row key), `order` (row position). Rows + * draw their own internals (row layout, separators via CSS); the section + * column only stacks them. NOTE: packages/client/locale and ui-theme + * repeat this entry verbatim (reference-cycle avoidance) — declaration + * merging enforces the copies stay identical; edit all three together. + */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } } } } @@ -60,3 +74,21 @@ export type SettingsRootInjected = { */ export type SettingsRootComponentProps = PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.section'> & SettingsRootInjected + +/** + * Injected share of the shell-owned General section: the shell's own + * `settings` namespace translate function for the skeleton rows (Permission, + * Tool Call). Live preference rows arrive through the item slot with their + * own faces. + */ +export type GeneralSectionInjected = { + /** Translate a `settings` dictionary key to the active-locale text. */ + t: (key: string) => string +} + +/** + * Full component props of the shell-owned General section: the section owner + * share, the declared item render share, and the injected face. + */ +export type GeneralSectionComponentProps = + PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 3613476ede..c360a84201 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -1,17 +1,24 @@ /** * Settings shell plugin, browser half. Occupies the sidebar-owned * `sidebar.settings` hole with the trigger row + modal panel, declares the - * `settings.section` list slot, and projects that ledger into the panel - * navigation. Export discipline: packages/client/AGENTS.md. + * `settings.section` list slot, projects that ledger into the panel + * navigation, and ships the first section itself: General, which declares + * the `settings.general.item` slot that feature plugins contribute + * preference rows into. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context/Events merges (ctx.locale, // 'locale/change') into this program. import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { SettingsRootInjected } from './contract/slots.ts' +import type { GeneralSectionInjected, SettingsRootInjected } from './contract/slots.ts' import { SettingsRoot } from './SettingsRoot.tsx' +import { GeneralSection } from './GeneralSection.tsx' +import { en, zh } from './locales.ts' -export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps } from './contract/slots.ts' +export type { + GeneralSectionComponentProps, GeneralSectionInjected, + SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps, +} from './contract/slots.ts' /** * Required services (cordis fiber inject). The target slot is declared by @@ -22,18 +29,20 @@ export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionO export const inject = ['slots', 'locale'] /** - * Register the settings shell into `sidebar.settings` once the declaration is - * on the ledger. + * Register the settings shell into `sidebar.settings` and the shell-owned + * General section into `settings.section`, each once its declaration is on + * the ledger. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { ctx.effect(() => { const disposers = [ - ctx.locale.register('settings', 'zh', { trigger: '设置', title: '设置', close: '关闭' }), - ctx.locale.register('settings', 'en', { trigger: 'Settings', title: 'Settings', close: 'Close' }), + ctx.locale.register('settings', 'zh', zh), + ctx.locale.register('settings', 'en', en), ] return () => { for (const dispose of disposers) dispose() } }, 'ui-settings: shell copy dictionaries') + const injected = (): SettingsRootInjected => ({ translate: (ref) => { const colon = ref.indexOf(':') @@ -73,4 +82,38 @@ export function apply(ctx: ClientContext): void { dispose?.() } }, 'ui-settings: shell registration') + + // The shell's own General section: first page, declares the item slot the + // feature plugins (locale, ui-theme, …) contribute preference rows into. + // Same ledger-judged deferral; label re-registers on locale change. + const generalInjected = (): GeneralSectionInjected => ({ + t: ctx.locale.bind('settings'), + }) + ctx.effect(() => { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined) return + if (ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)) return + dispose = ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: ctx.locale.bind('settings')('general.nav'), + children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, + inject: generalInjected, + }, GeneralSection) + } + const offLocale = ctx.on('locale/change', () => { + dispose?.() + dispose = undefined + tryRegister() + }) + const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) + tryRegister() + return () => { + offLocale() + unsubscribe() + dispose?.() + } + }, 'ui-settings: general section registration') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings/src/client/locales.ts similarity index 64% rename from packages/client/ui-settings-general/src/client/locales.ts rename to packages/client/ui-settings/src/client/locales.ts index ff10ce5c37..6fc3295561 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings/src/client/locales.ts @@ -1,7 +1,9 @@ /** - * `settings.general` namespace dictionaries. Skeleton-row technical copy + * `settings` namespace dictionaries: shell chrome plus the shell-owned + * General section (nav label, skeleton rows). Skeleton-row technical copy * (Read only / Schema mode / Code mode and their descriptions) is shared - * verbatim across locales per the Figma design. + * verbatim across locales per the Figma design. Feature-owned rows + * (Language, Appearance) ship their copy in their own packages. */ import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client' @@ -16,27 +18,23 @@ const SHARED = { /** Simplified Chinese dictionary. */ export const zh: LocaleDict = { ...SHARED, - 'nav': '通用设置', + 'trigger': '设置', + 'title': '设置', + 'close': '关闭', + 'general.nav': '通用设置', 'permission.title': '权限', 'permission.desc': '选择默认权限模式', 'toolcall.title': '工具调用', - 'language.title': '语言', - 'appearance.title': '外观', - 'appearance.light': '浅色', - 'appearance.dark': '深色', - 'appearance.system': '跟随系统', } /** English dictionary. */ export const en: LocaleDict = { ...SHARED, - 'nav': 'General', + 'trigger': 'Settings', + 'title': 'Settings', + 'close': 'Close', + 'general.nav': 'General', 'permission.title': 'Permission', 'permission.desc': 'Choose default permission mode', 'toolcall.title': 'Tool Call', - 'language.title': 'Language', - 'appearance.title': 'Appearance', - 'appearance.light': 'Light', - 'appearance.dark': 'Dark', - 'appearance.system': 'System', } diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index a94406e4ce..95684404a1 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -4,8 +4,9 @@ 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 { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { GeneralSectionInjected, SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' async function bench() { const ctx = new Context() @@ -77,11 +78,14 @@ describe('ui-settings apply', () => { declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const injected = injectedOf(b.slots) - expect(injected.sections()).toEqual([]) + // The shell ships its own General section (order 0) — the ledger is never + // empty once apply settles. + expect(injected.sections()).toEqual([{ id: 'general', order: 0, label: '通用设置' }]) b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) - b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) + b.slots.register({ name: 'settings.section', id: 'a', order: 5 } as never, () => null) expect(injected.sections()).toEqual([ - { id: 'a', order: 0, label: '' }, + { id: 'general', order: 0, label: '通用设置' }, + { id: 'a', order: 5, label: '' }, { id: 'z', order: 20, label: 'Z' }, ]) expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) @@ -118,3 +122,72 @@ describe('ui-settings apply', () => { expect(b.slots.spec('settings.section')).toBeUndefined() }) }) + +describe('ui-settings general section', () => { + it('registers the shell-owned General entry and declares the item slot', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(GeneralSection) + expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' }) + expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + const injected = (entry.inject as () => GeneralSectionInjected)() + expect(injected.t('permission.title')).toBe('权限') + }) + + it('re-registers with fresh label text on locale change', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General') + b.locale.setLocale('zh') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置') + }) + + it('locale change while settings.section is undeclared stays a no-op', async () => { + const b = await bench() + // No sidebar.settings declaration: the shell never registers, so + // settings.section is never declared either. + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')).toHaveLength(0) + b.locale.setLocale('zh') + }) + + it('re-registers after an HMR collapse of the whole chain (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + // Root declarer unload: the cascade removes the shell entry, the + // settings.section declaration, and the General entry below it. + redeclare() + expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + declare(b.slots) + // Two deferral hops: the shell re-registers (re-declaring + // settings.section), then General re-registers into it. + await Promise.resolve() + await Promise.resolve() + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(GeneralSection) + expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + // The recovered registration still rides the locale path. + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General') + b.locale.setLocale('zh') + }) + + it('removes the General entry and its item declaration on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.spec('settings.general.item')).toBeDefined() + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-settings/tests/general-section.spec.tsx b/packages/client/ui-settings/tests/general-section.spec.tsx new file mode 100644 index 0000000000..a944dd8b15 --- /dev/null +++ b/packages/client/ui-settings/tests/general-section.spec.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { GeneralSectionComponentProps } from '../src/client/contract/slots.ts' +import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +function mount() { + const renderSlot = vi.fn( + ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], + ) + const props: GeneralSectionComponentProps = { + t: (key) => en[key] ?? key, + renderSlot, + } + const view = render() + return { view, renderSlot } +} + +describe('GeneralSection', () => { + it('renders the Permission skeleton row with the disabled selector', () => { + mount() + expect(screen.getByText('Permission')).toBeTruthy() + expect(screen.getByText('Choose default permission mode')).toBeTruthy() + const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement + expect(selector.disabled).toBe(true) + }) + + it('renders the Tool Call skeleton cubes with schema pinned selected', () => { + mount() + expect(screen.getByText('Tool Call')).toBeTruthy() + const schema = screen.getByText('Schema mode') + const code = screen.getByText('Code mode') + expect(schema.parentElement!.className).toContain('selected') + expect(code.parentElement!.className).not.toContain('selected') + expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy() + expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy() + }) + + it('renders the feature-contributed item slot after the skeleton rows', () => { + const { renderSlot } = mount() + expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) + expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 4c6ad92cd4..e60289b52d 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", - "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets", + "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row", "version": "0.0.1", "private": true, "type": "module", @@ -24,18 +24,32 @@ "./package.json": "./package.json" }, "dshClient": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale" + ], "platform": "web", "immediately": true }, "license": "BSD-3-Clause", "peerDependencies": { + "@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", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", @@ -48,5 +62,8 @@ "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" + }, + "dependencies": { + "clsx": "^2.0.0" } } diff --git a/packages/client/ui-theme/src/client/AppearanceRow.module.css b/packages/client/ui-theme/src/client/AppearanceRow.module.css new file mode 100644 index 0000000000..9619caa823 --- /dev/null +++ b/packages/client/ui-theme/src/client/AppearanceRow.module.css @@ -0,0 +1,51 @@ +/* Appearance row (figma 'Frame 2117131228': title + cube row, column gap 8, + * pad 16/0, hairline separator; the section column strips it when last). */ + +.group { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.cubeRow { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered + * icon-over-label column, gap 4). */ +.themeCube { + box-sizing: border-box; + width: 276px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 20px 32px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: transparent; + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 + * step has no alias-layer name). */ +.selected { + background: var(--dsw-alias-bg-module-platform); + border-color: var(--dsw-static-neutral-bluish-400); +} diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx new file mode 100644 index 0000000000..b4aad1725d --- /dev/null +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -0,0 +1,63 @@ +/** + * Appearance preference row registered into the General section item slot + * (figma 501:30012 'Frame 2117131228'): title + three preference cubes. + * Registered by this package — the theme feature owns its own settings + * surface. Selection follows the persisted preference, never the resolved + * active theme. + */ +import clsx from 'clsx' +import { + IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { ThemePreference } from './index.ts' +import type {} from './settings-contract.ts' +import type { createAppearanceRowStore } from './settings-store.ts' +import css from './AppearanceRow.module.css' + +/** Injected business face: namespace-bound translate + the preference write. */ +export interface AppearanceRowInjected { + /** Translate a `settings.theme` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the theme preference. */ + setTheme: (id: ThemePreference) => void +} + +/** Full component props: runtime share + store share + injected face. */ +export type AppearanceRowComponentProps = + PropsRuntime<'settings.general.item'> & PropsStore> & AppearanceRowInjected + +/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ +const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ + { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, + { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, + { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, +] + +/** + * Render the Appearance row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function AppearanceRow({ t, setTheme, useStore }: AppearanceRowComponentProps) { + const preference = useStore(s => s.preference) + return ( +
+
{t('appearance.title')}
+
+ {CUBES.map(({ id, labelKey, Icon }) => ( + + ))} +
+
+ ) +} diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 5c7ca3fe12..ac79913062 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -2,9 +2,24 @@ * Browser theme registry over the `--dsw-*` token stylesheets. The service * owns the 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 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. */ 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' +// 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' + +export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' +export type { AppearanceRowState } from './settings-store.ts' + +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = 'settings.theme' /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record @@ -200,13 +215,75 @@ function persistPreference(preference: ThemePreference): void { } } -/** Required services (none; the loader passes the export surface as an object plugin). */ -export const inject: string[] = [] +/** Required services: slots + locale (the feature registers its own settings row with localized copy). */ +export const inject = ['slots', 'locale'] /** - * Client plugin body: provide the theme service. + * Client plugin body: provide the theme service and register the + * feature-owned Appearance preference row into the General section's item + * slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export function apply(ctx: Context): void { - ctx.provide('theme', new ThemeService(ctx)) +export function apply(ctx: ClientContext): void { + const theme = new ThemeService(ctx) + ctx.provide('theme', theme) + + ctx.effect(() => { + const disposers = [ + ctx.locale.register(SETTINGS_NS, 'zh', { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', + }), + ctx.locale.register(SETTINGS_NS, 'en', { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-theme: settings row dictionaries') + + const store = createAppearanceRowStore() + let bound: BoundActions | undefined + const sync = (snapshot: ThemeSnapshot): void => { + bound?.sync(snapshot.preference, snapshot.revision) + } + ctx.on('theme/change', sync) + const injected = (actions: BoundActions): AppearanceRowInjected => { + bound = actions + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(theme.getTheme()) + return { + t: ctx.locale.bind(SETTINGS_NS), + setTheme: (id) => { theme.setTheme(id) }, + } + } + // Declaration-aware registration; the LEDGER is the has-registered judge + // (not a local flag): after an HMR collapse re-declares the slot, the + // cascade already removed our entry, and a stale disposer must not block + // the re-registration. + ctx.effect(() => { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (ctx.slots.spec('settings.general.item') === undefined) return + if (ctx.slots.entries('settings.general.item').some(e => e.component === AppearanceRow)) return + dispose = ctx.slots.register({ + name: 'settings.general.item', + id: 'appearance', + order: 10, + store, + inject: injected, + }, AppearanceRow) + } + const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) + tryRegister() + return () => { + unsubscribe() + dispose?.() + } + }, 'ui-theme: appearance settings row registration') } diff --git a/packages/client/ui-theme/src/client/settings-contract.ts b/packages/client/ui-theme/src/client/settings-contract.ts new file mode 100644 index 0000000000..4354ca8728 --- /dev/null +++ b/packages/client/ui-theme/src/client/settings-contract.ts @@ -0,0 +1,17 @@ +/** + * Settings-surface slot merge consumed by this package's Appearance row. The + * AUTHORITATIVE home for 'settings.general.item' is the ui-settings contract + * (declaring is claiming: the shell's General entry declares the slot); this + * file repeats the entry verbatim because the settings shell sits above the + * feature layer, so importing its types from here would invert the layering. + * TypeScript declaration merging rejects diverging duplicates, so every + * program that sees both copies enforces identity. + */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** One preference row inside the General section (duplicate-identical merge; authority: ui-settings contract). */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } } + } +} + +export {} diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts new file mode 100644 index 0000000000..256b04a299 --- /dev/null +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -0,0 +1,37 @@ +/** + * Appearance row slot store: a mirror of the theme service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import type { ThemePreference } from './index.ts' + +/** Store state mirrored from the theme snapshot. */ +export interface AppearanceRowState { + /** Persisted preference (selection state reads this, never the resolved active theme). */ + preference: ThemePreference + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number +} + +/** Declared action shape giving the exported factory a stable return type. */ +type AppearanceRowActions = { + sync: (draft: AppearanceRowState, preference: ThemePreference, revision: number) => void +} + +/** + * Declares the Appearance row state and write surface. + * @returns the store handle. + */ +export function createAppearanceRowStore(): EngineStoreHandle { + return defineStore({ + init: (): AppearanceRowState => ({ preference: 'system', revision: -1 }), + actions: { + sync: (d, preference: ThemePreference, revision: number) => { + if (revision <= d.revision) return + d.preference = preference + d.revision = revision + }, + }, + }) +} diff --git a/packages/client/ui-theme/src/css-modules.d.ts b/packages/client/ui-theme/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-theme/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 6f4e867c4d..640599ea43 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -4,6 +4,8 @@ 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 { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' describe('invariant companion', () => { @@ -18,9 +20,13 @@ describe('invariant companion', () => { expect(true).toBe(true) // reaching here without throw is the contract }) - it('client apply provides ctx.theme with no service prerequisites', async () => { - expect(inject).toEqual([]) + 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']) const ctx = new Context() + new SlotsService(ctx) + await ctx.plugin({ inject: ['slots'], apply: localeApply }).await() await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) }) diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 51f9171643..7d5cc6f235 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,6 +8,18 @@ "src" ], "references": [ + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, { "path": "../../../vendor/cordis" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2410252eb0..40fee92c16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,18 +140,15 @@ importers: '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../packages/client/ui-layout + '@deepseek-ai/dsh-client-ui-models': + specifier: workspace:^ + version: link:../../packages/client/ui-models '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../packages/client/ui-question '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../packages/client/ui-settings - '@deepseek-ai/dsh-client-ui-settings-general': - specifier: workspace:^ - version: link:../../packages/client/ui-settings-general - '@deepseek-ai/dsh-client-ui-settings-models': - specifier: workspace:^ - version: link:../../packages/client/ui-settings-models '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../packages/client/ui-sidebar @@ -750,13 +747,32 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) packages/client/locale: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/modules: devDependencies: @@ -865,6 +881,33 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-models: + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-primitives: dependencies: clsx: @@ -979,70 +1022,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-general: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 - devDependencies: - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-ui-theme': - specifier: workspace:^ - version: link:../ui-theme - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - react: - specifier: ^18.2.0 - version: 18.3.1 - - packages/client/ui-settings-models: - devDependencies: - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-sidebar: dependencies: clsx: @@ -1087,13 +1066,35 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/client/ui-theme: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-trajectory: devDependencies: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index cb8ebb6a48..91554c5c5c 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -60,8 +60,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-workspace': { 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/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, - 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, - 'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 81e0fc989c..49a502571e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -116,8 +116,7 @@ "@deepseek-ai/dsh-client-ui-workspace": ["./packages/client/ui-workspace/src"], "@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"], "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], - "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], - "@deepseek-ai/dsh-client-ui-settings-models": ["./packages/client/ui-settings-models/src"], + "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], "@deepseek-ai/dsh-*": [ diff --git a/tsconfig.client.json b/tsconfig.client.json index abe80b8b12..55ff2d9a8c 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -39,8 +39,7 @@ { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, { "path": "./packages/client/ui-settings" }, - { "path": "./packages/client/ui-settings-general" }, - { "path": "./packages/client/ui-settings-models" }, + { "path": "./packages/client/ui-models" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } From 6ce5628bb140a40f5540eac2cbcc821ed8822cf5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:51:48 +0800 Subject: [PATCH 30/45] docs: tighten PR guidance --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 63957cca16..ad79a04b10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- **Keep PRs coherent; use merge commits.** Split independent features or design decisions when combining them obscures ownership, intent, or verification. Never squash, rebase, or rewrite pushed branches; fix the introducing PR, then merge down its stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). - **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the taxonomy remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From f2cf29bd04596c450b6c05ceba1df490d2ba7537 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:52:48 +0800 Subject: [PATCH 31/45] docs: deduplicate paired code-block checks --- ...-20-core-data-structures-catalog.i18n.yaml | 4 +- ...2026-06-20-core-data-structures-catalog.md | 2 +- ...6-06-20-core-data-structures-catalog.zh.md | 2 +- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 +- ...6-07-02-bilingual-docs-and-pairing-gate.md | 2 +- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 2 + docs/i18n/README.zh.md | 2 + scripts/doc-typecheck.ts | 13 +- scripts/paired-markdown-derivatives.spec.ts | 66 ++ scripts/paired-markdown-derivatives.ts | 63 ++ .../request-response.expected.json | 12 +- scripts/type-equiv.manifest.json | 945 +----------------- scripts/verify-type-equiv.ts | 15 +- 18 files changed, 175 insertions(+), 971 deletions(-) create mode 100644 scripts/paired-markdown-derivatives.spec.ts create mode 100644 scripts/paired-markdown-derivatives.ts diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index f59c523dce..0149872c3c 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-core-data-structures-catalog.md: d7e9d3d9b14fe723e3396c8167fe714b7613e2b5 -2026-06-20-core-data-structures-catalog.zh.md: 8d2f16a46216cba8df0539be0abd2f1ad840eec3 +2026-06-20-core-data-structures-catalog.md: ef100f96b06c454cfd1ec092cc7fd23e712bdf7a +2026-06-20-core-data-structures-catalog.zh.md: 4ace2b8c8a6b08e7721c1df8003ccfbdb128daf1 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md index d7e9d3d9b1..ef100f96b0 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -33,7 +33,7 @@ The durability requirement was specific: the doc shows the **literal** current t - Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A concise ` ```ts public-api ` fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches. - A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. -- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. +- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence** between each primary type-equiv block and one manifest entry, so a block can never be silently unchecked and an entry can never rot. A paired `.zh.md` block reuses the unsuffixed sibling's entry only when the complete tracked fence sequence matches in order, kind, and byte-exact body; otherwise the gate checks it independently, finds no manifest entry, and fails. - Wired into `doc-sync`, so relevant documentation changes run it locally and CI runs it with the other documentation checks. ### Maintenance is the author's job, with a gate backstop diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index 8d2f16a462..4ace2b8c8a 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -33,7 +33,7 @@ Status: implemented - 完整的类型声明及其 JSDoc 会逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。简洁的 ` ```ts public-api ` 围栏承载与源码等价的类环境投影,用于实现体不应进入目录的类。`doc-typecheck` 会识别并跳过这两种围栏(裸声明无法独立编译),并且**将它们排除在 opt-out 比例之外**——它们是单独受检的类别,而不是未经检查的草图。 - 新增的 `scripts/verify-type-equiv.ts` 通过 TypeScript 解析器提取每个块,并断言其声明结构和每条 JSDoc 注释都与所声明的符号匹配,只忽略格式空白和非 JSDoc 注释。普通块保留完整声明。`public-api` 投影保留类的公共字段、构造函数、访问器和方法及其原始 JSDoc,同时移除实现体以及私有或受保护成员。之所以选择它而非编译式 `_Check` 断言,是因为目录所保留的是源码名称与文档一致性,而不是可赋值性。 -- 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本强制执行 **1:1 对应**:每个 type-equiv 块恰好有一条 manifest 条目,反之亦然;因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。 +- 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本在每个主 type-equiv 块与一条 manifest 条目之间强制执行 **1:1 对应**,因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。只有当配对 `.zh.md` 块的完整受跟踪围栏序列在顺序、类型和按字节精确的正文上均与无后缀兄弟文件匹配时,才会复用后者的条目;否则门禁会独立检查该块,发现没有 manifest 条目后失败。 - 接入 `doc-sync`,因此相关文档变更会在本地运行它,CI 也会与其他文档检查一起运行它。 ### 维护是作者的职责,门禁作为兜底 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 c3430eb57d..7b523ab8b4 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 -2026-07-02-bilingual-docs-and-pairing-gate.md: 08e149ccc2342695d6dae4f1845896def6bf4388 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 91f25f33c20337ea688257829552795085e0a8c0 +2026-07-02-bilingual-docs-and-pairing-gate.md: ece3ccc183893a85335a36eb9b00cb42d32a1a37 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: dc8c648cc4ce9dd7739383c620ee2b1c3794f2ee 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 08e149ccc2..ece3ccc183 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 @@ -12,7 +12,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **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). - **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. -- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot. +- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot. 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. - **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. 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. 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 91f25f33c2..dc8c648cc4 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 @@ -12,7 +12,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `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)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 -- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中的源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照。 +- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中的源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。 - **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。发布到文档站的配对使用 `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。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 9d2573305e..db97881ae8 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 -development.md: f706d54764bbf79d1f13ccb4c412e7b5717b1edb -development.zh.md: 15f5ae0c514ac412c302a99cb0a662acce510c44 +development.md: c46d84740e6f0a1f67158f39f9ea421cb57165d4 +development.zh.md: 9e13ab258e1db5406f84ece61959a995110578ae diff --git a/docs/development.md b/docs/development.md index f706d54764..c46d84740e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -166,7 +166,7 @@ The [core data structures](core-data-structures/core.md) docs paste source-equiv { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both fence kinds (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. ## Architecture context diff --git a/docs/development.zh.md b/docs/development.zh.md index 15f5ae0c51..9e13ab258e 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -166,7 +166,7 @@ pnpm run demo:acp { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种围栏(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 ## 架构上下文 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 02f6151e29..b0d5a550df 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 -README.md: a60572b0691702c949b44b82a7b1d732a888ed93 -README.zh.md: 04ae032233dfa01c145b5d6bdbe7353a366e11f1 +README.md: 053453bc622f58083a5e0e2992f1b8a820e3f3f9 +README.zh.md: 7f6242e8ff2a3ec69e2b84402dab447297047991 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index a60572b069..053453bc62 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -28,6 +28,8 @@ This repo's documentation is read by people and agents both inside and outside t 3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. 4. 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. +Source-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. + `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. 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. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 04ae032233..7f6242e8ff 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -28,6 +28,8 @@ 3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。 4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。 +面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。 + `pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok),是翻译批次的工作清单。它从不失败;它只报告。 这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 9a9e007758..69b9d67411 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -1,7 +1,8 @@ /** * Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as * opt-outs; generated catalog fragments and source-equivalence blocks are skipped here because their - * owning gates verify them. A build-coordinated mode consumes existing declarations without emit. + * owning gates verify them. Byte-identical `.zh.md` copies reuse their unsuffixed sibling's check. A + * build-coordinated mode consumes existing declarations without emit. */ import { execFileSync } from 'node:child_process' @@ -10,6 +11,7 @@ import { join, relative, resolve } from 'node:path' import ts from 'typescript' import { builtDeclarationPath } from './doc-typecheck-paths.ts' import { extractFences } from './md-fences.ts' +import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts' const root = resolve(import.meta.dirname, '..') @@ -206,7 +208,12 @@ for (const pattern of markdownGlobs) { } files.sort() -const all = files.flatMap(extractBlocks) +const extracted = files.flatMap(extractBlocks) +const { primary: all, derivatives } = partitionPairedMarkdownDerivatives( + extracted, + block => block.file, + block => `${block.kind}\0${block.code}`, +) const checked = all.filter(b => b.kind === 'check') const ignored = all.filter(b => b.kind === 'ignore') // Only compile-eligible fences belong in the opt-out ratio; every other skipped @@ -233,7 +240,7 @@ if (compilationError !== undefined) { const ratio = ignored.length / ratioDenominator const skipped = all.length - ratioDenominator -console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`) +console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere), ${derivatives.length} paired derivative(s).`) // Guard against the escape hatch becoming the norm. if (ratioDenominator >= 4 && ratio > 0.5) { console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`) diff --git a/scripts/paired-markdown-derivatives.spec.ts b/scripts/paired-markdown-derivatives.spec.ts new file mode 100644 index 0000000000..f5882e007f --- /dev/null +++ b/scripts/paired-markdown-derivatives.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts' + +interface Block { + doc: string + kind: string + code: string +} + +const partition = (blocks: Block[]) => partitionPairedMarkdownDerivatives( + blocks, + block => block.doc, + block => `${block.kind}\0${block.code}`, +) + +describe('partitionPairedMarkdownDerivatives', () => { + it('treats a complete byte-identical Chinese sequence as derivative', () => { + const english = [ + { doc: 'docs/example.md', kind: 'ts', code: 'const one = 1' }, + { doc: 'docs/example.md', kind: 'type-equiv', code: 'interface Example {}' }, + ] + const chinese = english.map(block => ({ ...block, doc: 'docs/example.zh.md' })) + const unrelated = { doc: 'docs/other.md', kind: 'ts', code: 'const other = 2' } + + expect(partition([...english, ...chinese, unrelated])).toEqual({ + primary: [...english, unrelated], + derivatives: chinese, + }) + }) + + it('keeps reordered, changed, partial, and orphan Chinese sequences primary', () => { + const sequence = (doc: string) => [ + { doc, kind: 'ts', code: 'const one = 1' }, + { doc, kind: 'ts', code: 'const two = 2' }, + ] + const english = sequence('docs/example.md') + const changed = english.map((block, index) => ({ + ...block, + doc: 'docs/example.zh.md', + code: index === 0 ? 'const one = 0' : block.code, + })) + const reorderedEnglish = sequence('docs/reordered.md') + const reordered = [...reorderedEnglish].reverse().map(block => ({ ...block, doc: 'docs/reordered.zh.md' })) + const partialEnglish = sequence('docs/partial.md') + const partial = [{ ...partialEnglish[0]!, doc: 'docs/partial.zh.md' }] + const orphan = [{ doc: 'docs/orphan.zh.md', kind: 'ts', code: 'const orphan = true' }] + const blocks = [ + ...english, + ...changed, + ...reorderedEnglish, + ...reordered, + ...partialEnglish, + ...partial, + ...orphan, + ] + + expect(partition(blocks)).toEqual({ primary: blocks, derivatives: [] }) + }) + + it('requires the fence kind to match as well as the body', () => { + const english = { doc: 'docs/example.md', kind: 'type-equiv', code: 'interface Example {}' } + const chinese = { ...english, doc: 'docs/example.zh.md', kind: 'public-api' } + + expect(partition([english, chinese])).toEqual({ primary: [english, chinese], derivatives: [] }) + }) +}) diff --git a/scripts/paired-markdown-derivatives.ts b/scripts/paired-markdown-derivatives.ts new file mode 100644 index 0000000000..ead0c06923 --- /dev/null +++ b/scripts/paired-markdown-derivatives.ts @@ -0,0 +1,63 @@ +/** + * Separate byte-identical Chinese Markdown code blocks from the primary checks + * performed on their unsuffixed English siblings. The bilingual pairing gate + * owns cross-language identity; source-oriented gates consume one copy. + */ + +/** The result of separating canonical blocks from paired Chinese derivatives. */ +export interface MarkdownDerivativePartition { + /** Blocks that still require the caller's owning check. */ + primary: T[] + /** Chinese blocks covered by the byte-identical unsuffixed sequence. */ + derivatives: T[] +} + +/** Return the unsuffixed sibling of a Chinese Markdown path. */ +function unsuffixedSibling(doc: string): string | null { + return doc.endsWith('.zh.md') ? `${doc.slice(0, -'.zh.md'.length)}.md` : null +} + +/** + * Partition complete byte-identical `.zh.md` block sequences from primary + * blocks. A partial or reordered match stays primary so the caller fails + * closed; the translation-pairing gate reports the cross-language mismatch. + * + * @param blocks - Blocks in repository scan order. + * @param docOf - Repository-relative Markdown path owning a block. + * @param fingerprintOf - Block kind/info string plus byte-exact body. + * @returns Primary blocks and paired Chinese derivatives, preserving order. + */ +export function partitionPairedMarkdownDerivatives( + blocks: readonly T[], + docOf: (block: T) => string, + fingerprintOf: (block: T) => string, +): MarkdownDerivativePartition { + const byDoc = new Map() + for (const block of blocks) { + const doc = docOf(block) + const group = byDoc.get(doc) + if (group) group.push(block) + else byDoc.set(doc, [block]) + } + + const derivativeDocs = new Set() + for (const [doc, candidates] of byDoc) { + const sibling = unsuffixedSibling(doc) + if (sibling === null) continue + const originals = byDoc.get(sibling) + if (originals === undefined || originals.length !== candidates.length) continue + if (candidates.every((candidate, index) => { + const original = originals[index] + return original !== undefined && fingerprintOf(candidate) === fingerprintOf(original) + })) { + derivativeDocs.add(doc) + } + } + + const primary: T[] = [] + const derivatives: T[] = [] + for (const block of blocks) { + (derivativeDocs.has(docOf(block)) ? derivatives : primary).push(block) + } + return { primary, derivatives } +} diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index bc5355b931..36a701f376 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,19 +16,19 @@ }, { "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, and ACP automation 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 public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both fence kinds (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "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, and ACP automation 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 public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "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 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种围栏(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "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 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "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 `), 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`, and every document whose class appears in `requiredClasses`, in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. The classes are `non-readme` and `readme`; class matching is case-insensitive on the basename, so `missions/readme.md` is a README.\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 passes the gate; it must not pass review.\n\n## Scope, exclusions, and rollout\n\n**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\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\n**Enforcement frontier**: `requiredClasses` closes a whole document class after its back-catalog has been translated. `non-readme` is closed: every current or future in-scope non-README document must merge bilingual. README coverage remains an explicit-file rollout until `readme` joins the closed set. The manifest's `required` list retains already-admitted files, and a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after `requiredSince` must merge with its pair regardless of class. `--list` reports any unclosed-class backlog while every existing pair remains governed by the full contract.\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, switchers, 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 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 `), 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`, and every document whose class appears in `requiredClasses`, in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. The classes are `non-readme` and `readme`; class matching is case-insensitive on the basename, so `missions/readme.md` is a README.\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\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 — 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 passes the gate; it must not pass review.\n\n## Scope, exclusions, and rollout\n\n**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\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\n**Enforcement frontier**: `requiredClasses` closes a whole document class after its back-catalog has been translated. `non-readme` is closed: every current or future in-scope non-README document must merge bilingual. README coverage remains an explicit-file rollout until `readme` joins the closed set. The manifest's `required` list retains already-admitted files, and a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after `requiredSince` must merge with its pair regardless of class. `--list` reports any unclosed-class backlog while every existing pair remains governed by the full contract.\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, switchers, 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(智能体)阅读,因此 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 `),所以失去同步的配对是「把被改的一侧与其上次确认状态做 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` 列出的每个文件,以及所属文档类别出现在 `requiredClasses` 中的每篇文档,都有完整配对。类别分为 `non-readme` 和 `readme`;判断类别时,basename 不区分大小写,因此 `missions/readme.md` 也属于 README。\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(`packages/**`)在后续批次加入范围。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\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\n**执行红线**:某个文档类别的存量文档全部翻译完成后,`requiredClasses` 会将整个类别纳入强制范围。`non-readme` 已纳入强制范围:当前及今后所有纳入范围的非 README 文档,合并时都必须配齐双语文件。README 覆盖仍按显式文件逐步推进,直到 `readme` 加入这一强制范围。manifest 的 `required` 列表保留已纳入的文件;以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note)只要日期不早于 `requiredSince`,就无论所属类别都必须与对侧文件一同合并。`--list` 会报告尚未纳入强制范围的类别中的任何 backlog(待翻清单),而每个已存在的配对仍受完整契约约束。\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(智能体)阅读,因此 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 `),所以失去同步的配对是「把被改的一侧与其上次确认状态做 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` 列出的每个文件,以及所属文档类别出现在 `requiredClasses` 中的每篇文档,都有完整配对。类别分为 `non-readme` 和 `readme`;判断类别时,basename 不区分大小写,因此 `missions/readme.md` 也属于 README。\n2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。\n4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\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(`packages/**`)在后续批次加入范围。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\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\n**执行红线**:某个文档类别的存量文档全部翻译完成后,`requiredClasses` 会将整个类别纳入强制范围。`non-readme` 已纳入强制范围:当前及今后所有纳入范围的非 README 文档,合并时都必须配齐双语文件。README 覆盖仍按显式文件逐步推进,直到 `readme` 加入这一强制范围。manifest 的 `required` 列表保留已纳入的文件;以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note)只要日期不早于 `requiredSince`,就无论所属类别都必须与对侧文件一同合并。`--list` 会报告尚未纳入强制范围的类别中的任何 backlog(待翻清单),而每个已存在的配对仍受完整契约约束。\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" }, { "role": "user", @@ -40,11 +40,11 @@ }, { "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: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot.\n- **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. 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** — 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 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 docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- Rollout remains incremental until a document class is complete: explicit `required` entries and the date cutoff prevent regression during review batches, while a closed class makes every current and future member mandatory. The non-README class is closed, so only the README class can still appear as backlog.\n- The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), 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 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: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot. 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- **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. 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** — 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 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 docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- Rollout remains incremental until a document class is complete: explicit `required` entries and the date cutoff prevent regression during review batches, while a closed class makes every current and future member mandatory. The non-README class is closed, so only the README class can still appear as backlog.\n- The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), 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本仓库的 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))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中的源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照。\n- **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。发布到文档站的配对使用 `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- **英文为正典源、指纹放在译文内**:本 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`;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- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 在文档类别全部完成之前,推进仍然是渐进的:显式 `required` 条目与日期分界可在评审批次期间防止回退,已纳入强制范围的类别则将其当前及今后的每个成员都列为必选项。非 README 类别已纳入强制范围,因此只有 README 类别仍可能出现 backlog(待翻清单)。\n- 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" + "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))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中的源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。发布到文档站的配对使用 `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- **英文为正典源、指纹放在译文内**:本 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`;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- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 在文档类别全部完成之前,推进仍然是渐进的:显式 `required` 条目与日期分界可在评审批次期间防止回退,已纳入强制范围的类别则将其当前及今后的每个成员都列为必选项。非 README 类别已纳入强制范围,因此只有 README 类别仍可能出现 backlog(待翻清单)。\n- 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" }, { "role": "user", diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0310306bfa..b546e26f15 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,5 +1,5 @@ { - "comment": "Maps each ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence: every source-equivalence block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a source-equivalence block; remove it when you remove the block.", + "comment": "Maps each primary ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Paired `.zh.md` blocks are byte-identical derivatives checked through their unsuffixed sibling and have no duplicate entry. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence between primary blocks and entries. Add an entry when you add a primary source-equivalence block; remove it when you remove the block.", "entries": [ { "doc": "docs/core-data-structures/core.md", @@ -1248,949 +1248,6 @@ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "Branded", - "source": "packages/util/brand/src/index.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ContentBlockMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "AssistantProvenance", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "Message", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "MessageSourceMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "FinishReasonMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "LlmProviderInfo", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "LlmModelInfo", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "LlmModelContext", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "GenerateOptions", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ToolSchema", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "LlmCallConfig", - "source": "packages/llm/llm/src/call-config.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "SessionEvent", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "SendOptions", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "AgentCancelCause", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "InjectOptions", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ResolvedAgentInput", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "AgentMessageId", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "AgentMessage", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "CancelOptions", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "Agent", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "HookContext", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "PromptDecision", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ContinuationDecision", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "RequestError", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "RequestErrorDecision", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ContinuationStop", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "SessionStartSource", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/scope.zh.md", - "symbol": "ScopeKey", - "source": "packages/core/scope/src/index.ts" - }, - { - "doc": "docs/core-data-structures/scope.zh.md", - "symbol": "Scoped", - "source": "packages/core/scope/src/index.ts" - }, - { - "doc": "docs/core-data-structures/scope.zh.md", - "symbol": "Scope", - "source": "packages/core/scope/src/index.ts" - }, - { - "doc": "docs/core-data-structures/scope.zh.md", - "symbol": "ScopeLayer", - "source": "packages/core/scope/src/store.ts" - }, - { - "doc": "docs/core-data-structures/system-prompt.zh.md", - "symbol": "AssembleContext", - "source": "packages/core/system-prompt/src/index.ts" - }, - { - "doc": "docs/core-data-structures/system-prompt.zh.md", - "symbol": "PromptSection", - "source": "packages/core/system-prompt/src/index.ts" - }, - { - "doc": "docs/core-data-structures/system-prompt.zh.md", - "symbol": "ToolProviderResult", - "source": "packages/core/system-prompt/src/index.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "StreamChunk", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "LlmFailure", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "TokenUsage", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "ContentBlockMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "AppIdentity", - "source": "packages/llm/llm/src/attribution.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "BlockAssembler", - "source": "packages/llm/llm/src/assembler.ts", - "projection": "public-api" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "LlmAdapter", - "source": "packages/llm/llm/src/index.ts", - "projection": "public-api" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "PromptMessageData", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SessionEventMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "OutOfBandSessionEventMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "EpochHeader", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "TodoItem", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SessionEvent", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "TurnTriggerMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "TurnEndReasonMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceEventType", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceOp", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceIntent", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SessionSurface", - "source": "packages/core/session/src/surface.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceFoldReplacement", - "source": "packages/core/session/src/surface.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceFoldResult", - "source": "packages/core/session/src/surface.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "Session", - "source": "packages/core/session/src/index.ts", - "projection": "public-api" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "SessionHeader", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "CreateSessionOptions", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "SessionLocation", - "source": "packages/session-persistence/session-persistence/src/index.ts" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "SessionPersistenceRevision", - "source": "packages/session-persistence/session-persistence/src/revision.ts" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "SessionPersistenceSnapshot", - "source": "packages/session-persistence/session-persistence/src/index.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSurface", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionRecord", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionLogSnapshot", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSurfaceSnapshot", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionTitleObservation", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionTitleObservationResult", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventRecord", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionResultFilter", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventResultFilter", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSearchDocument", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSearchCursor", - "source": "packages/session-query/session-query/src/cursor.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSearchRequest", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSearchRequest", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSearchPage", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSearchPage", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSearchHit", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSearchHit", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionLineageNode", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionLineageTrace", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionQueryErrorCode", - "source": "packages/session-query/session-query/src/config.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventReadRequest", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventWindow", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventTraceRequest", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventTrace", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventTraceObservation", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolOutputDefinition", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolDefinition", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ValueSchemaSpec", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ParameterPropertySpec", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ParameterSchemaSpec", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "InferValue", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "InferArgs", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionToken", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionInput", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecution", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolDispatchExecution", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionMode", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolRunContext", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolGuard", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolRestriction", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolFailure", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionSuccess", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionFailure", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionResult", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "PreToolDecision", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "PostToolDecision", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "JsonSchemaScalar", - "source": "packages/core/tools/src/json-schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "JsonSchemaType", - "source": "packages/core/tools/src/json-schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "JsonSchemaNode", - "source": "packages/core/tools/src/json-schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ObjectJsonSchema", - "source": "packages/core/tools/src/json-schema.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionOption", - "source": "packages/ui/user-interaction/src/types.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionItem", - "source": "packages/ui/user-interaction/src/types.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionRequest", - "source": "packages/ui/user-interaction/src/index.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionAnswerItem", - "source": "packages/ui/user-interaction/src/types.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionAnswer", - "source": "packages/ui/user-interaction/src/types.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "UserInteractionProvider", - "source": "packages/ui/user-interaction/src/index.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "UserInteractionError", - "source": "packages/ui/user-interaction/src/index.ts" - }, - { - "doc": "docs/core-data-structures/approval.zh.md", - "symbol": "ApprovalRequestId", - "source": "packages/ui/user-approval/src/types.ts" - }, - { - "doc": "docs/core-data-structures/approval.zh.md", - "symbol": "ApprovalOutcome", - "source": "packages/ui/user-approval/src/types.ts" - }, - { - "doc": "docs/core-data-structures/approval.zh.md", - "symbol": "ApprovalPolicy", - "source": "packages/ui/user-approval/src/index.ts" - }, - { - "doc": "docs/core-data-structures/approval.zh.md", - "symbol": "ApprovalRequest", - "source": "packages/ui/user-approval/src/index.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "DshEnvironmentKey", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "DshEnvironment", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashExecRequest", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashExecSpec", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashRunResult", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashSandboxInfo", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "CollectedOutput", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashProcess", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashProcessRead", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxMode", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "ConfinedSandboxMode", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxExecutionPolicy", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxEnforcement", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxPolicy", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxPolicyRequest", - "source": "packages/sandbox/sandbox-policy/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "ConfinedArgv", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeJsonValue", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeRunRequest", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeRunResult", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeBindingNamespace", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeBindingErrorClass", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeBindingFunction", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeRunFailure", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsTarget", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsTargetKey", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsVersion", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsInfo", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsPathInfo", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsDirEntry", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsWriteIntent", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsWriteOutcome", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsEditRequest", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsEditOutcome", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsErrorCode", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsPolicyExec", - "source": "packages/fs/fs-policy/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FileReadOutcome", - "source": "packages/fs/tool-fs/src/read-render.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillSource", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillResourceBase", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillSummary", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillCandidate", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillDefinition", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillRegistration", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillLookupOptions", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillProvider", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "Config", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/compaction.zh.md", - "symbol": "CompactionResult", - "source": "packages/compact/compact/src/types.ts" - }, - { - "doc": "docs/core-data-structures/compaction.zh.md", - "symbol": "CompactionTrigger", - "source": "packages/compact/compact/src/index.ts" - }, - { - "doc": "docs/core-data-structures/compaction.zh.md", - "symbol": "PrunedEntry", - "source": "packages/compact/compact-tool-result-prune/src/types.ts" - }, - { - "doc": "docs/core-data-structures/compaction.zh.md", - "symbol": "PruneResult", - "source": "packages/compact/compact-tool-result-prune/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentCapabilities", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentStartRequest", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentResult", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentStopReasonMap", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentRun", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentProvider", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebSearchRequest", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebSearchResult", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebSearchSource", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebFetchRequest", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebFetchResult", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebFetchBody", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/workflow.zh.md", - "symbol": "WorkflowStartRequest", - "source": "packages/workflow/workflow/src/types.ts" - }, - { - "doc": "docs/core-data-structures/workflow.zh.md", - "symbol": "WorkflowMeta", - "source": "packages/workflow/workflow/src/types.ts" - }, - { - "doc": "docs/core-data-structures/workflow.zh.md", - "symbol": "WorkflowResult", - "source": "packages/workflow/workflow/src/types.ts" - }, - { - "doc": "docs/core-data-structures/workflow.zh.md", - "symbol": "WorkflowRun", - "source": "packages/workflow/workflow/src/types.ts" } ] } diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 7a3d2305d7..58cfea238d 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -4,12 +4,14 @@ * declaration; `public-api` entries preserve a class's body-stripped public * declaration. Blocks and entries have a one-to-one relationship; comparison * ignores whitespace and non-JSDoc comments but preserves declaration - * structure and every original JSDoc comment. + * structure and every original JSDoc comment. Byte-identical `.zh.md` blocks + * reuse the manifest-backed check of their unsuffixed sibling. */ import { globSync, readFileSync, existsSync } from 'node:fs' import { resolve, sep } from 'node:path' import ts from 'typescript' +import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts' const root = resolve(import.meta.dirname, '..') @@ -223,7 +225,12 @@ const docSet = new Set() for (const pattern of MARKDOWN_GLOBS) { for (const match of globSync(pattern, { cwd: root })) docSet.add(match.split(sep).join('/')) } -const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks) +const extractedBlocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks) +const { primary: blocks, derivatives } = partitionPairedMarkdownDerivatives( + extractedBlocks, + block => block.doc, + block => `${block.projection ?? 'declaration'}\0${block.code}`, +) const errors: string[] = [] // A manifest entry naming a doc that does not exist (or is outside the scanned @@ -299,11 +306,11 @@ for (const e of entries) { } if (errors.length === 0) { - console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest).`) + console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest); ${derivatives.length} paired derivative(s).`) process.exit(0) } console.error('verify-type-equiv: type-equiv verification failed:') for (const e of errors) console.error(` ${e}`) -console.error(`\n(checked ${blocks.length} block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s); manifest at scripts/type-equiv.manifest.json)`) +console.error(`\n(checked ${blocks.length} primary block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s), ${derivatives.length} paired derivative(s); manifest at scripts/type-equiv.manifest.json)`) process.exit(1) From a3ee5dd8a0c2ba5ddc850b485eb07f88cb11fff1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:54:29 +0800 Subject: [PATCH 32/45] docs(gui): regenerate graphs and sync the note's English pair Generated docs follow the ui-models rename and the removed ui-settings-general package; the English note side picks up the feature-owner self-registration doctrine. --- ...-25-client-settings-locale-theme.i18n.yaml | 4 +- ...2026-07-25-client-settings-locale-theme.md | 2 +- docs/config-catalog.md | 3 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 41 +++++----- .../client/locale/tests/language-row.spec.tsx | 82 +++++++++++++++++++ .../locale/tests/settings-store.spec.ts | 30 +++++++ .../client/ui-settings/tests/apply.spec.ts | 6 +- .../tests/general-section.spec.tsx | 4 + .../ui-theme/tests/appearance-row.spec.tsx | 75 +++++++++++++++++ .../ui-theme/tests/settings-store.spec.ts | 28 +++++++ 11 files changed, 250 insertions(+), 29 deletions(-) create mode 100644 packages/client/locale/tests/language-row.spec.tsx create mode 100644 packages/client/locale/tests/settings-store.spec.ts create mode 100644 packages/client/ui-theme/tests/appearance-row.spec.tsx create mode 100644 packages/client/ui-theme/tests/settings-store.spec.ts diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index 8583642403..39d7377c54 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 -2026-07-25-client-settings-locale-theme.md: e1245a9e1fac82fb0feb84af7a59945b17fa1daf -2026-07-25-client-settings-locale-theme.zh.md: 195b2e5ffa3556dd1b8bf2dd6ec8ae84115fbdb2 +2026-07-25-client-settings-locale-theme.md: 658e6bd3c2da39a98e476c60f10f3f51ad82e5ea +2026-07-25-client-settings-locale-theme.zh.md: dfe93a0ca1f53380c653886c73fe0c32b08d8443 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index 0dfcb4ea90..658e6bd3c2 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -119,4 +119,4 @@ Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the on ## Risks -The apply order of slot declarations and contributions is not fixed, so every new section must keep declaration-aware registration and idempotence guards. Service events may fire before a section's first render, so both the General store's init and the controller attach must align to the current snapshot from the getters. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. +The apply order of slot declarations and contributions is not fixed, so every section/item registrant must keep declaration-aware registration and judge presence by the ledger, not by a local disposer. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ede904e1a5..1940811d35 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2022,10 +2022,9 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2c1e85fb3c..53cd56f4cf 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -63,8 +63,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/dispatch` | - | [`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/ui/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), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | -| `locale/change` | `locale` (`emit`) | `ui-settings-general`, `ui-settings-models` | +| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings` | | `slots/changed` | `runtime` (`emit`) | - | -| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-settings-general` | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index c47bd92d83..4903e432cd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -141,11 +141,10 @@ flowchart TD pkg_client_runtime["client-runtime"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_layout["client-ui-layout"] + pkg_client_ui_models["client-ui-models"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] - pkg_client_ui_settings_general["client-ui-settings-general"] - pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_theme["client-ui-theme"] @@ -228,13 +227,11 @@ flowchart TD pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_loader_smoke --> pkg_invariants - pkg_client_locale --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_question --> pkg_invariants pkg_client_ui_slots --> pkg_invariants - pkg_client_ui_theme --> pkg_invariants pkg_client_ui_trajectory --> pkg_invariants pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants @@ -250,25 +247,21 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_invariants pkg_client_ui_settings --> pkg_client_runtime pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_settings_models --> pkg_client_runtime - pkg_client_ui_settings_models --> pkg_client_ui_slots - pkg_client_ui_settings_models --> pkg_invariants pkg_client_ui_sidebar --> pkg_client_runtime pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots @@ -303,6 +296,11 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -355,6 +353,10 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -813,13 +815,11 @@ flowchart TD | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | -| [`client-locale`](../packages/client/locale) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | @@ -831,11 +831,10 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`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-ui-conversation`](../packages/client/ui-conversation) | `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-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`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-settings-general`](../packages/client/ui-settings-general) | `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-settings-models`](../packages/client/ui-settings-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `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-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -848,6 +847,7 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`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) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -865,6 +865,7 @@ flowchart TD | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx new file mode 100644 index 0000000000..af33038970 --- /dev/null +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** LanguageRow behavior: selector pill shows the active locale, the menu + * opens/closes, and selection drives setLocale. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { LanguageRow } from '../src/client/LanguageRow.tsx' +import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx' +import { createLanguageRowStore } from '../src/client/settings-store.ts' + +afterEach(cleanup) + +const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +/** Empty global standard-kit hooks (the row reads neither). */ +function emptySessions() { + const store = createSnapshotStore( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(active = 'en') { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createLanguageRowStore().create() + store.actions.sync(active, OPTIONS, 0) + const setLocale = vi.fn() + const props: LanguageRowComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => key === 'language.title' ? 'Language' : key, + setLocale, + } + render() + return { store, setLocale } +} + +describe('LanguageRow', () => { + it('shows the title and the active locale label on the selector pill', () => { + mount('en') + expect(screen.getByText('Language')).toBeDefined() + const trigger = screen.getByRole('button', { name: /English/ }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) + + it('opens the menu, selects a locale, and closes', () => { + const b = mount('en') + const trigger = screen.getByRole('button', { name: /English/ }) + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('menuitem', { name: '中文' })) + expect(b.setLocale).toHaveBeenCalledWith('zh') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + }) + + it('closes on outside pointerdown without selecting', () => { + const b = mount('en') + fireEvent.click(screen.getByRole('button', { name: /English/ })) + expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + expect(b.setLocale).not.toHaveBeenCalled() + }) + + it('follows store changes; an unknown active id falls back to the id itself', () => { + const b = mount('en') + act(() => { b.store.actions.sync('zh', OPTIONS, 1) }) + expect(screen.getByRole('button', { name: /中文/ })).toBeDefined() + act(() => { b.store.actions.sync('fr', OPTIONS, 2) }) + expect(screen.getByRole('button', { name: /fr/ })).toBeDefined() + }) +}) diff --git a/packages/client/locale/tests/settings-store.spec.ts b/packages/client/locale/tests/settings-store.spec.ts new file mode 100644 index 0000000000..90e9487a96 --- /dev/null +++ b/packages/client/locale/tests/settings-store.spec.ts @@ -0,0 +1,30 @@ +/** Language row store: snapshot-mirror action and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createLanguageRowStore } from '../src/client/settings-store.ts' + +const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +describe('createLanguageRowStore', () => { + it('init shape: empty mirror with revision at -1', () => { + const store = createLanguageRowStore().create() + expect(store.getSnapshot()).toEqual({ active: '', options: [], revision: -1 }) + }) + + it('sync mirrors the snapshot and advances the revision', () => { + const store = createLanguageRowStore().create() + store.actions.sync('zh', OPTIONS, 0) + expect(store.getSnapshot()).toEqual({ active: 'zh', options: OPTIONS, revision: 0 }) + store.actions.sync('en', OPTIONS, 1) + expect(store.getSnapshot().active).toBe('en') + expect(store.getSnapshot().revision).toBe(1) + }) + + it('revision guard drops stale and duplicate writes', () => { + const store = createLanguageRowStore().create() + store.actions.sync('en', OPTIONS, 5) + store.actions.sync('zh', OPTIONS, 4) + store.actions.sync('zh', OPTIONS, 5) + expect(store.getSnapshot().active).toBe('en') + expect(store.getSnapshot().revision).toBe(5) + }) +}) diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index 95684404a1..2346012553 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -82,10 +82,12 @@ describe('ui-settings apply', () => { // empty once apply settles. expect(injected.sections()).toEqual([{ id: 'general', order: 0, label: '通用设置' }]) b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) - b.slots.register({ name: 'settings.section', id: 'a', order: 5 } as never, () => null) + // No order and no label: both projection defaults apply (order 0 ties + // keep registration sequence, so 'a' lands after the General entry). + b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) expect(injected.sections()).toEqual([ { id: 'general', order: 0, label: '通用设置' }, - { id: 'a', order: 5, label: '' }, + { id: 'a', order: 0, label: '' }, { id: 'z', order: 20, label: 'Z' }, ]) expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) diff --git a/packages/client/ui-settings/tests/general-section.spec.tsx b/packages/client/ui-settings/tests/general-section.spec.tsx index a944dd8b15..ce09aabf93 100644 --- a/packages/client/ui-settings/tests/general-section.spec.tsx +++ b/packages/client/ui-settings/tests/general-section.spec.tsx @@ -11,7 +11,11 @@ function mount() { const renderSlot = vi.fn( ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], ) + // Global standard kit stubs: the section consumes neither hook. + const unusedHook = (() => { throw new Error('unused by GeneralSection') }) as never const props: GeneralSectionComponentProps = { + useSessions: unusedHook, + useWorkspaces: unusedHook, t: (key) => en[key] ?? key, renderSlot, } diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx new file mode 100644 index 0000000000..4782b674a8 --- /dev/null +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +/** AppearanceRow behavior: three cubes, selection follows the persisted + * preference, clicks drive setTheme. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { AppearanceRow } from '../src/client/AppearanceRow.tsx' +import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx' +import { createAppearanceRowStore } from '../src/client/settings-store.ts' +import type { ThemePreference } from '../src/client/index.ts' + +afterEach(cleanup) + +const COPY: Record = { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} + +/** Empty global standard-kit hooks (the row reads neither). */ +function emptySessions() { + const store = createSnapshotStore( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(preference: ThemePreference = 'system') { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createAppearanceRowStore().create() + store.actions.sync(preference, 0) + const setTheme = vi.fn() + const props: AppearanceRowComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => COPY[key] ?? key, + setTheme, + } + render() + return { store, setTheme } +} + +const pressed = (name: RegExp): string | null => + screen.getByRole('button', { name }).getAttribute('aria-pressed') + +describe('AppearanceRow', () => { + it('renders the title and three cubes with the preference cube selected', () => { + mount('dark') + expect(screen.getByText('Appearance')).toBeDefined() + expect(pressed(/Dark/)).toBe('true') + expect(pressed(/Light/)).toBe('false') + expect(pressed(/System/)).toBe('false') + }) + + it('click drives setTheme; selection follows the store mirror, not the click echo', () => { + const b = mount('dark') + fireEvent.click(screen.getByRole('button', { name: /Light/ })) + expect(b.setTheme).toHaveBeenCalledWith('light') + // No store write yet: selection is unchanged. + expect(pressed(/Dark/)).toBe('true') + act(() => { b.store.actions.sync('light', 1) }) + expect(pressed(/Light/)).toBe('true') + expect(pressed(/Dark/)).toBe('false') + }) +}) diff --git a/packages/client/ui-theme/tests/settings-store.spec.ts b/packages/client/ui-theme/tests/settings-store.spec.ts new file mode 100644 index 0000000000..540d0f5b3b --- /dev/null +++ b/packages/client/ui-theme/tests/settings-store.spec.ts @@ -0,0 +1,28 @@ +/** Appearance row store: snapshot-mirror action and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createAppearanceRowStore } from '../src/client/settings-store.ts' + +describe('createAppearanceRowStore', () => { + it('init shape: system preference with revision at -1', () => { + const store = createAppearanceRowStore().create() + expect(store.getSnapshot()).toEqual({ preference: 'system', revision: -1 }) + }) + + it('sync mirrors the preference and advances the revision', () => { + const store = createAppearanceRowStore().create() + store.actions.sync('dark', 0) + expect(store.getSnapshot()).toEqual({ preference: 'dark', revision: 0 }) + store.actions.sync('light', 2) + expect(store.getSnapshot().preference).toBe('light') + expect(store.getSnapshot().revision).toBe(2) + }) + + it('revision guard drops stale and duplicate writes', () => { + const store = createAppearanceRowStore().create() + store.actions.sync('dark', 3) + store.actions.sync('system', 2) + store.actions.sync('system', 3) + expect(store.getSnapshot().preference).toBe('dark') + expect(store.getSnapshot().revision).toBe(3) + }) +}) From 0c9f310008160bf8db955b496afa0941e53d3c70 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:57:54 +0800 Subject: [PATCH 33/45] test(gui): apply-level suites for the feature-owned settings rows Locale and ui-theme apply coverage on a real Context + SlotCore: dictionary assembly, declaration-aware registration both ways, inject-time getter re-sync, service write-back through the event flow, HMR collapse recovery, and teardown reclamation. Four settings-surface packages sit at full per-file coverage. --- packages/client/locale/tests/apply.spec.ts | 116 ++++++++++++++++++ packages/client/ui-theme/tests/apply.spec.ts | 117 +++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 packages/client/locale/tests/apply.spec.ts create mode 100644 packages/client/ui-theme/tests/apply.spec.ts diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts new file mode 100644 index 0000000000..25dbdfe239 --- /dev/null +++ b/packages/client/locale/tests/apply.spec.ts @@ -0,0 +1,116 @@ +/** locale apply wiring: service + dictionaries provision, declaration-aware + * Language row registration, snapshot projection into the row store, and + * recovery after an HMR collapse of the declaring entry. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/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 { LanguageRow } from '../src/client/LanguageRow.tsx' +import type { createLanguageRowStore } from '../src/client/settings-store.ts' + +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 } +} + +/** Stand in for the settings shell: declare the General item slot from root. */ +function declareItems(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries(SLOT).find(e => e.component === LanguageRow)! + const handle = entry.store as ReturnType + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => LanguageRowInjected)(instance.actions) + return { entry, instance, face } +} + +describe('locale apply', () => { + it('declares the slot service', () => { + expect(inject).toEqual(['slots']) + }) + + it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { + const before = await bench() + declareItems(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const locale = before.ctx.get('locale') as LocaleService + // Base dictionaries are registered: the (ns, locale) seats are occupied. + expect(() => locale.register('common', 'zh', {})).toThrow('already has locale') + expect(() => locale.register('common', 'en', {})).toThrow('already has locale') + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') + const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)! + expect(entry.options).toMatchObject({ id: 'language', order: 0 }) + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries(SLOT)).toHaveLength(0) + declareItems(after.slots) + await Promise.resolve() + expect(after.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true) + }) + + it('projects service snapshots into the row store and routes face writes back', async () => { + const b = await bench() + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const locale = b.ctx.get('locale') as LocaleService + // An event ahead of any inject hits the unbound-actions arm. + locale.setLocale('en') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: the mirror is current. + expect(instance.getSnapshot().active).toBe('en') + expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en']) + expect(face.t('language.title')).toBe('Language') + + face.setLocale('zh') + expect(locale.getLocale().active).toBe('zh') + expect(instance.getSnapshot().active).toBe('zh') + expect(face.t('language.title')).toBe('语言') + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + declareItems(b.slots) + await Promise.resolve() + expect(b.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true) + }) + + it('teardown removes the row; teardown without a declaration is quiet', async () => { + const b = await bench() + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + // Never-declared bench: the effect disposer's dispose arm stays undefined. + const quiet = await bench() + const f2 = quiet.ctx.plugin({ inject: [...inject], apply }) + await f2.await() + await f2.dispose() + expect(quiet.slots.entries(SLOT)).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts new file mode 100644 index 0000000000..9852b93e66 --- /dev/null +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -0,0 +1,117 @@ +/** ui-theme apply wiring: service provision, settings dictionaries riding the + * 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 { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +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 { AppearanceRow } from '../src/client/AppearanceRow.tsx' +import type { createAppearanceRowStore } from '../src/client/settings-store.ts' + +const SLOT = 'settings.general.item' + +async function bench() { + 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 } +} + +/** Stand in for the settings shell: declare the General item slot from root. */ +function declareItems(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries(SLOT).find(e => e.component === AppearanceRow)! + const handle = entry.store as ReturnType + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => AppearanceRowInjected)(instance.actions) + return { entry, instance, face } +} + +describe('ui-theme apply', () => { + it('declares the slot and locale services', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => { + const before = await bench() + declareItems(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('外观') + before.locale.setLocale('en') + expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('Appearance') + const entry = before.slots.entries(SLOT).find(e => e.component === AppearanceRow)! + expect(entry.options).toMatchObject({ id: 'appearance', order: 10 }) + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries(SLOT)).toHaveLength(0) + declareItems(after.slots) + await Promise.resolve() + expect(after.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + }) + + it('projects service snapshots into the row store and routes face writes back', async () => { + const b = await bench() + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + // An event ahead of any inject hits the unbound-actions arm. + theme.setTheme('dark') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: the mirror is current. + expect(instance.getSnapshot().preference).toBe('dark') + expect(face.t('appearance.dark')).toBe('深色') + + face.setTheme('system') + expect(theme.getTheme().preference).toBe('system') + expect(instance.getSnapshot().preference).toBe('system') + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + declareItems(b.slots) + await Promise.resolve() + expect(b.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + }) + + it('teardown removes the row and the dictionaries; teardown without a declaration is quiet', async () => { + const b = await bench() + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries(SLOT)).toHaveLength(0) + // Dictionary disposal: translation falls back to the bare key. + expect(b.locale.bind(SETTINGS_NS)('appearance.title')).toBe('appearance.title') + + // Never-declared bench: the effect disposer's dispose arm stays undefined. + const quiet = await bench() + const f2 = quiet.ctx.plugin({ inject: [...inject], apply }) + await f2.await() + await f2.dispose() + expect(quiet.slots.entries(SLOT)).toHaveLength(0) + }) +}) From c5d323e8e599e63dc0c3eeff4f8c46209e9fc953 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:22:35 +0800 Subject: [PATCH 34/45] fix(gui): follow-ups for the feature-owned settings surfaces The ui-layout apply bench provides a real LocaleService before the theme plugin boots (ui-theme now injects slots/locale to register its Appearance row); drop locale's unused clsx dependency. --- packages/client/locale/package.json | 3 --- packages/client/ui-layout/package.json | 1 + packages/client/ui-layout/tests/apply.spec.ts | 4 ++++ packages/client/ui-layout/tsconfig.json | 3 +++ pnpm-lock.yaml | 7 +++---- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index d53d2e4269..4d75496dc8 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -58,8 +58,5 @@ "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" - }, - "dependencies": { - "clsx": "^2.0.0" } } diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 17459ee5d2..b816533474 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -43,6 +43,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 29ad4b3058..910b5a3132 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -9,6 +9,7 @@ import { Context } from 'cordis' 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 { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' @@ -17,6 +18,9 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' 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. + ctx.provide('locale', new LocaleService(ctx)) 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-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 4401e8e4db..731eec8012 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../ui-slots" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40fee92c16..612cea101f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -747,10 +747,6 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) packages/client/locale: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -859,6 +855,9 @@ importers: packages/client/ui-layout: devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From 78e57ab53e9ac175f9c6321ced5fe35a7517163e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:28:39 +0800 Subject: [PATCH 35/45] docs: record PR label taxonomy --- ...07-25-semantic-pr-label-taxonomy.i18n.yaml | 6 ++ .../2026-07-25-semantic-pr-label-taxonomy.md | 60 +++++++++++++++++++ ...026-07-25-semantic-pr-label-taxonomy.zh.md | 60 +++++++++++++++++++ AGENTS.md | 2 +- 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md create mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml new file mode 100644 index 0000000000..9be3fb4b13 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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 +2026-07-25-semantic-pr-label-taxonomy.md: cf25691e55d100cc2cc12246b17f5edbba35feea +2026-07-25-semantic-pr-label-taxonomy.zh.md: 2978bdc80a660f67f84821e473dc229e13b2bc4d diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md new file mode 100644 index 0000000000..cf25691e55 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -0,0 +1,60 @@ +# Agent Note: Semantic pull request label taxonomy + +Status: implemented + +English | [中文](2026-07-25-semantic-pr-label-taxonomy.zh.md) + +## Problem + +Pull requests need two different signals: what kind of change they make and which repository domains they affect. A flat or broadly named label set conflates those questions, hides work in distinct areas such as `session` and `llm`, and gives reviewers and automation weak inputs. + +The repository also gains new domains over time. Treating today's area labels as a closed set would force future work into inaccurate labels or a generic catch-all. + +## Decision + +Every open or merged pull request carries exactly one kind and every materially affected area. Closed pull requests that were never merged are outside the maintained historical corpus. Other operational labels may coexist, but they do not satisfy either dimension. + +### Kinds + +| Kind | Meaning | +|---|---| +| `feature` | Adds or intentionally changes behavior. | +| `bug-fix` | Corrects incorrect behavior. | +| `doc` | Changes documentation only. | +| `testing` | Changes tests or testing infrastructure without changing product behavior. | +| `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. | + +The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change. + +Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request may carry several areas when it changes several domains. + +### Current areas + +The 43 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. + +| Group | Areas | +|---|---| +| Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | +| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | +| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Interfaces | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | + +### Extensibility + +The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together. + +The kind set stays narrow because kinds are mutually exclusive. A new kind requires a distinct change intent that cannot be represented by the current five; it is not a substitute for an area. + +## Alternatives considered + +- **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered. +- **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy. +- **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need. +- **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts. + +## Consequences + +- Reviewers and automation receive one stable intent signal plus a complete semantic scope. +- Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change. +- Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md new file mode 100644 index 0000000000..2978bdc80a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -0,0 +1,60 @@ +# Agent Note: 语义化 PR 标签分类体系 + +Status: implemented + +[English](2026-07-25-semantic-pr-label-taxonomy.md) | 中文 + +## 问题 + +PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更,以及会影响仓库中的哪些领域。一套扁平或命名宽泛的标签会混淆这两个问题,掩盖 `session`、`llm` 等不同领域的工作,也让评审人和自动化流程得到的输入缺乏有效信息。 + +仓库还会随时间发展出新的领域。如果把当前的领域标签视为封闭集合,未来的工作就只能归入不准确的标签或通用兜底标签。 + +## 决策 + +每项开放或已合并的 PR 都带有恰好一个类型标签,以及所有受到实质影响的领域标签。未合并即关闭的 PR 不属于持续维护的历史记录集合。其他管理用途的标签可以并存,但都不能满足这两个维度中的任一个。 + +### 类型 + +| 类型 | 含义 | +|---|---| +| `feature` | 新增行为或有意改变行为。 | +| `bug-fix` | 修正错误行为。 | +| `doc` | 仅修改文档。 | +| `testing` | 修改测试或测试基础设施,但不改变产品行为。 | +| `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | + +类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。 + +领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。如果一项 PR 修改了多个领域,就可以带有多个领域标签。 + +### 当前领域 + +当前的 43 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 + +| 分组 | 领域 | +|---|---| +| agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | +| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | +| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 接口 | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | + +### 可扩展性 + +领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。 + +类型集合保持精简,因为各类型互斥。新增类型的前提是存在一种当前五类无法表达的独立变更意图;类型不能用来替代领域。 + +## 曾考虑的替代方案 + +- **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。 +- **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。 +- **一个宽泛的 `core` 领域,或从包(package)结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。 + +## 后果 + +- 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。 +- 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。 +- 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。 diff --git a/AGENTS.md b/AGENTS.md index ad79a04b10..a8f811229a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent; use merge commits.** Split independent features or design decisions when combining them obscures ownership, intent, or verification. Never squash, rebase, or rewrite pushed branches; fix the introducing PR, then merge down its stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the taxonomy remains extensible for recurring distinctions. +- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 37bfac749b36fda839cb66f8681658d0ecc691de Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:29:11 +0800 Subject: [PATCH 36/45] docs: enforce bilingual README coverage --- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 +- ...6-07-02-bilingual-docs-and-pairing-gate.md | 4 +- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 4 +- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 4 +- docs/i18n/README.zh.md | 4 +- docs/i18n/style-samples.md | 4 +- .../request-response.expected.json | 8 +-- scripts/translation-pairing.manifest.json | 3 +- scripts/translation-pairing.spec.ts | 40 +++++++++++++ scripts/translation-pairing.ts | 57 +++++++++++++++++++ scripts/verify-translation-pairing.ts | 18 +++--- 12 files changed, 126 insertions(+), 28 deletions(-) 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 7b523ab8b4..bdda20cd5e 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 -2026-07-02-bilingual-docs-and-pairing-gate.md: ece3ccc183893a85335a36eb9b00cb42d32a1a37 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: dc8c648cc4ce9dd7739383c620ee2b1c3794f2ee +2026-07-02-bilingual-docs-and-pairing-gate.md: 45a587586b1387d7c351f9c268bd038fcd549ed5 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 4875e48b43f2324ae117bb7aec1bf81dce3bc2bb 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 ece3ccc183..45a587586b 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 @@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **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). - **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. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot. 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. -- **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. 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. +- **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. Both classes are closed. 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. @@ -40,5 +40,5 @@ Paired sibling files with locale suffixes are the dominant Chinese big-tech conv - 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. - 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. - Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list. -- Rollout remains incremental until a document class is complete: explicit `required` entries and the date cutoff prevent regression during review batches, while a closed class makes every current and future member mandatory. The non-README class is closed, so only the README class can still appear as backlog. +- Explicit `required` entries and the date cutoff preserve the reviewed rollout history, while the two closed classes make every current and future in-scope document mandatory. No document class can grow a new backlog. - The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism. 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 dc8c648cc4..4875e48b43 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 @@ -13,7 +13,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `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)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中的源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。 -- **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 +- **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。这两个类别均已纳入强制范围。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。 @@ -40,5 +40,5 @@ Status: implemented - 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。 - 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。 - 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。 -- 在文档类别全部完成之前,推进仍然是渐进的:显式 `required` 条目与日期分界可在评审批次期间防止回退,已纳入强制范围的类别则将其当前及今后的每个成员都列为必选项。非 README 类别已纳入强制范围,因此只有 README 类别仍可能出现 backlog(待翻清单)。 +- 显式 `required` 条目与日期分界保留已经评审的推进历史,而两个已纳入强制范围的类别会将当前及今后所有范围内的文档列为必选项。任何文档类别都不能新增 backlog(待翻清单)。 - 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index b0d5a550df..51e3ffbca8 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 -README.md: 053453bc622f58083a5e0e2992f1b8a820e3f3f9 -README.zh.md: 7f6242e8ff2a3ec69e2b84402dab447297047991 +README.md: 25c4698b2efacbb0cb1dd5b8f27ad94be051c558 +README.zh.md: e5faefff97d4ef8de9bf05613f257ede06c5e4a7 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 053453bc62..25c4698b2e 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -38,7 +38,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co ## Scope, exclusions, and rollout -**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch. +**Scope**: every non-vendor README, plus every 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 are discovery exclusions, not source documentation. **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): @@ -47,7 +47,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. -**Enforcement frontier**: `requiredClasses` closes a whole document class after its back-catalog has been translated. `non-readme` is closed: every current or future in-scope non-README document must merge bilingual. README coverage remains an explicit-file rollout until `readme` joins the closed set. The manifest's `required` list retains already-admitted files, and a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after `requiredSince` must merge with its pair regardless of class. `--list` reports any unclosed-class backlog while every existing pair remains governed by the full contract. +**Enforcement frontier**: `requiredClasses` closes a whole document class after its back-catalog has been translated. Both `non-readme` and `readme` are closed: every current or future in-scope document must merge bilingual. The manifest's `required` list retains already-admitted files, and a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after `requiredSince` must merge with its pair regardless of class. `--list` reports any unclosed-class backlog while every existing pair remains governed by the full contract. ## Division of labor diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 7f6242e8ff..e5faefff97 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -38,7 +38,7 @@ ## 范围、排除与推进 -**范围**:根 `README.md`,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部内容。包(package)README(`packages/**`)在后续批次加入范围。 +**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录和被忽略的构建产物目录只在发现阶段排除,并非源文档。 **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): @@ -47,7 +47,7 @@ - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 -**执行红线**:某个文档类别的存量文档全部翻译完成后,`requiredClasses` 会将整个类别纳入强制范围。`non-readme` 已纳入强制范围:当前及今后所有纳入范围的非 README 文档,合并时都必须配齐双语文件。README 覆盖仍按显式文件逐步推进,直到 `readme` 加入这一强制范围。manifest 的 `required` 列表保留已纳入的文件;以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note)只要日期不早于 `requiredSince`,就无论所属类别都必须与对侧文件一同合并。`--list` 会报告尚未纳入强制范围的类别中的任何 backlog(待翻清单),而每个已存在的配对仍受完整契约约束。 +**执行红线**:某个文档类别的存量文档全部翻译完成后,`requiredClasses` 会将整个类别纳入强制范围。`non-readme` 与 `readme` 均已纳入强制范围:当前及今后所有纳入范围的文档,合并时都必须配齐双语文件。manifest 的 `required` 列表保留已纳入的文件;以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note)只要日期不早于 `requiredSince`,就无论所属类别都必须与对侧文件一同合并。`--list` 会报告尚未纳入强制范围的类别中的任何 backlog(待翻清单),而每个已存在的配对仍受完整契约约束。 ## 分工 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index c62ae259d0..53d8851325 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -70,9 +70,9 @@ ## ⑦ 推进策略(长段拆分示范) -> **Enforcement frontier**: a document class enters the manifest's `requiredClasses` set only after its back-catalog has been translated and reviewed. The `non-readme` class is closed, so every current or future in-scope non-README document must merge bilingual; README coverage remains an explicit-file rollout until that class is ready to close. […] Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so close a class only when translation review can sustain it. +> **Enforcement frontier**: a document class enters the manifest's `requiredClasses` set only after its back-catalog has been translated and reviewed. The `non-readme` and `readme` classes are closed, so every current or future in-scope document must merge bilingual. […] Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so close a class only when translation review can sustain it. -**执行红线**:只有在某个文档类别的存量文档全部完成翻译和评审后,该类别才会进入 manifest(元数据清单)的 `requiredClasses` 集合。`non-readme` 类别已纳入强制范围,因此当前及今后所有纳入范围的非 README 文档,合入时都必须配齐双语文件;README 覆盖仍按显式文件逐步推进,直到该类别具备整体纳入强制范围的条件。(……)一旦文档完成配对,后续修改任一侧都必须同步更新另一侧。因此,只有在翻译评审能力足以持续支撑时,才应将整个类别纳入强制范围。 +**执行红线**:只有在某个文档类别的存量文档全部完成翻译和评审后,该类别才会进入 manifest(元数据清单)的 `requiredClasses` 集合。`non-readme` 与 `readme` 类别均已纳入强制范围,因此当前及今后所有纳入范围的文档,合入时都必须配齐双语文件。(……)一旦文档完成配对,后续修改任一侧都必须同步更新另一侧。因此,只有在翻译评审能力足以持续支撑时,才应将整个类别纳入强制范围。 ## 从样例提炼的要点 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 36a701f376..2314b67221 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,11 +24,11 @@ }, { "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 `), 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`, and every document whose class appears in `requiredClasses`, in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. The classes are `non-readme` and `readme`; class matching is case-insensitive on the basename, so `missions/readme.md` is a README.\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\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 — 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 passes the gate; it must not pass review.\n\n## Scope, exclusions, and rollout\n\n**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\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\n**Enforcement frontier**: `requiredClasses` closes a whole document class after its back-catalog has been translated. `non-readme` is closed: every current or future in-scope non-README document must merge bilingual. README coverage remains an explicit-file rollout until `readme` joins the closed set. The manifest's `required` list retains already-admitted files, and a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after `requiredSince` must merge with its pair regardless of class. `--list` reports any unclosed-class backlog while every existing pair remains governed by the full contract.\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, switchers, 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 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 `), 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`, and every document whose class appears in `requiredClasses`, in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. The classes are `non-readme` and `readme`; class matching is case-insensitive on the basename, so `missions/readme.md` is a README.\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\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 — 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 passes the gate; it must not pass review.\n\n## Scope, exclusions, and rollout\n\n**Scope**: every non-vendor README, plus every 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 are discovery exclusions, not source documentation.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\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\n**Enforcement frontier**: `requiredClasses` closes a whole document class after its back-catalog has been translated. Both `non-readme` and `readme` are closed: every current or future in-scope document must merge bilingual. The manifest's `required` list retains already-admitted files, and a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after `requiredSince` must merge with its pair regardless of class. `--list` reports any unclosed-class backlog while every existing pair remains governed by the full contract.\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, switchers, 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(智能体)阅读,因此 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 `),所以失去同步的配对是「把被改的一侧与其上次确认状态做 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` 列出的每个文件,以及所属文档类别出现在 `requiredClasses` 中的每篇文档,都有完整配对。类别分为 `non-readme` 和 `readme`;判断类别时,basename 不区分大小写,因此 `missions/readme.md` 也属于 README。\n2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。\n4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\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(`packages/**`)在后续批次加入范围。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\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\n**执行红线**:某个文档类别的存量文档全部翻译完成后,`requiredClasses` 会将整个类别纳入强制范围。`non-readme` 已纳入强制范围:当前及今后所有纳入范围的非 README 文档,合并时都必须配齐双语文件。README 覆盖仍按显式文件逐步推进,直到 `readme` 加入这一强制范围。manifest 的 `required` 列表保留已纳入的文件;以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note)只要日期不早于 `requiredSince`,就无论所属类别都必须与对侧文件一同合并。`--list` 会报告尚未纳入强制范围的类别中的任何 backlog(待翻清单),而每个已存在的配对仍受完整契约约束。\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(智能体)阅读,因此 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 `),所以失去同步的配对是「把被改的一侧与其上次确认状态做 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` 列出的每个文件,以及所属文档类别出现在 `requiredClasses` 中的每篇文档,都有完整配对。类别分为 `non-readme` 和 `readme`;判断类别时,basename 不区分大小写,因此 `missions/readme.md` 也属于 README。\n2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。\n4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\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**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录和被忽略的构建产物目录只在发现阶段排除,并非源文档。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\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\n**执行红线**:某个文档类别的存量文档全部翻译完成后,`requiredClasses` 会将整个类别纳入强制范围。`non-readme` 与 `readme` 均已纳入强制范围:当前及今后所有纳入范围的文档,合并时都必须配齐双语文件。manifest 的 `required` 列表保留已纳入的文件;以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note)只要日期不早于 `requiredSince`,就无论所属类别都必须与对侧文件一同合并。`--list` 会报告尚未纳入强制范围的类别中的任何 backlog(待翻清单),而每个已存在的配对仍受完整契约约束。\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" }, { "role": "user", @@ -40,11 +40,11 @@ }, { "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: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot. 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- **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. 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** — 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 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 docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- Rollout remains incremental until a document class is complete: explicit `required` entries and the date cutoff prevent regression during review batches, while a closed class makes every current and future member mandatory. The non-README class is closed, so only the README class can still appear as backlog.\n- The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), 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 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: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot. 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- **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. Both classes are closed. 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** — 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 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 docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- Explicit `required` entries and the date cutoff preserve the reviewed rollout history, while the two closed classes make every current and future in-scope document mandatory. No document class can grow a new backlog.\n- The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), 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本仓库的 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))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中的源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。发布到文档站的配对使用 `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- **英文为正典源、指纹放在译文内**:本 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`;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- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 在文档类别全部完成之前,推进仍然是渐进的:显式 `required` 条目与日期分界可在评审批次期间防止回退,已纳入强制范围的类别则将其当前及今后的每个成员都列为必选项。非 README 类别已纳入强制范围,因此只有 README 类别仍可能出现 backlog(待翻清单)。\n- 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" + "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))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中的源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。这两个类别均已纳入强制范围。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- **英文为正典源、指纹放在译文内**:本 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`;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- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 显式 `required` 条目与日期分界保留已经评审的推进历史,而两个已纳入强制范围的类别会将当前及今后所有范围内的文档列为必选项。任何文档类别都不能新增 backlog(待翻清单)。\n- 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" }, { "role": "user", diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 35688f62b7..1667a8eaef 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -1,6 +1,7 @@ { "requiredClasses": [ - "non-readme" + "non-readme", + "readme" ], "requiredSince": "2026-07-14", "required": [ diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index b8b880565a..5c10f80abb 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { datedDocumentDate, isIsoDate, + isTranslationScopeFile, parseTranslationMarkdown, parseTranslationPairingManifest, requiresPairByDate, @@ -82,6 +83,45 @@ describe('document-class pairing frontier', () => { expect(requiresTranslationPair('docs/legacy/README.md', manifest)).toBe(true) expect(requiresTranslationPair('docs/new/README.md', manifest)).toBe(false) }) + + it('requires both document classes after the README frontier closes', () => { + const closed = parseTranslationPairingManifest(JSON.stringify({ + ...manifest, + requiredClasses: ['non-readme', 'readme'], + })) + expect(requiresTranslationPair('docs/guide.md', closed)).toBe(true) + expect(requiresTranslationPair('future/subtree/README.md', closed)).toBe(true) + }) +}) + +describe('translation scope discovery', () => { + it.each([ + 'README.md', + 'apps/cli/README.md', + 'future/subtree/readme.md', + 'packages/example/README.zh.md', + 'native/example/README.i18n.yaml', + '.agents/notes/proposed/feature.md', + 'docs/guide.md', + 'python/guide.md', + ])('includes %s', (file) => { + expect(isTranslationScopeFile(file)).toBe(true) + }) + + it.each([ + 'packages/example/guide.md', + 'examples/tutorial.md', + 'website/reference.md', + 'packages/example/README.txt', + 'vendor/example/README.md', + 'packages/example/node_modules/dependency/README.md', + 'packages/example/lib/README.md', + 'coverage/report/README.md', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-macos-arm64/README.md', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/README.md', + ])('excludes non-source or non-README path %s', (file) => { + expect(isTranslationScopeFile(file)).toBe(false) + }) }) describe('date-based pairing frontier', () => { diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index 92c38e8896..a41bcc3077 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -26,6 +26,63 @@ const TRANSLATION_DOCUMENT_CLASSES: TranslationDocumentClass[] = ['readme', 'non const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/ +const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i +const NON_SOURCE_DIRECTORIES = new Set([ + 'node_modules', + 'lib', + '.pnpm-store', + '.cache', + 'coverage', + '.sessions', + '.storages', + 'tmp', + 'dist-exe', + '__pycache__', + '.pytest_cache', + '.artifacts', + 'vendor', +]) + +/** Glob traversal exclusions corresponding to the non-source path predicate. */ +export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [ + '**/node_modules/**', + '**/lib/**', + '**/.pnpm-store/**', + '**/.cache/**', + '**/coverage/**', + '**/.doc-typecheck-*/**', + '**/.node-next-types-*/**', + '**/.sessions/**', + '**/.storages/**', + '**/tmp/**', + '**/dist-exe/**', + '**/__pycache__/**', + '**/.pytest_cache/**', + 'apps/web/dist/**', + '.artifacts/**', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*/**', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**', + 'vendor/**', +] + +/** Whether a repository-relative path belongs to a dependency or generated tree. */ +function isTranslationSourceExcluded(file: string): boolean { + const segments = file.split('/') + return segments.some(segment => NON_SOURCE_DIRECTORIES.has(segment) + || segment.startsWith('.doc-typecheck-') + || segment.startsWith('.node-next-types-')) + || file.startsWith('apps/web/dist/') + || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-') + || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/') +} + +/** Whether one discovered Markdown or sidecar path belongs to the bilingual source corpus. */ +export function isTranslationScopeFile(file: string): boolean { + return !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file) + || file.startsWith('.agents/notes/') + || file.startsWith('docs/') + || file.startsWith('python/')) +} /** Whether a string names one real calendar day in canonical ISO form. */ export function isIsoDate(value: string): boolean { diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 9372064707..8e688d02e9 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -15,7 +15,9 @@ import { linksTo, parseTranslationMarkdown, parseTranslationPairingManifest, + isTranslationScopeFile, requiresTranslationPair, + TRANSLATION_SCOPE_GLOB_EXCLUDES, translationDocumentClass, translationStructureDiff, translationStructureSignature, @@ -25,17 +27,12 @@ const root = resolve(import.meta.dirname, '..') const listMode = process.argv.includes('--list') const writeMode = process.argv.includes('--write') -/** Scope of the bilingual contract: root docs, Agent Notes, the docs tree, and the Python SDK tree. */ +/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */ const SCOPE_PATTERNS = [ - 'README.md', - 'README.zh.md', - 'README.i18n.yaml', + '**/*.md', + '**/*.i18n.yaml', '.agents/notes/**/*.md', '.agents/notes/**/*.i18n.yaml', - 'docs/**/*.md', - 'docs/**/*.i18n.yaml', - 'python/**/*.md', - 'python/**/*.i18n.yaml', ] const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) @@ -93,7 +90,10 @@ function renderMeta(source: string, sourceHash: string, zh: string, zhHash: stri // Enumerate the scope once. const files = new Set() for (const pattern of SCOPE_PATTERNS) { - for (const match of globSync(pattern, { cwd: root })) files.add(match.split(sep).join('/')) + for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) { + const normalized = match.split(sep).join('/') + if (isTranslationScopeFile(normalized)) files.add(normalized) + } } const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort() From a04ce7afbb6dde19d5e2973794f183667f27c1c4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:15:56 +0800 Subject: [PATCH 37/45] refactor(client): shared declaration-aware registration deferral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five settings-surface registrants carried near-identical spec-check/ledger-judge/subscribe scaffolding (three jscpd clones); ui-slots now owns deferRegistration() — ledger-judged presence, refresh for registrant-localized labels, one-call disposal — and every registrant shrinks to its registration body. --- packages/client/locale/src/client/index.ts | 23 ++----- packages/client/ui-models/src/client/index.ts | 32 +++------ .../client/ui-settings/src/client/index.ts | 46 ++++--------- packages/client/ui-slots/src/deferred.ts | 66 +++++++++++++++++++ packages/client/ui-slots/src/index.ts | 1 + packages/client/ui-theme/src/client/index.ts | 23 ++----- 6 files changed, 97 insertions(+), 94 deletions(-) create mode 100644 packages/client/ui-slots/src/deferred.ts diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 444594f9b8..ea6f9d8da9 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -5,7 +5,7 @@ * its own settings surface. */ import type { Context } from 'cordis' -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { en } from '../locales/en.ts' import { zh } from '../locales/zh.ts' @@ -233,28 +233,15 @@ export function apply(ctx: ClientContext): void { setLocale: (id) => { locale.setLocale(id) }, } } - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.general.item') === undefined) return - if (ctx.slots.entries('settings.general.item').some(e => e.component === LanguageRow)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () => + ctx.slots.register({ name: 'settings.general.item', id: 'language', order: 0, store, inject: injected, - }, LanguageRow) - } - const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) - tryRegister() - return () => { - unsubscribe() - dispose?.() - } + }, LanguageRow)) + return () => { deferred.dispose() } }, 'locale: language settings row registration') } diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index 4f080ec928..5abcb65bcf 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -5,6 +5,7 @@ * discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -31,37 +32,20 @@ export function apply(ctx: ClientContext): void { ] return () => { for (const dispose of disposers) dispose() } }, 'ui-models: nav copy dictionaries') - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined) return - if (ctx.slots.entries('settings.section').some(e => e.component === ModelsSection)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () => + ctx.slots.register({ name: 'settings.section', id: 'models', order: 10, label: ctx.locale.bind('settings.models')('nav'), - }, ModelsSection) - } - // Nav labels are registrant-localized: re-register on locale change so - // the ledger carries fresh text (the version bump re-renders the shell). - // Dispose-then-requery: after an HMR collapse the disposer is stale and - // the ledger/spec re-check keeps this path an idempotent no-op. - const offLocale = ctx.on('locale/change', () => { - dispose?.() - dispose = undefined - tryRegister() - }) - const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) - tryRegister() + }, ModelsSection)) + // Nav labels are registrant-localized: refresh on locale change so the + // ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { deferred.refresh() }) return () => { offLocale() - unsubscribe() - dispose?.() + deferred.dispose() } }, 'ui-models: settings section registration') } diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index c360a84201..9d379c301e 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -7,6 +7,7 @@ * preference rows into. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the locale plugin's Context/Events merges (ctx.locale, // 'locale/change') into this program. import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -60,60 +61,37 @@ export function apply(ctx: ClientContext): void { })) .sort((a, b) => a.order - b.order), }) - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('sidebar.settings') === undefined) return - if (ctx.slots.entries('sidebar.settings').some(e => e.component === SettingsRoot)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => + ctx.slots.register({ name: 'sidebar.settings', children: { 'settings.section': { kind: 'list', scope: 'root' } }, inject: injected, - }, SettingsRoot) - } - const unsubscribe = ctx.slots.subscribe('sidebar.settings', () => { tryRegister() }) - tryRegister() - return () => { - unsubscribe() - dispose?.() - } + }, SettingsRoot)) + return () => { deferred.dispose() } }, 'ui-settings: shell registration') // The shell's own General section: first page, declares the item slot the // feature plugins (locale, ui-theme, …) contribute preference rows into. - // Same ledger-judged deferral; label re-registers on locale change. const generalInjected = (): GeneralSectionInjected => ({ t: ctx.locale.bind('settings'), }) ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined) return - if (ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => + ctx.slots.register({ name: 'settings.section', id: 'general', order: 0, label: ctx.locale.bind('settings')('general.nav'), children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, inject: generalInjected, - }, GeneralSection) - } - const offLocale = ctx.on('locale/change', () => { - dispose?.() - dispose = undefined - tryRegister() - }) - const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) - tryRegister() + }, GeneralSection)) + // Nav labels are registrant-localized: refresh on locale change so the + // ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { deferred.refresh() }) return () => { offLocale() - unsubscribe() - dispose?.() + deferred.dispose() } }, 'ui-settings: general section registration') } diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts new file mode 100644 index 0000000000..af85c27f97 --- /dev/null +++ b/packages/client/ui-slots/src/deferred.ts @@ -0,0 +1,66 @@ +/** + * Declaration-aware registration deferral: the shared timing machinery for + * registering into a slot whose declaring entry activates in unconstrained + * order (dshClient.inject edges never sequence apply). Presence is judged on + * the LEDGER, not a local flag — after an HMR collapse re-declares the slot, + * the cascade has already removed the entry while the local disposer went + * stale, and a flag guard would block the re-registration. + */ + +/** Minimal registry face the deferral reads (SlotsService satisfies it). */ +export interface DeferralRegistry { + /** Declared spec lookup (undefined = not declared yet). */ + spec(name: string): unknown + /** Current entries of the slot (component identity is the presence judge). */ + entries(name: string): readonly { component: unknown }[] + /** Subscribe to the slot's ledger changes; returns the unsubscriber. */ + subscribe(name: string, listener: () => void): () => void +} + +/** Handle over one deferred registration. */ +export interface DeferredRegistration { + /** + * Drop the current registration (stale disposers are harmless no-ops) and + * immediately re-attempt — the refresh path for registrants whose options + * carry localized text. + */ + refresh(): void + /** Unsubscribe and unregister (idempotent through the slot core). */ + dispose(): void +} + +/** + * Register into `name` as soon as its declaration is on the ledger, and + * re-register whenever the declaration reappears after a collapse. + * @param registry - the slot registry face. + * @param name - target slot name. + * @param component - the component whose ledger presence marks "registered". + * @param register - performs the actual registration; returns its disposer. + * @returns the deferral handle (dispose in the owning effect's disposer). + */ +export function deferRegistration( + registry: DeferralRegistry, + name: string, + component: unknown, + register: () => () => void, +): DeferredRegistration { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (registry.spec(name) === undefined) return + if (registry.entries(name).some(e => e.component === component)) return + dispose = register() + } + const unsubscribe = registry.subscribe(name, () => { tryRegister() }) + tryRegister() + return { + refresh() { + dispose?.() + dispose = undefined + tryRegister() + }, + dispose() { + unsubscribe() + dispose?.() + }, + } +} diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 677bc4e0df..e3bc57797d 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -18,6 +18,7 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec export * from './store.ts' export * from './renderer.ts' +export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index ac79913062..dd11c98f06 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -7,7 +7,7 @@ * 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 { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } 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' @@ -262,28 +262,15 @@ export function apply(ctx: ClientContext): void { setTheme: (id) => { theme.setTheme(id) }, } } - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.general.item') === undefined) return - if (ctx.slots.entries('settings.general.item').some(e => e.component === AppearanceRow)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () => + ctx.slots.register({ name: 'settings.general.item', id: 'appearance', order: 10, store, inject: injected, - }, AppearanceRow) - } - const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) - tryRegister() - return () => { - unsubscribe() - dispose?.() - } + }, AppearanceRow)) + return () => { deferred.dispose() } }, 'ui-theme: appearance settings row registration') } From 586ccb677ec0abd4cab2afd98a98965d5f570341 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:18:03 +0800 Subject: [PATCH 38/45] docs: refine semantic PR label taxonomy --- ...07-25-semantic-pr-label-taxonomy.i18n.yaml | 4 +-- .../2026-07-25-semantic-pr-label-taxonomy.md | 23 ++++++++++++----- ...026-07-25-semantic-pr-label-taxonomy.zh.md | 25 +++++++++++++------ 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml index 9be3fb4b13..8bc3f87435 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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 -2026-07-25-semantic-pr-label-taxonomy.md: cf25691e55d100cc2cc12246b17f5edbba35feea -2026-07-25-semantic-pr-label-taxonomy.zh.md: 2978bdc80a660f67f84821e473dc229e13b2bc4d +2026-07-25-semantic-pr-label-taxonomy.md: 61b7a829b8c44836cf9c6d0d8a7df463df309d89 +2026-07-25-semantic-pr-label-taxonomy.zh.md: cc0c5e7a8953bc97de51f90349a0e2542be5b77e diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md index cf25691e55..61b7a829b8 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -20,26 +20,32 @@ Every open or merged pull request carries exactly one kind and every materially |---|---| | `feature` | Adds or intentionally changes behavior. | | `bug-fix` | Corrects incorrect behavior. | -| `doc` | Changes documentation only. | +| `doc` | Makes documentation the dominant intent. | | `testing` | Changes tests or testing infrastructure without changing product behavior. | | `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. | The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change. -Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request may carry several areas when it changes several domains. +Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. Area labels are not a hierarchy: a pull request may carry several when it changes distinct contracts, but an umbrella and a narrower label do not both describe the same work. ### Current areas -The 43 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. +The 45 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. | Group | Areas | |---|---| | Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | -| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | -| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | -| Interfaces | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | +| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | +`gui` covers browser and Electron graphical applications, including standalone graphical developer tools; `vscode` remains the editor extension integration. `ui` covers shared cross-interface commands, approval interaction, presentation, and app boot; it coexists with `gui`, `tui`, or a protocol area only when the pull request also changes that shared contract. + +`tasks` owns background work tied to a running process, while `schedule` owns durable time-triggered jobs. `tools` owns generic registry, schema, and execution contracts; a concrete capability receives `tools` only when it changes one of those contracts. `attachment` owns durable media references and multimodal input delivery, while `artifact` owns model-declared deliverable identity and preview lifecycle; neither borrows `tools` or `ui` for its implementation parts. + +Names follow semantic ownership rather than lexical resemblance. `hooks` means the Claude Code and Codex agent bridges, not local Git hooks; `platform` means product portability, not CI runner selection; and `build` means compilation, bundling, and built package artifacts, not documentation generators. + ### Extensibility The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together. @@ -51,10 +57,15 @@ The kind set stays narrow because kinds are mutually exclusive. A new kind requi - **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered. - **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy. - **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need. +- **Separate browser and desktop areas.** Rejected because browser delivery and Electron packaging expose one graphical client domain; splitting them classifies the delivery shell rather than the semantic work. +- **Broad implementation areas in place of a domain.** Rejected because a durable scheduled job is not a background task, an attachment is not merely its source interface or filesystem implementation, and an artifact is not merely its declaring tool or preview interface. +- **Umbrella and leaf areas for the same contract.** Rejected because duplicate labels inflate scope without adding information. Multiple areas remain correct when a pull request changes genuinely distinct contracts. - **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts. ## Consequences - Reviewers and automation receive one stable intent signal plus a complete semantic scope. +- `gui` queries cover browser and desktop delivery together, while `ui` queries retain only shared cross-interface contracts. +- `schedule`, `attachment`, and `artifact` queries identify those domains directly instead of approximating them through implementation dependencies. - Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change. - Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md index 2978bdc80a..cc0c5e7a89 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -20,26 +20,32 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 |---|---| | `feature` | 新增行为或有意改变行为。 | | `bug-fix` | 修正错误行为。 | -| `doc` | 仅修改文档。 | +| `doc` | 以文档变更为主要意图。 | | `testing` | 修改测试或测试基础设施,但不改变产品行为。 | | `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | 类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。 -领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。如果一项 PR 修改了多个领域,就可以带有多个领域标签。 +领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。领域标签不构成层级:一项 PR 修改不同契约时可以带有多个领域标签,但不能用一个总括标签和一个较窄标签重复描述同一项工作。 ### 当前领域 -当前的 43 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 +当前的 45 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 | 分组 | 领域 | |---|---| | agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | -| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | -| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | -| 接口 | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | +| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | +`gui` 涵盖浏览器和 Electron 图形应用,包括独立的图形化开发者工具;`vscode` 仍表示编辑器扩展集成。`ui` 涵盖共享的跨接口命令、审批交互、呈现和应用启动;只有当 PR 还修改这项共享契约时,它才与 `gui`、`tui` 或某个协议领域并用。 + +`tasks` 负责与运行中进程绑定的后台工作,`schedule` 则负责持久化的定时作业。`tools` 负责通用的注册表契约、schema 契约和执行契约;具体能力只有在修改其中一项契约时才带有 `tools`。`attachment` 负责持久化的媒体引用和多模态输入传递,`artifact` 则负责模型声明的交付物标识和预览生命周期;二者都不会因实现包含工具或界面部分而借用 `tools` 或 `ui`。 + +标签名称以语义归属为准,而不是词面相似性。`hooks` 指 Claude Code 和 Codex 的 agent 桥接,而不是本地 Git 钩子;`platform` 指产品可移植性,而不是 CI 运行器选择;`build` 指编译、打包和已构建的包(package)产物,而不是文档生成器。 + ### 可扩展性 领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。 @@ -50,11 +56,16 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 - **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。 - **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。 -- **一个宽泛的 `core` 领域,或从包(package)结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **一个宽泛的 `core` 领域,或从包结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **为浏览器和桌面端分别设置领域。** 不予采纳,因为浏览器交付和 Electron 打包共同呈现同一个图形客户端领域;拆开二者将按交付形态而非工作的语义进行分类。 +- **以宽泛的实现领域替代语义领域。** 不予采纳,因为持久化的定时作业不是后台任务,附件不只是其来源接口或文件系统实现,产物也不只是声明它的工具或预览接口。 +- **同一项契约同时使用总括领域与细分领域。** 不予采纳,因为重复标签只会虚增范围,不会增加信息。一项 PR 确实修改不同契约时,多个领域标签仍然合理。 - **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。 ## 后果 - 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。 +- `gui` 查询会同时覆盖浏览器与桌面端交付,`ui` 查询则只涵盖共享的跨接口契约。 +- `schedule`、`attachment` 与 `artifact` 查询直接对应各自领域,无需通过实现依赖近似归类。 - 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。 - 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。 From 226dc7a249845aaae8f99a340109c214d20139bf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:03:53 +0800 Subject: [PATCH 39/45] docs: translate remaining READMEs --- apps/cli/README.i18n.yaml | 6 + apps/cli/README.md | 2 + apps/cli/README.zh.md | 27 +++ examples/README.i18n.yaml | 6 + examples/README.md | 2 + examples/README.zh.md | 35 ++++ examples/acp-agent/README.i18n.yaml | 6 + examples/acp-agent/README.md | 2 + examples/acp-agent/README.zh.md | 30 +++ examples/cordis-agent/README.i18n.yaml | 6 + examples/cordis-agent/README.md | 2 + examples/cordis-agent/README.zh.md | 35 ++++ examples/headless-agent/README.i18n.yaml | 6 + examples/headless-agent/README.md | 2 + examples/headless-agent/README.zh.md | 26 +++ examples/jsonrpc-agent/README.i18n.yaml | 6 + examples/jsonrpc-agent/README.md | 2 + examples/jsonrpc-agent/README.zh.md | 27 +++ examples/tui-agent/README.i18n.yaml | 6 + examples/tui-agent/README.md | 2 + examples/tui-agent/README.zh.md | 80 +++++++ native/README.i18n.yaml | 6 + native/README.md | 2 + native/README.zh.md | 22 ++ native/landlock-run/README.i18n.yaml | 6 + native/landlock-run/README.md | 2 + native/landlock-run/README.zh.md | 60 ++++++ .../packages/entry/README.i18n.yaml | 6 + native/landlock-run/packages/entry/README.md | 2 + .../landlock-run/packages/entry/README.zh.md | 18 ++ .../packages/linux-arm64/README.i18n.yaml | 6 + .../packages/linux-arm64/README.md | 2 + .../packages/linux-arm64/README.zh.md | 9 + .../packages/linux-x64/README.i18n.yaml | 6 + .../landlock-run/packages/linux-x64/README.md | 2 + .../packages/linux-x64/README.zh.md | 9 + packages/README.i18n.yaml | 6 + packages/README.md | 2 + packages/README.zh.md | 56 +++++ packages/acp/README.i18n.yaml | 6 + packages/acp/README.md | 2 + packages/acp/README.zh.md | 11 + packages/acp/acp/README.i18n.yaml | 6 + packages/acp/acp/README.md | 2 + packages/acp/acp/README.zh.md | 79 +++++++ packages/bash/README.i18n.yaml | 6 + packages/bash/README.md | 2 + packages/bash/README.zh.md | 14 ++ packages/bash/bash-local/README.i18n.yaml | 6 + packages/bash/bash-local/README.md | 2 + packages/bash/bash-local/README.zh.md | 49 +++++ packages/bash/bash-sandbox/README.i18n.yaml | 6 + packages/bash/bash-sandbox/README.md | 2 + packages/bash/bash-sandbox/README.zh.md | 90 ++++++++ packages/bash/bash/README.i18n.yaml | 6 + packages/bash/bash/README.md | 2 + packages/bash/bash/README.zh.md | 49 +++++ packages/bash/tool-bash/README.i18n.yaml | 6 + packages/bash/tool-bash/README.md | 2 + packages/bash/tool-bash/README.zh.md | 158 ++++++++++++++ packages/client/connection/README.i18n.yaml | 6 + packages/client/connection/README.md | 2 + packages/client/connection/README.zh.md | 22 ++ packages/client/hmr/README.i18n.yaml | 6 + packages/client/hmr/README.md | 2 + packages/client/hmr/README.zh.md | 21 ++ packages/client/i18n/README.i18n.yaml | 6 + packages/client/i18n/README.md | 2 + packages/client/i18n/README.zh.md | 18 ++ packages/client/modules/README.i18n.yaml | 6 + packages/client/modules/README.md | 2 + packages/client/modules/README.zh.md | 22 ++ packages/client/runtime/README.i18n.yaml | 6 + packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 33 +++ .../client/ui-conversation/README.i18n.yaml | 6 + packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 33 +++ packages/client/ui-layout/README.i18n.yaml | 6 + packages/client/ui-layout/README.md | 2 + packages/client/ui-layout/README.zh.md | 23 +++ .../client/ui-primitives/README.i18n.yaml | 6 + packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 23 +++ packages/client/ui-question/README.i18n.yaml | 6 + packages/client/ui-question/README.md | 2 + packages/client/ui-question/README.zh.md | 22 ++ packages/client/ui-sidebar/README.i18n.yaml | 6 + packages/client/ui-sidebar/README.md | 2 + packages/client/ui-sidebar/README.zh.md | 25 +++ packages/client/ui-slots/README.i18n.yaml | 6 + packages/client/ui-slots/README.md | 2 + packages/client/ui-slots/README.zh.md | 35 ++++ packages/client/ui-theme/README.i18n.yaml | 6 + packages/client/ui-theme/README.md | 2 + packages/client/ui-theme/README.zh.md | 19 ++ .../client/ui-trajectory/README.i18n.yaml | 6 + packages/client/ui-trajectory/README.md | 2 + packages/client/ui-trajectory/README.zh.md | 17 ++ packages/client/ui-workspace/README.i18n.yaml | 6 + packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 22 ++ packages/client/web-react/README.i18n.yaml | 6 + packages/client/web-react/README.md | 2 + packages/client/web-react/README.zh.md | 19 ++ packages/client/web/README.i18n.yaml | 6 + packages/client/web/README.md | 2 + packages/client/web/README.zh.md | 26 +++ packages/code-runtime/README.i18n.yaml | 6 + packages/code-runtime/README.md | 2 + packages/code-runtime/README.zh.md | 12 ++ .../code-runtime-worker/README.i18n.yaml | 6 + .../code-runtime-worker/README.md | 2 + .../code-runtime-worker/README.zh.md | 54 +++++ .../code-runtime/README.i18n.yaml | 6 + packages/code-runtime/code-runtime/README.md | 2 + .../code-runtime/code-runtime/README.zh.md | 36 ++++ packages/compact/README.i18n.yaml | 6 + packages/compact/README.md | 2 + packages/compact/README.zh.md | 14 ++ .../compact/compact-basic/README.i18n.yaml | 6 + packages/compact/compact-basic/README.md | 2 + packages/compact/compact-basic/README.zh.md | 161 +++++++++++++++ .../README.i18n.yaml | 6 + .../compact-tool-result-prune/README.md | 2 + .../compact-tool-result-prune/README.zh.md | 62 ++++++ packages/compact/compact/README.i18n.yaml | 6 + packages/compact/compact/README.md | 2 + packages/compact/compact/README.zh.md | 82 ++++++++ packages/context/README.i18n.yaml | 6 + packages/context/README.md | 2 + packages/context/README.zh.md | 13 ++ .../session-reference/README.i18n.yaml | 6 + packages/context/session-reference/README.md | 2 + .../context/session-reference/README.zh.md | 50 +++++ .../context/time-context/README.i18n.yaml | 6 + packages/context/time-context/README.md | 2 + packages/context/time-context/README.zh.md | 70 +++++++ .../workspace-context/README.i18n.yaml | 6 + packages/context/workspace-context/README.md | 2 + .../context/workspace-context/README.zh.md | 169 +++++++++++++++ packages/cordis/README.i18n.yaml | 6 + packages/cordis/README.md | 2 + packages/cordis/README.zh.md | 9 + packages/cordis/tool-cordis/README.i18n.yaml | 6 + packages/cordis/tool-cordis/README.md | 2 + packages/cordis/tool-cordis/README.zh.md | 87 ++++++++ packages/core/README.i18n.yaml | 6 + packages/core/README.md | 2 + packages/core/README.zh.md | 20 ++ packages/core/agent-loop/README.i18n.yaml | 6 + packages/core/agent-loop/README.md | 2 + packages/core/agent-loop/README.zh.md | 129 ++++++++++++ packages/core/agent/README.i18n.yaml | 6 + packages/core/agent/README.md | 2 + packages/core/agent/README.zh.md | 116 +++++++++++ packages/core/scope/README.i18n.yaml | 6 + packages/core/scope/README.md | 2 + packages/core/scope/README.zh.md | 36 ++++ packages/core/session/README.i18n.yaml | 6 + packages/core/session/README.md | 2 + packages/core/session/README.zh.md | 147 +++++++++++++ packages/core/system-prompt/README.i18n.yaml | 6 + packages/core/system-prompt/README.md | 2 + packages/core/system-prompt/README.zh.md | 86 ++++++++ packages/core/tools/README.i18n.yaml | 6 + packages/core/tools/README.md | 2 + packages/core/tools/README.zh.md | 195 ++++++++++++++++++ packages/examples/README.i18n.yaml | 6 + packages/examples/README.md | 2 + packages/examples/README.zh.md | 23 +++ packages/examples/acp-demo/README.i18n.yaml | 6 + packages/examples/acp-demo/README.md | 2 + packages/examples/acp-demo/README.zh.md | 59 ++++++ .../agent-spine-demo/README.i18n.yaml | 6 + packages/examples/agent-spine-demo/README.md | 2 + .../examples/agent-spine-demo/README.zh.md | 83 ++++++++ packages/examples/cli-demo/README.i18n.yaml | 6 + packages/examples/cli-demo/README.md | 2 + packages/examples/cli-demo/README.zh.md | 79 +++++++ .../examples/jsonrpc-demo/README.i18n.yaml | 6 + packages/examples/jsonrpc-demo/README.md | 2 + packages/examples/jsonrpc-demo/README.zh.md | 33 +++ packages/examples/tui-demo/README.i18n.yaml | 6 + packages/examples/tui-demo/README.md | 2 + packages/examples/tui-demo/README.zh.md | 112 ++++++++++ packages/fs/README.i18n.yaml | 6 + packages/fs/README.md | 2 + packages/fs/README.zh.md | 20 ++ packages/fs/fs-local/README.i18n.yaml | 6 + packages/fs/fs-local/README.md | 2 + packages/fs/fs-local/README.zh.md | 41 ++++ packages/fs/fs-policy/README.i18n.yaml | 6 + packages/fs/fs-policy/README.md | 2 + packages/fs/fs-policy/README.zh.md | 73 +++++++ packages/fs/fs-sandbox/README.i18n.yaml | 6 + packages/fs/fs-sandbox/README.md | 2 + packages/fs/fs-sandbox/README.zh.md | 35 ++++ packages/fs/fs/README.i18n.yaml | 6 + packages/fs/fs/README.md | 2 + packages/fs/fs/README.zh.md | 62 ++++++ packages/fs/tool-fs-search/README.i18n.yaml | 6 + packages/fs/tool-fs-search/README.md | 2 + packages/fs/tool-fs-search/README.zh.md | 124 +++++++++++ packages/fs/tool-fs/README.i18n.yaml | 6 + packages/fs/tool-fs/README.md | 2 + packages/fs/tool-fs/README.zh.md | 151 ++++++++++++++ packages/goal/README.i18n.yaml | 6 + packages/goal/README.md | 2 + packages/goal/README.zh.md | 14 ++ packages/goal/command-goal/README.i18n.yaml | 6 + packages/goal/command-goal/README.md | 2 + packages/goal/command-goal/README.zh.md | 58 ++++++ packages/goal/goal-session/README.i18n.yaml | 6 + packages/goal/goal-session/README.md | 2 + packages/goal/goal-session/README.zh.md | 73 +++++++ packages/goal/goal/README.i18n.yaml | 6 + packages/goal/goal/README.md | 2 + packages/goal/goal/README.zh.md | 58 ++++++ packages/goal/tool-goal/README.i18n.yaml | 6 + packages/goal/tool-goal/README.md | 2 + packages/goal/tool-goal/README.zh.md | 80 +++++++ packages/guard/README.i18n.yaml | 6 + packages/guard/README.md | 2 + packages/guard/README.zh.md | 11 + .../guard/repeat-tool-guard/README.i18n.yaml | 6 + packages/guard/repeat-tool-guard/README.md | 2 + packages/guard/repeat-tool-guard/README.zh.md | 94 +++++++++ packages/hooks/README.i18n.yaml | 6 + packages/hooks/README.md | 2 + packages/hooks/README.zh.md | 13 ++ packages/hooks/hook-protocol/README.i18n.yaml | 6 + packages/hooks/hook-protocol/README.md | 2 + packages/hooks/hook-protocol/README.zh.md | 45 ++++ packages/hooks/hooks-claude/README.i18n.yaml | 6 + packages/hooks/hooks-claude/README.md | 2 + packages/hooks/hooks-claude/README.zh.md | 97 +++++++++ packages/hooks/hooks-codex/README.i18n.yaml | 6 + packages/hooks/hooks-codex/README.md | 2 + packages/hooks/hooks-codex/README.zh.md | 100 +++++++++ packages/host/apiproxy/README.i18n.yaml | 6 + packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 33 +++ packages/host/webserver/README.i18n.yaml | 6 + packages/host/webserver/README.md | 2 + packages/host/webserver/README.zh.md | 25 +++ packages/llm/README.i18n.yaml | 6 + packages/llm/README.md | 2 + packages/llm/README.zh.md | 15 ++ packages/llm/llm-deepseek/README.i18n.yaml | 6 + packages/llm/llm-deepseek/README.md | 2 + packages/llm/llm-deepseek/README.zh.md | 94 +++++++++ packages/llm/llm-pi-ai/README.i18n.yaml | 6 + packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/README.zh.md | 102 +++++++++ packages/llm/llm-retry/README.i18n.yaml | 6 + packages/llm/llm-retry/README.md | 2 + packages/llm/llm-retry/README.zh.md | 43 ++++ packages/llm/llm/README.i18n.yaml | 6 + packages/llm/llm/README.md | 2 + packages/llm/llm/README.zh.md | 80 +++++++ packages/llm/token-meter/README.i18n.yaml | 6 + packages/llm/token-meter/README.md | 2 + packages/llm/token-meter/README.zh.md | 46 +++++ packages/lsp/README.i18n.yaml | 6 + packages/lsp/README.md | 2 + packages/lsp/README.zh.md | 15 ++ packages/lsp/lsp-local/README.i18n.yaml | 6 + packages/lsp/lsp-local/README.md | 2 + packages/lsp/lsp-local/README.zh.md | 58 ++++++ packages/lsp/lsp/README.i18n.yaml | 6 + packages/lsp/lsp/README.md | 2 + packages/lsp/lsp/README.zh.md | 44 ++++ packages/lsp/tool-lsp/README.i18n.yaml | 6 + packages/lsp/tool-lsp/README.md | 2 + packages/lsp/tool-lsp/README.zh.md | 90 ++++++++ packages/mcp/README.i18n.yaml | 6 + packages/mcp/README.md | 2 + packages/mcp/README.zh.md | 9 + packages/mcp/mcp-client/README.i18n.yaml | 6 + packages/mcp/mcp-client/README.md | 2 + packages/mcp/mcp-client/README.zh.md | 108 ++++++++++ packages/plan/README.i18n.yaml | 6 + packages/plan/README.md | 2 + packages/plan/README.zh.md | 11 + packages/plan/plan-mode/README.i18n.yaml | 6 + packages/plan/plan-mode/README.md | 2 + packages/plan/plan-mode/README.zh.md | 91 ++++++++ packages/pty/README.i18n.yaml | 6 + packages/pty/README.md | 2 + packages/pty/README.zh.md | 13 ++ packages/pty/pty-local/README.i18n.yaml | 6 + packages/pty/pty-local/README.md | 2 + packages/pty/pty-local/README.zh.md | 36 ++++ packages/pty/pty/README.i18n.yaml | 6 + packages/pty/pty/README.md | 2 + packages/pty/pty/README.zh.md | 41 ++++ packages/pty/tool-pty/README.i18n.yaml | 6 + packages/pty/tool-pty/README.md | 2 + packages/pty/tool-pty/README.zh.md | 71 +++++++ packages/sandbox/README.i18n.yaml | 6 + packages/sandbox/README.md | 2 + packages/sandbox/README.zh.md | 15 ++ .../sandbox/sandbox-local/README.i18n.yaml | 6 + packages/sandbox/sandbox-local/README.md | 2 + packages/sandbox/sandbox-local/README.zh.md | 40 ++++ .../sandbox/sandbox-policy/README.i18n.yaml | 6 + packages/sandbox/sandbox-policy/README.md | 2 + packages/sandbox/sandbox-policy/README.zh.md | 41 ++++ packages/sandbox/sandbox/README.i18n.yaml | 6 + packages/sandbox/sandbox/README.md | 2 + packages/sandbox/sandbox/README.zh.md | 42 ++++ packages/sdk/README.i18n.yaml | 6 + packages/sdk/README.md | 2 + packages/sdk/README.zh.md | 17 ++ packages/sdk/create-sdk/README.i18n.yaml | 6 + packages/sdk/create-sdk/README.md | 2 + packages/sdk/create-sdk/README.zh.md | 25 +++ packages/sdk/helper/README.i18n.yaml | 6 + packages/sdk/helper/README.md | 2 + packages/sdk/helper/README.zh.md | 29 +++ packages/sdk/scripts/README.i18n.yaml | 6 + packages/sdk/scripts/README.md | 2 + packages/sdk/scripts/README.zh.md | 37 ++++ packages/sdk/telemetry/README.i18n.yaml | 6 + packages/sdk/telemetry/README.md | 2 + packages/sdk/telemetry/README.zh.md | 30 +++ packages/session-persistence/README.i18n.yaml | 6 + packages/session-persistence/README.md | 2 + packages/session-persistence/README.zh.md | 14 ++ .../README.i18n.yaml | 6 + .../session-checkpoint-policy/README.md | 2 + .../session-checkpoint-policy/README.zh.md | 47 +++++ .../README.i18n.yaml | 6 + .../session-persistence-jsonl/README.md | 2 + .../session-persistence-jsonl/README.zh.md | 75 +++++++ .../README.i18n.yaml | 6 + .../session-persistence-sqlite/README.md | 2 + .../session-persistence-sqlite/README.zh.md | 61 ++++++ .../session-persistence/README.i18n.yaml | 6 + .../session-persistence/README.md | 2 + .../session-persistence/README.zh.md | 83 ++++++++ packages/session-query/README.i18n.yaml | 6 + packages/session-query/README.md | 2 + packages/session-query/README.zh.md | 13 ++ .../session-query-sqlite/README.i18n.yaml | 6 + .../session-query-sqlite/README.md | 2 + .../session-query-sqlite/README.zh.md | 54 +++++ .../session-query/README.i18n.yaml | 6 + .../session-query/session-query/README.md | 2 + .../session-query/session-query/README.zh.md | 56 +++++ .../tool-session-query/README.i18n.yaml | 6 + .../tool-session-query/README.md | 2 + .../tool-session-query/README.zh.md | 76 +++++++ packages/session-title/README.i18n.yaml | 6 + packages/session-title/README.md | 2 + packages/session-title/README.zh.md | 14 ++ .../README.i18n.yaml | 6 + .../session-title-all-messages-llm/README.md | 2 + .../README.zh.md | 28 +++ .../README.i18n.yaml | 6 + .../session-title-first-message-llm/README.md | 2 + .../README.zh.md | 28 +++ .../session-title-llm/README.i18n.yaml | 6 + .../session-title/session-title-llm/README.md | 2 + .../session-title-llm/README.zh.md | 47 +++++ .../session-title/README.i18n.yaml | 6 + .../session-title/session-title/README.md | 2 + .../session-title/session-title/README.zh.md | 54 +++++ packages/skill/README.i18n.yaml | 6 + packages/skill/README.md | 2 + packages/skill/README.zh.md | 13 ++ packages/skill/skill-local/README.i18n.yaml | 6 + packages/skill/skill-local/README.md | 2 + packages/skill/skill-local/README.zh.md | 54 +++++ packages/skill/skill/README.i18n.yaml | 6 + packages/skill/skill/README.md | 2 + packages/skill/skill/README.zh.md | 53 +++++ packages/skill/tool-skill/README.i18n.yaml | 6 + packages/skill/tool-skill/README.md | 2 + packages/skill/tool-skill/README.zh.md | 148 +++++++++++++ packages/spill/README.i18n.yaml | 6 + packages/spill/README.md | 2 + packages/spill/README.zh.md | 15 ++ packages/spill/spill-local/README.i18n.yaml | 6 + packages/spill/spill-local/README.md | 2 + packages/spill/spill-local/README.zh.md | 34 +++ packages/spill/spill-policy/README.i18n.yaml | 6 + packages/spill/spill-policy/README.md | 2 + packages/spill/spill-policy/README.zh.md | 56 +++++ packages/spill/spill/README.i18n.yaml | 6 + packages/spill/spill/README.md | 2 + packages/spill/spill/README.zh.md | 42 ++++ packages/storage/README.i18n.yaml | 6 + packages/storage/README.md | 2 + packages/storage/README.zh.md | 14 ++ .../storage/storage-domain/README.i18n.yaml | 6 + packages/storage/storage-domain/README.md | 2 + packages/storage/storage-domain/README.zh.md | 35 ++++ .../storage/storage-json/README.i18n.yaml | 6 + packages/storage/storage-json/README.md | 2 + packages/storage/storage-json/README.zh.md | 38 ++++ .../storage/storage-sqlite/README.i18n.yaml | 6 + packages/storage/storage-sqlite/README.md | 2 + packages/storage/storage-sqlite/README.zh.md | 43 ++++ packages/storage/storage/README.i18n.yaml | 6 + packages/storage/storage/README.md | 2 + packages/storage/storage/README.zh.md | 41 ++++ packages/subagent/README.i18n.yaml | 6 + packages/subagent/README.md | 2 + packages/subagent/README.zh.md | 19 ++ .../subagent/subagent-acp/README.i18n.yaml | 6 + packages/subagent/subagent-acp/README.md | 2 + packages/subagent/subagent-acp/README.zh.md | 103 +++++++++ .../subagent/subagent-fork/README.i18n.yaml | 6 + packages/subagent/subagent-fork/README.md | 2 + packages/subagent/subagent-fork/README.zh.md | 61 ++++++ .../subagent-inprocess/README.i18n.yaml | 6 + .../subagent/subagent-inprocess/README.md | 2 + .../subagent/subagent-inprocess/README.zh.md | 112 ++++++++++ .../subagent/subagent-spawn/README.i18n.yaml | 6 + packages/subagent/subagent-spawn/README.md | 2 + packages/subagent/subagent-spawn/README.zh.md | 56 +++++ .../subagent-subprocess/README.i18n.yaml | 6 + .../subagent/subagent-subprocess/README.md | 2 + .../subagent/subagent-subprocess/README.zh.md | 55 +++++ packages/subagent/subagent/README.i18n.yaml | 6 + packages/subagent/subagent/README.md | 2 + packages/subagent/subagent/README.zh.md | 82 ++++++++ .../subagent/tool-subagent/README.i18n.yaml | 6 + packages/subagent/tool-subagent/README.md | 2 + packages/subagent/tool-subagent/README.zh.md | 81 ++++++++ packages/support/README.i18n.yaml | 6 + packages/support/README.md | 2 + packages/support/README.zh.md | 16 ++ .../support/acp-snapshot/README.i18n.yaml | 6 + packages/support/acp-snapshot/README.md | 2 + packages/support/acp-snapshot/README.zh.md | 72 +++++++ .../agent-loop-testkit/README.i18n.yaml | 6 + packages/support/agent-loop-testkit/README.md | 2 + .../support/agent-loop-testkit/README.zh.md | 33 +++ packages/support/invariants/README.i18n.yaml | 6 + packages/support/invariants/README.md | 2 + packages/support/invariants/README.zh.md | 85 ++++++++ .../support/llm-mock-server/README.i18n.yaml | 6 + packages/support/llm-mock-server/README.md | 2 + packages/support/llm-mock-server/README.zh.md | 86 ++++++++ packages/support/llm-replay/README.i18n.yaml | 6 + packages/support/llm-replay/README.md | 2 + packages/support/llm-replay/README.zh.md | 70 +++++++ .../support/loader-smoke/README.i18n.yaml | 6 + packages/support/loader-smoke/README.md | 2 + packages/support/loader-smoke/README.zh.md | 23 +++ packages/tasks/README.i18n.yaml | 6 + packages/tasks/README.md | 2 + packages/tasks/README.zh.md | 12 ++ packages/tasks/tasks/README.i18n.yaml | 6 + packages/tasks/tasks/README.md | 2 + packages/tasks/tasks/README.zh.md | 43 ++++ packages/tasks/tool-tasks/README.i18n.yaml | 6 + packages/tasks/tool-tasks/README.md | 2 + packages/tasks/tool-tasks/README.zh.md | 86 ++++++++ packages/timeout/README.i18n.yaml | 6 + packages/timeout/README.md | 2 + packages/timeout/README.zh.md | 11 + .../timeout/timeout-policy/README.i18n.yaml | 6 + packages/timeout/timeout-policy/README.md | 2 + packages/timeout/timeout-policy/README.zh.md | 57 +++++ packages/todo/README.i18n.yaml | 6 + packages/todo/README.md | 2 + packages/todo/README.zh.md | 11 + packages/todo/tool-todo/README.i18n.yaml | 6 + packages/todo/tool-todo/README.md | 2 + packages/todo/tool-todo/README.zh.md | 63 ++++++ packages/ui/README.i18n.yaml | 6 + packages/ui/README.md | 2 + packages/ui/README.zh.md | 22 ++ packages/ui/app-boot/README.i18n.yaml | 6 + packages/ui/app-boot/README.md | 2 + packages/ui/app-boot/README.zh.md | 48 +++++ packages/ui/commands/README.i18n.yaml | 6 + packages/ui/commands/README.md | 2 + packages/ui/commands/README.zh.md | 41 ++++ packages/ui/jsonrpc/README.i18n.yaml | 6 + packages/ui/jsonrpc/README.md | 2 + packages/ui/jsonrpc/README.zh.md | 47 +++++ packages/ui/permission/README.i18n.yaml | 6 + packages/ui/permission/README.md | 2 + packages/ui/permission/README.zh.md | 24 +++ packages/ui/tool-ask-user/README.i18n.yaml | 6 + packages/ui/tool-ask-user/README.md | 2 + packages/ui/tool-ask-user/README.zh.md | 57 +++++ packages/ui/tui/README.i18n.yaml | 6 + packages/ui/tui/README.md | 2 + packages/ui/tui/README.zh.md | 165 +++++++++++++++ packages/ui/user-approval/README.i18n.yaml | 6 + packages/ui/user-approval/README.md | 2 + packages/ui/user-approval/README.zh.md | 63 ++++++ packages/ui/user-interaction/README.i18n.yaml | 6 + packages/ui/user-interaction/README.md | 2 + packages/ui/user-interaction/README.zh.md | 39 ++++ packages/util/README.i18n.yaml | 6 + packages/util/README.md | 2 + packages/util/README.zh.md | 20 ++ packages/util/brand/README.i18n.yaml | 6 + packages/util/brand/README.md | 2 + packages/util/brand/README.zh.md | 28 +++ packages/util/paths/README.i18n.yaml | 6 + packages/util/paths/README.md | 2 + packages/util/paths/README.zh.md | 24 +++ packages/util/retention/README.i18n.yaml | 6 + packages/util/retention/README.md | 2 + packages/util/retention/README.zh.md | 97 +++++++++ packages/util/timeout/README.i18n.yaml | 6 + packages/util/timeout/README.md | 2 + packages/util/timeout/README.zh.md | 70 +++++++ packages/web/README.i18n.yaml | 6 + packages/web/README.md | 2 + packages/web/README.zh.md | 18 ++ packages/web/tool-web/README.i18n.yaml | 6 + packages/web/tool-web/README.md | 2 + packages/web/tool-web/README.zh.md | 131 ++++++++++++ packages/web/web-fetch-local/README.i18n.yaml | 6 + packages/web/web-fetch-local/README.md | 2 + packages/web/web-fetch-local/README.zh.md | 51 +++++ .../web/web-search-deepseek/README.i18n.yaml | 6 + packages/web/web-search-deepseek/README.md | 2 + packages/web/web-search-deepseek/README.zh.md | 79 +++++++ packages/web/web-search-exa/README.i18n.yaml | 6 + packages/web/web-search-exa/README.md | 2 + packages/web/web-search-exa/README.zh.md | 42 ++++ .../web-search-perplexity/README.i18n.yaml | 6 + packages/web/web-search-perplexity/README.md | 2 + .../web/web-search-perplexity/README.zh.md | 65 ++++++ packages/web/web/README.i18n.yaml | 6 + packages/web/web/README.md | 2 + packages/web/web/README.zh.md | 61 ++++++ packages/workflow/README.i18n.yaml | 6 + packages/workflow/README.md | 2 + packages/workflow/README.zh.md | 16 ++ packages/workflow/tool-ralph/README.i18n.yaml | 6 + packages/workflow/tool-ralph/README.md | 2 + packages/workflow/tool-ralph/README.zh.md | 93 +++++++++ .../workflow/tool-workflow/README.i18n.yaml | 6 + packages/workflow/tool-workflow/README.md | 2 + packages/workflow/tool-workflow/README.zh.md | 80 +++++++ .../workflow-workerthread/README.i18n.yaml | 6 + .../workflow/workflow-workerthread/README.md | 2 + .../workflow-workerthread/README.zh.md | 124 +++++++++++ packages/workflow/workflow/README.i18n.yaml | 6 + packages/workflow/workflow/README.md | 2 + packages/workflow/workflow/README.zh.md | 59 ++++++ packages/workspace/README.i18n.yaml | 6 + packages/workspace/README.md | 2 + packages/workspace/README.zh.md | 11 + packages/workspace/workspace/README.i18n.yaml | 6 + packages/workspace/workspace/README.md | 2 + packages/workspace/workspace/README.zh.md | 39 ++++ scripts/doc-budgets.manifest.json | 2 +- 559 files changed, 11227 insertions(+), 1 deletion(-) create mode 100644 apps/cli/README.i18n.yaml create mode 100644 apps/cli/README.zh.md create mode 100644 examples/README.i18n.yaml create mode 100644 examples/README.zh.md create mode 100644 examples/acp-agent/README.i18n.yaml create mode 100644 examples/acp-agent/README.zh.md create mode 100644 examples/cordis-agent/README.i18n.yaml create mode 100644 examples/cordis-agent/README.zh.md create mode 100644 examples/headless-agent/README.i18n.yaml create mode 100644 examples/headless-agent/README.zh.md create mode 100644 examples/jsonrpc-agent/README.i18n.yaml create mode 100644 examples/jsonrpc-agent/README.zh.md create mode 100644 examples/tui-agent/README.i18n.yaml create mode 100644 examples/tui-agent/README.zh.md create mode 100644 native/README.i18n.yaml create mode 100644 native/README.zh.md create mode 100644 native/landlock-run/README.i18n.yaml create mode 100644 native/landlock-run/README.zh.md create mode 100644 native/landlock-run/packages/entry/README.i18n.yaml create mode 100644 native/landlock-run/packages/entry/README.zh.md create mode 100644 native/landlock-run/packages/linux-arm64/README.i18n.yaml create mode 100644 native/landlock-run/packages/linux-arm64/README.zh.md create mode 100644 native/landlock-run/packages/linux-x64/README.i18n.yaml create mode 100644 native/landlock-run/packages/linux-x64/README.zh.md create mode 100644 packages/README.i18n.yaml create mode 100644 packages/README.zh.md create mode 100644 packages/acp/README.i18n.yaml create mode 100644 packages/acp/README.zh.md create mode 100644 packages/acp/acp/README.i18n.yaml create mode 100644 packages/acp/acp/README.zh.md create mode 100644 packages/bash/README.i18n.yaml create mode 100644 packages/bash/README.zh.md create mode 100644 packages/bash/bash-local/README.i18n.yaml create mode 100644 packages/bash/bash-local/README.zh.md create mode 100644 packages/bash/bash-sandbox/README.i18n.yaml create mode 100644 packages/bash/bash-sandbox/README.zh.md create mode 100644 packages/bash/bash/README.i18n.yaml create mode 100644 packages/bash/bash/README.zh.md create mode 100644 packages/bash/tool-bash/README.i18n.yaml create mode 100644 packages/bash/tool-bash/README.zh.md create mode 100644 packages/client/connection/README.i18n.yaml create mode 100644 packages/client/connection/README.zh.md create mode 100644 packages/client/hmr/README.i18n.yaml create mode 100644 packages/client/hmr/README.zh.md create mode 100644 packages/client/i18n/README.i18n.yaml create mode 100644 packages/client/i18n/README.zh.md create mode 100644 packages/client/modules/README.i18n.yaml create mode 100644 packages/client/modules/README.zh.md create mode 100644 packages/client/runtime/README.i18n.yaml create mode 100644 packages/client/runtime/README.zh.md create mode 100644 packages/client/ui-conversation/README.i18n.yaml create mode 100644 packages/client/ui-conversation/README.zh.md create mode 100644 packages/client/ui-layout/README.i18n.yaml create mode 100644 packages/client/ui-layout/README.zh.md create mode 100644 packages/client/ui-primitives/README.i18n.yaml create mode 100644 packages/client/ui-primitives/README.zh.md create mode 100644 packages/client/ui-question/README.i18n.yaml create mode 100644 packages/client/ui-question/README.zh.md create mode 100644 packages/client/ui-sidebar/README.i18n.yaml create mode 100644 packages/client/ui-sidebar/README.zh.md create mode 100644 packages/client/ui-slots/README.i18n.yaml create mode 100644 packages/client/ui-slots/README.zh.md create mode 100644 packages/client/ui-theme/README.i18n.yaml create mode 100644 packages/client/ui-theme/README.zh.md create mode 100644 packages/client/ui-trajectory/README.i18n.yaml create mode 100644 packages/client/ui-trajectory/README.zh.md create mode 100644 packages/client/ui-workspace/README.i18n.yaml create mode 100644 packages/client/ui-workspace/README.zh.md create mode 100644 packages/client/web-react/README.i18n.yaml create mode 100644 packages/client/web-react/README.zh.md create mode 100644 packages/client/web/README.i18n.yaml create mode 100644 packages/client/web/README.zh.md create mode 100644 packages/code-runtime/README.i18n.yaml create mode 100644 packages/code-runtime/README.zh.md create mode 100644 packages/code-runtime/code-runtime-worker/README.i18n.yaml create mode 100644 packages/code-runtime/code-runtime-worker/README.zh.md create mode 100644 packages/code-runtime/code-runtime/README.i18n.yaml create mode 100644 packages/code-runtime/code-runtime/README.zh.md create mode 100644 packages/compact/README.i18n.yaml create mode 100644 packages/compact/README.zh.md create mode 100644 packages/compact/compact-basic/README.i18n.yaml create mode 100644 packages/compact/compact-basic/README.zh.md create mode 100644 packages/compact/compact-tool-result-prune/README.i18n.yaml create mode 100644 packages/compact/compact-tool-result-prune/README.zh.md create mode 100644 packages/compact/compact/README.i18n.yaml create mode 100644 packages/compact/compact/README.zh.md create mode 100644 packages/context/README.i18n.yaml create mode 100644 packages/context/README.zh.md create mode 100644 packages/context/session-reference/README.i18n.yaml create mode 100644 packages/context/session-reference/README.zh.md create mode 100644 packages/context/time-context/README.i18n.yaml create mode 100644 packages/context/time-context/README.zh.md create mode 100644 packages/context/workspace-context/README.i18n.yaml create mode 100644 packages/context/workspace-context/README.zh.md create mode 100644 packages/cordis/README.i18n.yaml create mode 100644 packages/cordis/README.zh.md create mode 100644 packages/cordis/tool-cordis/README.i18n.yaml create mode 100644 packages/cordis/tool-cordis/README.zh.md create mode 100644 packages/core/README.i18n.yaml create mode 100644 packages/core/README.zh.md create mode 100644 packages/core/agent-loop/README.i18n.yaml create mode 100644 packages/core/agent-loop/README.zh.md create mode 100644 packages/core/agent/README.i18n.yaml create mode 100644 packages/core/agent/README.zh.md create mode 100644 packages/core/scope/README.i18n.yaml create mode 100644 packages/core/scope/README.zh.md create mode 100644 packages/core/session/README.i18n.yaml create mode 100644 packages/core/session/README.zh.md create mode 100644 packages/core/system-prompt/README.i18n.yaml create mode 100644 packages/core/system-prompt/README.zh.md create mode 100644 packages/core/tools/README.i18n.yaml create mode 100644 packages/core/tools/README.zh.md create mode 100644 packages/examples/README.i18n.yaml create mode 100644 packages/examples/README.zh.md create mode 100644 packages/examples/acp-demo/README.i18n.yaml create mode 100644 packages/examples/acp-demo/README.zh.md create mode 100644 packages/examples/agent-spine-demo/README.i18n.yaml create mode 100644 packages/examples/agent-spine-demo/README.zh.md create mode 100644 packages/examples/cli-demo/README.i18n.yaml create mode 100644 packages/examples/cli-demo/README.zh.md create mode 100644 packages/examples/jsonrpc-demo/README.i18n.yaml create mode 100644 packages/examples/jsonrpc-demo/README.zh.md create mode 100644 packages/examples/tui-demo/README.i18n.yaml create mode 100644 packages/examples/tui-demo/README.zh.md create mode 100644 packages/fs/README.i18n.yaml create mode 100644 packages/fs/README.zh.md create mode 100644 packages/fs/fs-local/README.i18n.yaml create mode 100644 packages/fs/fs-local/README.zh.md create mode 100644 packages/fs/fs-policy/README.i18n.yaml create mode 100644 packages/fs/fs-policy/README.zh.md create mode 100644 packages/fs/fs-sandbox/README.i18n.yaml create mode 100644 packages/fs/fs-sandbox/README.zh.md create mode 100644 packages/fs/fs/README.i18n.yaml create mode 100644 packages/fs/fs/README.zh.md create mode 100644 packages/fs/tool-fs-search/README.i18n.yaml create mode 100644 packages/fs/tool-fs-search/README.zh.md create mode 100644 packages/fs/tool-fs/README.i18n.yaml create mode 100644 packages/fs/tool-fs/README.zh.md create mode 100644 packages/goal/README.i18n.yaml create mode 100644 packages/goal/README.zh.md create mode 100644 packages/goal/command-goal/README.i18n.yaml create mode 100644 packages/goal/command-goal/README.zh.md create mode 100644 packages/goal/goal-session/README.i18n.yaml create mode 100644 packages/goal/goal-session/README.zh.md create mode 100644 packages/goal/goal/README.i18n.yaml create mode 100644 packages/goal/goal/README.zh.md create mode 100644 packages/goal/tool-goal/README.i18n.yaml create mode 100644 packages/goal/tool-goal/README.zh.md create mode 100644 packages/guard/README.i18n.yaml create mode 100644 packages/guard/README.zh.md create mode 100644 packages/guard/repeat-tool-guard/README.i18n.yaml create mode 100644 packages/guard/repeat-tool-guard/README.zh.md create mode 100644 packages/hooks/README.i18n.yaml create mode 100644 packages/hooks/README.zh.md create mode 100644 packages/hooks/hook-protocol/README.i18n.yaml create mode 100644 packages/hooks/hook-protocol/README.zh.md create mode 100644 packages/hooks/hooks-claude/README.i18n.yaml create mode 100644 packages/hooks/hooks-claude/README.zh.md create mode 100644 packages/hooks/hooks-codex/README.i18n.yaml create mode 100644 packages/hooks/hooks-codex/README.zh.md create mode 100644 packages/host/apiproxy/README.i18n.yaml create mode 100644 packages/host/apiproxy/README.zh.md create mode 100644 packages/host/webserver/README.i18n.yaml create mode 100644 packages/host/webserver/README.zh.md create mode 100644 packages/llm/README.i18n.yaml create mode 100644 packages/llm/README.zh.md create mode 100644 packages/llm/llm-deepseek/README.i18n.yaml create mode 100644 packages/llm/llm-deepseek/README.zh.md create mode 100644 packages/llm/llm-pi-ai/README.i18n.yaml create mode 100644 packages/llm/llm-pi-ai/README.zh.md create mode 100644 packages/llm/llm-retry/README.i18n.yaml create mode 100644 packages/llm/llm-retry/README.zh.md create mode 100644 packages/llm/llm/README.i18n.yaml create mode 100644 packages/llm/llm/README.zh.md create mode 100644 packages/llm/token-meter/README.i18n.yaml create mode 100644 packages/llm/token-meter/README.zh.md create mode 100644 packages/lsp/README.i18n.yaml create mode 100644 packages/lsp/README.zh.md create mode 100644 packages/lsp/lsp-local/README.i18n.yaml create mode 100644 packages/lsp/lsp-local/README.zh.md create mode 100644 packages/lsp/lsp/README.i18n.yaml create mode 100644 packages/lsp/lsp/README.zh.md create mode 100644 packages/lsp/tool-lsp/README.i18n.yaml create mode 100644 packages/lsp/tool-lsp/README.zh.md create mode 100644 packages/mcp/README.i18n.yaml create mode 100644 packages/mcp/README.zh.md create mode 100644 packages/mcp/mcp-client/README.i18n.yaml create mode 100644 packages/mcp/mcp-client/README.zh.md create mode 100644 packages/plan/README.i18n.yaml create mode 100644 packages/plan/README.zh.md create mode 100644 packages/plan/plan-mode/README.i18n.yaml create mode 100644 packages/plan/plan-mode/README.zh.md create mode 100644 packages/pty/README.i18n.yaml create mode 100644 packages/pty/README.zh.md create mode 100644 packages/pty/pty-local/README.i18n.yaml create mode 100644 packages/pty/pty-local/README.zh.md create mode 100644 packages/pty/pty/README.i18n.yaml create mode 100644 packages/pty/pty/README.zh.md create mode 100644 packages/pty/tool-pty/README.i18n.yaml create mode 100644 packages/pty/tool-pty/README.zh.md create mode 100644 packages/sandbox/README.i18n.yaml create mode 100644 packages/sandbox/README.zh.md create mode 100644 packages/sandbox/sandbox-local/README.i18n.yaml create mode 100644 packages/sandbox/sandbox-local/README.zh.md create mode 100644 packages/sandbox/sandbox-policy/README.i18n.yaml create mode 100644 packages/sandbox/sandbox-policy/README.zh.md create mode 100644 packages/sandbox/sandbox/README.i18n.yaml create mode 100644 packages/sandbox/sandbox/README.zh.md create mode 100644 packages/sdk/README.i18n.yaml create mode 100644 packages/sdk/README.zh.md create mode 100644 packages/sdk/create-sdk/README.i18n.yaml create mode 100644 packages/sdk/create-sdk/README.zh.md create mode 100644 packages/sdk/helper/README.i18n.yaml create mode 100644 packages/sdk/helper/README.zh.md create mode 100644 packages/sdk/scripts/README.i18n.yaml create mode 100644 packages/sdk/scripts/README.zh.md create mode 100644 packages/sdk/telemetry/README.i18n.yaml create mode 100644 packages/sdk/telemetry/README.zh.md create mode 100644 packages/session-persistence/README.i18n.yaml create mode 100644 packages/session-persistence/README.zh.md create mode 100644 packages/session-persistence/session-checkpoint-policy/README.i18n.yaml create mode 100644 packages/session-persistence/session-checkpoint-policy/README.zh.md create mode 100644 packages/session-persistence/session-persistence-jsonl/README.i18n.yaml create mode 100644 packages/session-persistence/session-persistence-jsonl/README.zh.md create mode 100644 packages/session-persistence/session-persistence-sqlite/README.i18n.yaml create mode 100644 packages/session-persistence/session-persistence-sqlite/README.zh.md create mode 100644 packages/session-persistence/session-persistence/README.i18n.yaml create mode 100644 packages/session-persistence/session-persistence/README.zh.md create mode 100644 packages/session-query/README.i18n.yaml create mode 100644 packages/session-query/README.zh.md create mode 100644 packages/session-query/session-query-sqlite/README.i18n.yaml create mode 100644 packages/session-query/session-query-sqlite/README.zh.md create mode 100644 packages/session-query/session-query/README.i18n.yaml create mode 100644 packages/session-query/session-query/README.zh.md create mode 100644 packages/session-query/tool-session-query/README.i18n.yaml create mode 100644 packages/session-query/tool-session-query/README.zh.md create mode 100644 packages/session-title/README.i18n.yaml create mode 100644 packages/session-title/README.zh.md create mode 100644 packages/session-title/session-title-all-messages-llm/README.i18n.yaml create mode 100644 packages/session-title/session-title-all-messages-llm/README.zh.md create mode 100644 packages/session-title/session-title-first-message-llm/README.i18n.yaml create mode 100644 packages/session-title/session-title-first-message-llm/README.zh.md create mode 100644 packages/session-title/session-title-llm/README.i18n.yaml create mode 100644 packages/session-title/session-title-llm/README.zh.md create mode 100644 packages/session-title/session-title/README.i18n.yaml create mode 100644 packages/session-title/session-title/README.zh.md create mode 100644 packages/skill/README.i18n.yaml create mode 100644 packages/skill/README.zh.md create mode 100644 packages/skill/skill-local/README.i18n.yaml create mode 100644 packages/skill/skill-local/README.zh.md create mode 100644 packages/skill/skill/README.i18n.yaml create mode 100644 packages/skill/skill/README.zh.md create mode 100644 packages/skill/tool-skill/README.i18n.yaml create mode 100644 packages/skill/tool-skill/README.zh.md create mode 100644 packages/spill/README.i18n.yaml create mode 100644 packages/spill/README.zh.md create mode 100644 packages/spill/spill-local/README.i18n.yaml create mode 100644 packages/spill/spill-local/README.zh.md create mode 100644 packages/spill/spill-policy/README.i18n.yaml create mode 100644 packages/spill/spill-policy/README.zh.md create mode 100644 packages/spill/spill/README.i18n.yaml create mode 100644 packages/spill/spill/README.zh.md create mode 100644 packages/storage/README.i18n.yaml create mode 100644 packages/storage/README.zh.md create mode 100644 packages/storage/storage-domain/README.i18n.yaml create mode 100644 packages/storage/storage-domain/README.zh.md create mode 100644 packages/storage/storage-json/README.i18n.yaml create mode 100644 packages/storage/storage-json/README.zh.md create mode 100644 packages/storage/storage-sqlite/README.i18n.yaml create mode 100644 packages/storage/storage-sqlite/README.zh.md create mode 100644 packages/storage/storage/README.i18n.yaml create mode 100644 packages/storage/storage/README.zh.md create mode 100644 packages/subagent/README.i18n.yaml create mode 100644 packages/subagent/README.zh.md create mode 100644 packages/subagent/subagent-acp/README.i18n.yaml create mode 100644 packages/subagent/subagent-acp/README.zh.md create mode 100644 packages/subagent/subagent-fork/README.i18n.yaml create mode 100644 packages/subagent/subagent-fork/README.zh.md create mode 100644 packages/subagent/subagent-inprocess/README.i18n.yaml create mode 100644 packages/subagent/subagent-inprocess/README.zh.md create mode 100644 packages/subagent/subagent-spawn/README.i18n.yaml create mode 100644 packages/subagent/subagent-spawn/README.zh.md create mode 100644 packages/subagent/subagent-subprocess/README.i18n.yaml create mode 100644 packages/subagent/subagent-subprocess/README.zh.md create mode 100644 packages/subagent/subagent/README.i18n.yaml create mode 100644 packages/subagent/subagent/README.zh.md create mode 100644 packages/subagent/tool-subagent/README.i18n.yaml create mode 100644 packages/subagent/tool-subagent/README.zh.md create mode 100644 packages/support/README.i18n.yaml create mode 100644 packages/support/README.zh.md create mode 100644 packages/support/acp-snapshot/README.i18n.yaml create mode 100644 packages/support/acp-snapshot/README.zh.md create mode 100644 packages/support/agent-loop-testkit/README.i18n.yaml create mode 100644 packages/support/agent-loop-testkit/README.zh.md create mode 100644 packages/support/invariants/README.i18n.yaml create mode 100644 packages/support/invariants/README.zh.md create mode 100644 packages/support/llm-mock-server/README.i18n.yaml create mode 100644 packages/support/llm-mock-server/README.zh.md create mode 100644 packages/support/llm-replay/README.i18n.yaml create mode 100644 packages/support/llm-replay/README.zh.md create mode 100644 packages/support/loader-smoke/README.i18n.yaml create mode 100644 packages/support/loader-smoke/README.zh.md create mode 100644 packages/tasks/README.i18n.yaml create mode 100644 packages/tasks/README.zh.md create mode 100644 packages/tasks/tasks/README.i18n.yaml create mode 100644 packages/tasks/tasks/README.zh.md create mode 100644 packages/tasks/tool-tasks/README.i18n.yaml create mode 100644 packages/tasks/tool-tasks/README.zh.md create mode 100644 packages/timeout/README.i18n.yaml create mode 100644 packages/timeout/README.zh.md create mode 100644 packages/timeout/timeout-policy/README.i18n.yaml create mode 100644 packages/timeout/timeout-policy/README.zh.md create mode 100644 packages/todo/README.i18n.yaml create mode 100644 packages/todo/README.zh.md create mode 100644 packages/todo/tool-todo/README.i18n.yaml create mode 100644 packages/todo/tool-todo/README.zh.md create mode 100644 packages/ui/README.i18n.yaml create mode 100644 packages/ui/README.zh.md create mode 100644 packages/ui/app-boot/README.i18n.yaml create mode 100644 packages/ui/app-boot/README.zh.md create mode 100644 packages/ui/commands/README.i18n.yaml create mode 100644 packages/ui/commands/README.zh.md create mode 100644 packages/ui/jsonrpc/README.i18n.yaml create mode 100644 packages/ui/jsonrpc/README.zh.md create mode 100644 packages/ui/permission/README.i18n.yaml create mode 100644 packages/ui/permission/README.zh.md create mode 100644 packages/ui/tool-ask-user/README.i18n.yaml create mode 100644 packages/ui/tool-ask-user/README.zh.md create mode 100644 packages/ui/tui/README.i18n.yaml create mode 100644 packages/ui/tui/README.zh.md create mode 100644 packages/ui/user-approval/README.i18n.yaml create mode 100644 packages/ui/user-approval/README.zh.md create mode 100644 packages/ui/user-interaction/README.i18n.yaml create mode 100644 packages/ui/user-interaction/README.zh.md create mode 100644 packages/util/README.i18n.yaml create mode 100644 packages/util/README.zh.md create mode 100644 packages/util/brand/README.i18n.yaml create mode 100644 packages/util/brand/README.zh.md create mode 100644 packages/util/paths/README.i18n.yaml create mode 100644 packages/util/paths/README.zh.md create mode 100644 packages/util/retention/README.i18n.yaml create mode 100644 packages/util/retention/README.zh.md create mode 100644 packages/util/timeout/README.i18n.yaml create mode 100644 packages/util/timeout/README.zh.md create mode 100644 packages/web/README.i18n.yaml create mode 100644 packages/web/README.zh.md create mode 100644 packages/web/tool-web/README.i18n.yaml create mode 100644 packages/web/tool-web/README.zh.md create mode 100644 packages/web/web-fetch-local/README.i18n.yaml create mode 100644 packages/web/web-fetch-local/README.zh.md create mode 100644 packages/web/web-search-deepseek/README.i18n.yaml create mode 100644 packages/web/web-search-deepseek/README.zh.md create mode 100644 packages/web/web-search-exa/README.i18n.yaml create mode 100644 packages/web/web-search-exa/README.zh.md create mode 100644 packages/web/web-search-perplexity/README.i18n.yaml create mode 100644 packages/web/web-search-perplexity/README.zh.md create mode 100644 packages/web/web/README.i18n.yaml create mode 100644 packages/web/web/README.zh.md create mode 100644 packages/workflow/README.i18n.yaml create mode 100644 packages/workflow/README.zh.md create mode 100644 packages/workflow/tool-ralph/README.i18n.yaml create mode 100644 packages/workflow/tool-ralph/README.zh.md create mode 100644 packages/workflow/tool-workflow/README.i18n.yaml create mode 100644 packages/workflow/tool-workflow/README.zh.md create mode 100644 packages/workflow/workflow-workerthread/README.i18n.yaml create mode 100644 packages/workflow/workflow-workerthread/README.zh.md create mode 100644 packages/workflow/workflow/README.i18n.yaml create mode 100644 packages/workflow/workflow/README.zh.md create mode 100644 packages/workspace/README.i18n.yaml create mode 100644 packages/workspace/README.zh.md create mode 100644 packages/workspace/workspace/README.i18n.yaml create mode 100644 packages/workspace/workspace/README.zh.md diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml new file mode 100644 index 0000000000..9769d5153b --- /dev/null +++ b/apps/cli/README.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 +README.md: 83b60ea72580facbceb155d053223567fdc8446b +README.zh.md: ca25547f4c5ed2627e692187e6ca9e947f1b3eac diff --git a/apps/cli/README.md b/apps/cli/README.md index 4ff9034dd3..83b60ea725 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh` +English | [中文](README.zh.md) + The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md new file mode 100644 index 0000000000..ca25547f4c --- /dev/null +++ b/apps/cli/README.zh.md @@ -0,0 +1,27 @@ +# `@deepseek-ai/dsh` + +[English](README.md) | 中文 + +`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。 + +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。 + +TUI 界面: + +- 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; +- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume ` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话; +- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析; +- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; +- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 + +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 + +## 安装(开发机) + +将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout,因此代码更改会在下次启动时生效,无需构建: + +```sh +ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh +``` + +`pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。 diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml new file mode 100644 index 0000000000..a96133c3c8 --- /dev/null +++ b/examples/README.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 +README.md: 8fd261e624771dc568582b6d22e9985072a06715 +README.zh.md: 2ff0d0bef382435588fce01c23aa7b74e1a149b5 diff --git a/examples/README.md b/examples/README.md index f1f3967634..8fd261e624 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,7 @@ # Examples +English | [中文](README.zh.md) + Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI (which mounts the `tui-demo` bundle), and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. ## headless-agent diff --git a/examples/README.zh.md b/examples/README.zh.md new file mode 100644 index 0000000000..2ff0d0bef3 --- /dev/null +++ b/examples/README.zh.md @@ -0,0 +1,35 @@ +# 示例 + +[English](README.md) | 中文 + +展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 + +## headless-agent + +非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 + +运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 + +## tui-agent + +交互式编码 agent:DeepSeek V4、文件系统与 bash 工具、subagent、工作流、`todo_write`、压缩(compaction)和全屏 TUI。这里也是 TUI PTY 与快照场景的归属地。 + +运行:`pnpm run demo:tui`(需要 `DEEPSEEK_API_KEY`)。使用 `pnpm run demo:code-mode` 运行其 Code Mode 覆盖。控制与组合详见 [tui-agent/README.md](tui-agent/README.md)。 + +## jsonrpc-agent + +通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 + +## cordis-agent + +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查自身所在的实时 cordis 运行时,将模型编写的插件挂载到其中(事件监听器、一个专为自身创建的全新工具,或一个供另一挂载项注入的服务),并再次释放它们。所有动态挂载都归入同一 `cordis-dynamic` fiber 子树。`ctx.fs`/`ctx.web` 服务仅作为提供方随行,是这些插件构建所依赖的能力。 + +运行:`pnpm run demo:cordis`(需要 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 + +## acp-agent + +作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 + +运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode acp` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 + +默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。 diff --git a/examples/acp-agent/README.i18n.yaml b/examples/acp-agent/README.i18n.yaml new file mode 100644 index 0000000000..22842fcd04 --- /dev/null +++ b/examples/acp-agent/README.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 +README.md: 4b3d86b00613cc7c37a8898ef3b39d40a167e66b +README.zh.md: 5bcd85f2b4ae34a11b980bf196d3401f764004d8 diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 892a32cdc7..4b3d86b006 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -1,5 +1,7 @@ # acp-agent example +English | [中文](README.zh.md) + Automation-oriented [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. It is intended for parent agents, subagent providers, and other programmatic clients, not as the product UI. ```sh diff --git a/examples/acp-agent/README.zh.md b/examples/acp-agent/README.zh.md new file mode 100644 index 0000000000..5bcd85f2b4 --- /dev/null +++ b/examples/acp-agent/README.zh.md @@ -0,0 +1,30 @@ +# acp-agent 示例 + +[English](README.md) | 中文 + +通过 JSON-RPC stdio 提供的自动化导向 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。它面向父 agent(智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。 + +```sh +pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) +pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport +``` + +该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 则添加 `run_code` 及其生成的 TypeScript SDK。 + +## 协议通道 + +Stdout 只携带以换行分隔的 ACP JSON-RPC。`@deepseek-ai/dsh-acp-demo` 不安装 stdout logger;叶节点的附加项必须使用 stderr 输出诊断信息。 + +自动化契约(支持的方法、基线提示词内容、已提交文本输出,以及有意缺少的 UI 界面)位于 [`@deepseek-ai/dsh-acp`](../../packages/acp/acp/README.md)。 + +## 会话 workspace 与权限 + +每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 与文件系统变更会根据该会话 cwd 解析 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 在部署和测试中选择 `workspace-write` 或 `danger-full-access`。 + +在 `workspace-write` 下,模型请求扩大沙箱权限的重试会触发 `session/request_permission`,选项为 `allow_once` 和 `reject_once`。客户端以程序方式决策;解除对话框或答案不可用时会失败闭合。选定结果仅适用于该次重试,并通过常规工具结果/审计路径记录。服务器绝不公开权限选择器,也不持久化客户端策略。 + +## 快照测试 + +此示例拥有 ACP 快照套件。它会启动真实自动化服务器,通过 `dsh-llm-replay` 回放已提交的模型流,并比较规范化后的协议输出与重新持久化的会话日志。录制使用真实模型;刷新会复用已提交的回放输入。覆盖场景包括抛出/挂起行为,可选 `workspace/` fixture(测试前置数据)则为外部状态检查预置环境。 + +大多数场景锁定后端行为,而非 ACP 专用行为;[仅面向自动化的 ACP 决策](../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)说明了为何该覆盖仍与传输层耦合。 diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml new file mode 100644 index 0000000000..7f4f2ae9dd --- /dev/null +++ b/examples/cordis-agent/README.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 +README.md: 1309fe9b2935d3224f097ceb2e80501c8075a933 +README.zh.md: 2e3e7d7206d0d676ae7d9c9b3a2c2f8be26aafe7 diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index a0bb2188da..1309fe9b29 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -1,5 +1,7 @@ # cordis-agent +English | [中文](README.zh.md) + The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md new file mode 100644 index 0000000000..2e3e7d7206 --- /dev/null +++ b/examples/cordis-agent/README.zh.md @@ -0,0 +1,35 @@ +# cordis-agent + +[English](README.md) | 中文 + +自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者通过 agent(智能体)所在的 **实时 cordis 运行时** 向模型提供三个工具:检查运行时、将新插件挂载到其中,以及再次释放它们。`ctx.fs` 和 `ctx.web` 服务也会挂载(仅作为提供方,不包含面向模型的文件/Web 工具),使 agent 编写的插件可以构建于真实能力之上;Node 内置模块在沙箱中被截获并重定向到这些服务。设计(沙箱语义、挂载生命周期、跨挂载组合、注意事项)详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 + +## 运行 + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:cordis +``` + +预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: + +``` +> Mount a plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. + [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) + [tool result] mounted dyn-1 (plugin "status-logger", state: active) + [tool call] bash({"command": "echo hi"}) +[cordis:dyn-1] status → … ← the mounted listener firing, live +> Now give yourself a reverse_text tool and use it on "harness". + [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) + [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier +> Unmount both. + [tool call] cordis_unmount({"id": "dyn-1"}) +``` + +请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看为 agent 生成、供其编写插件时参考的服务/事件资料。还可尝试两个协作挂载(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 cordis 如何暂停并恢复消费方。 + +## 端到端测试 + +`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载状态监听器,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个挂载。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml new file mode 100644 index 0000000000..7c4dbaf463 --- /dev/null +++ b/examples/headless-agent/README.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 +README.md: 445804a2611e5e8093eadf345ad10a2a7984c012 +README.zh.md: 68ec718afe0b2aca276be2689cbae74167ee1c7b diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index a1e2455ed3..445804a261 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -1,5 +1,7 @@ # headless-agent +English | [中文](README.zh.md) + Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door. ## Run it diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md new file mode 100644 index 0000000000..68ec718afe --- /dev/null +++ b/examples/headless-agent/README.zh.md @@ -0,0 +1,26 @@ +# headless-agent + +[English](README.md) | 中文 + +无头单次 agent(智能体)接线:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与新 agent Ralph 迭代 + `todo_write` + JSONL 持久化,并以 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) 作为应用入口。 + +## 运行 + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:headless "fix the failing test in this workspace" +pnpm run demo:headless --output-format json -- "summarize the implementation" +pnpm run demo:headless --output-format stream-json -- "run the focused tests" +``` + +必须提供且只能提供一个非空位置任务;含空格的任务需要加引号。没有 `-p` 标志。`text` 打印最后一条包含文本的 assistant 消息,`json` 打印一条 DSH 原生结果记录,`stream-json` 则在该记录之前发出顶层会话的规范任务轮次事件。子会话只通过父工具事件和结果对外显示。 + +每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷新、释放并退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动 workspace、运行命令、spawn 子 agent,并消耗提供方 token。 + +## 高级与快照接线 + +[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。[`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) 只将实时 LLM(大语言模型)替换为回放。[`tests/`](tests/) 下的测试拥有无密钥真实 Loader 冒烟测试、密钥门控的外部状态验证冒烟测试,以及带父子会话 fixture(测试前置数据)的 `stream-json` 回放快照。 + +包级 [CLI 契约](../../packages/examples/cli-demo/README.md)记录输出记录、退出状态、取消、持久化以及模型/token 影响。 diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml new file mode 100644 index 0000000000..59c18e0131 --- /dev/null +++ b/examples/jsonrpc-agent/README.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 +README.md: 6ee4e9d824315bde76b7a534679f018df9a6d3e8 +README.zh.md: dc9b6233e7074e7a9b13bf10bcd2f310b0ad7bf3 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 83c6e98fa5..6ee4e9d824 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -1,5 +1,7 @@ # jsonrpc-agent +English | [中文](README.zh.md) + The unattended coding-agent composition for the Python SDK's bundled JSON-RPC runtime. It intentionally loads no terminal UI, console logger, approval surface, or user-interaction tool because stdout belongs to the SDK protocol and turns are driven by the SDK. The model-facing tools are: diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md new file mode 100644 index 0000000000..dc9b6233e7 --- /dev/null +++ b/examples/jsonrpc-agent/README.zh.md @@ -0,0 +1,27 @@ +# jsonrpc-agent + +[English](README.md) | 中文 + +面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、console logger、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。 + +面向模型的工具为: + +- `bash`,仅前台 +- `read`、`write` 和 `edit` +- `subagent`,使用一个前台进程内 spawn 提供方 +- `todo_write` + +周边运行时还加载 JSONL 会话持久化和自动上下文压缩(compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。 + +## 运行时环境 + +| 变量 | 用途 | +|---|---| +| `DEEPSEEK_API_KEY` | 传给 OpenAI 兼容宿主端点的凭据 | +| `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 | +| `DSH_CWD` | bash 和文件系统工具使用的 agent workspace | +| `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 | +| `DSH_SESSION_ROOT` | JSONL 轨迹目录 | +| `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | + +通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。 diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml new file mode 100644 index 0000000000..631848bb77 --- /dev/null +++ b/examples/tui-agent/README.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 +README.md: fdf3972f98f8ff9690b71469ae415dc18d339f10 +README.zh.md: 71f7ae949d034757a20adfae2cbe566011edc584 diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 5196de053b..fdf3972f98 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -1,5 +1,7 @@ # tui-agent +English | [中文](README.zh.md) + The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, plan mode (`/plan` enters and `exit_plan_mode` reviews the exit), timeout/spill policy, and JSONL persistence through [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), loaded from `cordis.yml`. The sibling [`headless-agent`](../headless-agent/README.md) runs the same capability class as a one-shot pipe-friendly task, and [`acp-agent`](../acp-agent/README.md) serves it over JSON-RPC. ## Run it diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md new file mode 100644 index 0000000000..71f7ae949d --- /dev/null +++ b/examples/tui-agent/README.zh.md @@ -0,0 +1,80 @@ +# tui-agent + +[English](README.md) | 中文 + +全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与新 agent Ralph 迭代、plan mode(`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合单次管道的任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。 + +## 运行 + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:tui +``` + +演示脚本和可安装的 `dsh` CLI([`apps/cli`](../../apps/cli/README.md))都会作为已交付的默认配置启动此示例的 `cordis.yml`;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为 workspace。 + +输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次操作都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。fs 工具和 bash 都会根据会话 workspace 解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。 + +`todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 + +TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 和 Enter,或使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 + +### 恢复早先的会话 + +每次运行默认都会启动新会话(其事件日志落在 `./.sessions/` 下)。如需 **继续** 先前对话,请将其 id 传给已安装的 `dsh` CLI:此时 `main` agent 会重新水化持久日志,而不会从头开始,因此模型会将早先轮次视为历史: + +```sh +dsh --resume +``` + +`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久 goal 阶段和实时/已持久化状态。已安装的 `dsh` 宿主会刷新并释放当前应用,然后以 `dsh --resume ` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume ` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。 + +## Code Mode + +[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 + +```sh +pnpm run demo:code-mode # this overlay under the TUI (default UI) +pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay +``` + +尝试一项横跨多个工具调用的任务,例如: + +> 统计 docs/ 下每个 `*.md` 文件的行数,并将最大的三个写入 summary.txt。 + +然后观察 transcript(文本记录):一次 `run_code` 调用、一个循环调用工具的程序,以及模型筛选后的结果,而不是五次原始工具输出往返。 + +## 每个叶节点配置项所演示的内容 + +此示例是轻量叶节点 `cordis.yml`:它选择可替换后端、加载一个应用包(package),并添加有意放在共享主干外的产品工具。主干(会话、系统提示词、工具、agent、不变式、`agent-loop`)和入口集群(JSONL 持久化、pi-tui 通道、预创建的 `main` agent)位于 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 应用及其加载的 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 组合包中;叶节点负责接线后端与面向模型的可选工具: + +| 配置项 | 演示内容 | +|---|---| +| `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | +| `llm-deepseek` | 通过配置提供真实 `LlmAdapter`(`!!js process.env.…` 密钥);将一行替换为 `@deepseek-ai/dsh-llm-pi-ai` 即可使用库后端对照实现 | +| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 的可替换一半。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | +| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | +| `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | +| `tool-subagent`, `tool-subagent-fork` | 两次面向模型的 `dsh-tool-subagent` 加载,每次绑定不同提供方,并以不同工具名(`subagent`、`subagent_fork`)公开 | +| `workflow-workerthread`, `tool-workflow` | worker 线程工作流引擎及其面向模型的 `workflow` 工具,子调用通过 spawn 后端路由 | +| `plan-mode` | 插件拥有的 `/plan [message]` 进入命令和 `/plan off` 退出命令、plan-mode 提示词策略、工具限制,以及经评审的 `exit_plan_mode` 转换 | +| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径根据会话 workspace 解析 | + +## 端到端测试(`pnpm run test:e2e`) + +与 UI 无关的带密钥套件通过 `tests/harness.ts` 以程序方式组装完整栈(无 PTY、无 Loader): + +- `tests/full-loop.e2e.ts`:canary 测试:真实模型通过真实 bash 工具运行 `echo e2e-ok`;断言 `tool/call`/`tool/result` 会话事件和最终答案。 +- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的声称。 +- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后释放整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。 +- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,表层缩减(替换节点遮蔽旧节点),且 agent 在压缩后仍给出正确最终答案。 +- `tests/todo-write.e2e.ts`:加载选用 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。 +- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言线上工具列表精确为 `[run_code]`,`tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。 + +这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM 对话,Code Mode 覆盖欢迎行,以及恢复失败退出路径。 + +## 快照测试 + +`tests/snapshots//session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。使用 `pnpm run test:snapshot:refresh` 刷新仅展示变更;已录制模型旅程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 拥有场景矩阵,以及已录制旅程、瞬时包快照与 PTY 覆盖之间的分工。 diff --git a/native/README.i18n.yaml b/native/README.i18n.yaml new file mode 100644 index 0000000000..4013fc656d --- /dev/null +++ b/native/README.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 +README.md: 84808b2ee9dafa4f9f980c35a81ebe12480a4f5d +README.zh.md: f73d4176454d9a577bf674a6bfe3f15cce3402b4 diff --git a/native/README.md b/native/README.md index 983e67f740..84808b2ee9 100644 --- a/native/README.md +++ b/native/README.md @@ -1,5 +1,7 @@ # native/ +English | [中文](README.zh.md) + Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher the harness consumes from npm (`packages/sandbox/sandbox-local`, `packages/bash/bash-sandbox`). Launcher development happens HERE, next to the consumers; the standalone repository is the release mirror that packs and publishes the npm package family. ## Release mirror diff --git a/native/README.zh.md b/native/README.zh.md new file mode 100644 index 0000000000..f73d417645 --- /dev/null +++ b/native/README.zh.md @@ -0,0 +1,22 @@ +# native/ + +[English](README.md) | 中文 + +`node-addon-landlock-run` 的记录真源:这是 harness 从 npm 消费的 Landlock「先限制自身、再执行」启动器(`packages/sandbox/sandbox-local`、`packages/bash/bash-sandbox`)。启动器在此处开发,与消费方相邻;独立仓库是打包并发布 npm 包系列的发布镜像。 + +## 发布镜像 + +| 目录 | 镜像仓库 | 上次导出的发布版 | Commit | +|---|---|---|---| +| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | + +该子树是一个自包含的 pnpm workspace,拥有自己的 `AGENTS.md`、文档、门禁和锁文件;它不属于 harness workspace(`pnpm-workspace.yaml` 不包含它),因此 harness 的安装、构建和 CI 门禁绝不会触及它。镜像的 `.github/` 不进入该子树;[.github/workflows/landlock-run.yml](../.github/workflows/landlock-run.yml)(手动触发)在此处运行子树的 CI 任务,对这些任务的更改会在下次导出时镜像到镜像仓库的 `ci.yml`。 + +## 导出流程(发布新版本) + +1. 先通过常规 harness PR 将启动器更改落地于此;触发 `Landlock Run` 工作流,并确保其所有任务通过。 +2. 在镜像 checkout 中替换 `.github/` 以外的所有内容:`git -C rm -rq -- . ':!.github'`,然后执行 `git -C archive HEAD:native/landlock-run | tar -x -C `,最后执行 `git -C add -A` 并提交。 +3. 在镜像中按照其发布清单(`docs/release.md`)操作:`pnpm release:commit ` → 合并 → 标记 `vX.Y.Z` → 两阶段 `Release` 工作流(先以 `publish=false` 预演,再从标签以 `publish=true` 发布)。 +4. 使用已发布的标签/commit 更新上方 manifest(元数据清单)表,并在同一更改中提升 harness 消费方的依赖范围。 + +镜像不得分叉:如果更改直接提交到镜像中(例如发布期间的热修复),必须在下次导出前将其移植回此处。 diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml new file mode 100644 index 0000000000..7212b6a317 --- /dev/null +++ b/native/landlock-run/README.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 +README.md: 284d5df764cf5a5205973696211aee2366d3b76e +README.zh.md: 7163314abac0362afccee6fcc4506a84701cc27a diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md index 2bb92843e6..284d5df764 100644 --- a/native/landlock-run/README.md +++ b/native/landlock-run/README.md @@ -1,5 +1,7 @@ # node-addon-landlock-run +English | [中文](README.zh.md) + A [Landlock](https://landlock.io/) self-restrict-then-exec launcher for confining subprocesses on Linux, distributed as prebuilt per-platform npm packages plus a thin JS entry package that resolves the binary and speaks its CLI contract. Built for agent harnesses and other hosts that need to run untrusted commands under a filesystem allow-list without confining themselves. The first tool is **`landlock-run`** — a self-restrict-then-exec [Landlock](https://landlock.io/) launcher (~300 lines of C11 over the raw kernel UAPI, statically linked against musl). It installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the command and every process it spawns run confined while the invoking process stays unrestricted. Fail-closed: if the kernel cannot enforce, it exits without running the command. diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md new file mode 100644 index 0000000000..7163314aba --- /dev/null +++ b/native/landlock-run/README.zh.md @@ -0,0 +1,60 @@ +# node-addon-landlock-run + +[English](README.md) | 中文 + +一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以每平台预构建 npm 包加一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要在文件系统允许清单下运行不可信命令、但不能限制自身的 agent harness 和其他宿主。 + +第一个工具是 **`landlock-run`**:一个「先限制自身、再执行」的 [Landlock](https://landlock.io/) 启动器(基于原始内核 UAPI 编写,约 300 行 C11,并与 musl 静态链接)。它在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此命令及其产生的每个进程都在限制下运行,调用进程仍不受限制。它采用失败闭合:如果内核无法强制执行,则不运行命令并直接退出。 + +## 安装 + +```sh +npm install node-addon-landlock-run +``` + +已发布包由一个入口包和可选平台包组成: + +```text +node-addon-landlock-run +node-addon-landlock-run-linux-x64 +node-addon-landlock-run-linux-arm64 +``` + +npm 的 `os`/`cpu` 字段使安装器只拉取匹配的平台包。系统有意不提供安装时构建回退:在没有对应平台包的宿主上,解析后的路径绝不存在,探测会报告 `unusable`,消费方以失败闭合方式处理。 + +## 用法 + +```js +import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; + +const launcher = launcherPath(); +if (probe(launcher) !== 'unusable') { + const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; + // spawn argv with your process runner of choice +} +``` + +公开 API 有意保持简小: + +- `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。 +- `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`。 +- `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。 +- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT` (125):契约常量。 + +完整的二进制契约(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。 + +## 支持范围 + +支持 linux-x64 和 linux-arm64,且内核已启用 Landlock(5.13+;ABI 级别决定强制执行为 `full` 还是 `partial`,详见 [docs/support-matrix.md](docs/support-matrix.md))。其他平台有意不提供对应包:消费方会在这些平台上运行其他限制后端。 + +## 开发 + +```sh +corepack enable +pnpm install +pnpm build:ts # entry packages → lib/ +pnpm build:native # this Linux architecture's binaries (apt-get install musl-tools) +pnpm test +``` + +二进制文件被 git 忽略,并且按架构原生构建:本地只构建当前机器的版本,CI 的每架构 runner 则是记录中的构建者。发布流程详见 [docs/release.md](docs/release.md)。 diff --git a/native/landlock-run/packages/entry/README.i18n.yaml b/native/landlock-run/packages/entry/README.i18n.yaml new file mode 100644 index 0000000000..2985be0419 --- /dev/null +++ b/native/landlock-run/packages/entry/README.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 +README.md: e402cdfe71c4eb81b977a21955fe3fff6bf55fd3 +README.zh.md: 03dd18969d8e5c94be31605201b74b126ae5c06d diff --git a/native/landlock-run/packages/entry/README.md b/native/landlock-run/packages/entry/README.md index 789b1ddf6b..e402cdfe71 100644 --- a/native/landlock-run/packages/entry/README.md +++ b/native/landlock-run/packages/entry/README.md @@ -1,5 +1,7 @@ # node-addon-landlock-run +English | [中文](README.zh.md) + Landlock self-restrict-then-exec launcher for confining subprocesses on Linux: this entry package resolves the per-platform prebuilt binary, runs its functional enforcement probe, and builds its grant argv — consumers never spell launcher flags or parse launcher output themselves. ```js diff --git a/native/landlock-run/packages/entry/README.zh.md b/native/landlock-run/packages/entry/README.zh.md new file mode 100644 index 0000000000..03dd18969d --- /dev/null +++ b/native/landlock-run/packages/entry/README.zh.md @@ -0,0 +1,18 @@ +# node-addon-landlock-run + +[English](README.md) | 中文 + +用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包解析每平台预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 + +```js +import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; + +const launcher = launcherPath(); +if (probe(launcher) !== 'unusable') { + const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; +} +``` + +启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:始终失败闭合,绝不失败开放。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 + +平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回确定且不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 diff --git a/native/landlock-run/packages/linux-arm64/README.i18n.yaml b/native/landlock-run/packages/linux-arm64/README.i18n.yaml new file mode 100644 index 0000000000..b6b14f1eef --- /dev/null +++ b/native/landlock-run/packages/linux-arm64/README.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 +README.md: e5117988cf0bae2227edaa041700c2f75753899c +README.zh.md: 93fee68207a9f03a54f214c69904d44729ed71e5 diff --git a/native/landlock-run/packages/linux-arm64/README.md b/native/landlock-run/packages/linux-arm64/README.md index 1921c8f4b5..e5117988cf 100644 --- a/native/landlock-run/packages/linux-arm64/README.md +++ b/native/landlock-run/packages/linux-arm64/README.md @@ -1,5 +1,7 @@ # node-addon-landlock-run-linux-arm64 +English | [中文](README.zh.md) + Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. diff --git a/native/landlock-run/packages/linux-arm64/README.zh.md b/native/landlock-run/packages/linux-arm64/README.zh.md new file mode 100644 index 0000000000..93fee68207 --- /dev/null +++ b/native/landlock-run/packages/linux-arm64/README.zh.md @@ -0,0 +1,9 @@ +# node-addon-landlock-run-linux-arm64 + +[English](README.md) | 中文 + +面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。 + +该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 + +同级包:`node-addon-landlock-run-linux-x64`。 diff --git a/native/landlock-run/packages/linux-x64/README.i18n.yaml b/native/landlock-run/packages/linux-x64/README.i18n.yaml new file mode 100644 index 0000000000..af3f916fb4 --- /dev/null +++ b/native/landlock-run/packages/linux-x64/README.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 +README.md: 68b5dfc9b6f437a387c3792ee047a1f11630aca0 +README.zh.md: b1fa2e3f16c20c4d7e287c17ab0ba946e6cadbea diff --git a/native/landlock-run/packages/linux-x64/README.md b/native/landlock-run/packages/linux-x64/README.md index ce741eb34c..68b5dfc9b6 100644 --- a/native/landlock-run/packages/linux-x64/README.md +++ b/native/landlock-run/packages/linux-x64/README.md @@ -1,5 +1,7 @@ # node-addon-landlock-run-linux-x64 +English | [中文](README.zh.md) + Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. diff --git a/native/landlock-run/packages/linux-x64/README.zh.md b/native/landlock-run/packages/linux-x64/README.zh.md new file mode 100644 index 0000000000..b1fa2e3f16 --- /dev/null +++ b/native/landlock-run/packages/linux-x64/README.zh.md @@ -0,0 +1,9 @@ +# node-addon-landlock-run-linux-x64 + +[English](README.md) | 中文 + +面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。 + +该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 + +同级包:`node-addon-landlock-run-linux-arm64`。 diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml new file mode 100644 index 0000000000..9b2c44af22 --- /dev/null +++ b/packages/README.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 +README.md: d7427c3f9892f56185cc1175245f14a6ccea0d25 +README.zh.md: 6894aa7333f6ba4bc5723871fb77c18b5fb518a1 diff --git a/packages/README.md b/packages/README.md index cffc4a0554..d7427c3f98 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,5 +1,7 @@ # Packages +English | [中文](README.zh.md) + Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). ## Hierarchy diff --git a/packages/README.zh.md b/packages/README.zh.md new file mode 100644 index 0000000000..6894aa7333 --- /dev/null +++ b/packages/README.zh.md @@ -0,0 +1,56 @@ +# 包 + +[English](README.md) | 中文 + +所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类或函数插件;所有贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 + +## 层级结构 + +包位于 `packages///`;组是容器,包名仍为 `@deepseek-ai/dsh-`。**每个组 README 是规范的包/ctx 键映射。** + +| 组 | 职责 | 发布预期 | +|---|---|---| +| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | +| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | +| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | +| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | +| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 | +| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 | +| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定表面 | +| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定表面 | +| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定表面 | +| [`skill/`](skill/README.md) | Skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/加载器 | 产品:稳定表面 | +| [`compact/`](compact/README.md) | 压缩(compaction)能力系列:抽象 seam + 基础后端(工具延后) | 产品:稳定表面 | +| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 | +| [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 | +| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 | +| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 | +| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 | +| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 | +| [`todo/`](todo/README.md) | Todo/规划系列:面向模型的 `todo_write` 工具 | 产品:稳定表面 | +| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 | +| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 | +| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 | +| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 | +| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 | +| [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 | +| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | +| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 | +| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | +| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | +| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 | +| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 | +| [`ui/`](ui/README.md) | 人类/客户端集成:TUI 与 JSON-RPC、批准/交互 seam、用户问答工具 | 产品:稳定表面 | +| [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | +| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | +| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | + +组用于区分产品 API 与支持基础设施。新包加入现有组;新组则更新其 README 和此表。 + +## 依赖 + +依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.md)(`pnpm run gen-module-graph`,CI 中有新鲜度门禁)。 + +**扩展插件依赖接口,绝不依赖具体循环。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力拆分为接口/实现/消费方包;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。 + +包 README 覆盖用途、API、扩展点和[模型体验](../docs/cookbook/adding-a-package.md#4-write-the-package-readme);列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它们还要包含 `## Known Limitations and Deferred Work`,或使用其[允许清单](../scripts/verify-package-readme-limitations.ts)。 diff --git a/packages/acp/README.i18n.yaml b/packages/acp/README.i18n.yaml new file mode 100644 index 0000000000..cf2cf1791a --- /dev/null +++ b/packages/acp/README.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 +README.md: 326615210e5cfc39004fc5ab7462623089ac4126 +README.zh.md: 9999ecdd019501c3f501a6c69fab5e0ccfaf555c diff --git a/packages/acp/README.md b/packages/acp/README.md index 480b15726d..326615210e 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -1,5 +1,7 @@ # acp/ — Agent Client Protocol automation +English | [中文](README.zh.md) + The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer. | Package | Role | diff --git a/packages/acp/README.zh.md b/packages/acp/README.zh.md new file mode 100644 index 0000000000..9999ecdd01 --- /dev/null +++ b/packages/acp/README.zh.md @@ -0,0 +1,11 @@ +# acp/:Agent Client Protocol 自动化 + +[English](README.md) | 中文 + +ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,而非展示层或人机交互层。 + +| 包 | 职责 | +|---|---| +| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接拥有的清理。 | + +与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以驱动同一服务器契约。 diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml new file mode 100644 index 0000000000..5583a2b9a5 --- /dev/null +++ b/packages/acp/acp/README.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 +README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88 +README.zh.md: f8abe9e45a5efffa436513f7d4a931c69c624b84 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 20b1ecbefe..1b188b994d 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-acp +English | [中文](README.zh.md) + Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md). This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the web and TUI modules. diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md new file mode 100644 index 0000000000..f8abe9e45a --- /dev/null +++ b/packages/acp/acp/README.zh.md @@ -0,0 +1,79 @@ +# @deepseek-ai/dsh-acp + +[English](README.md) | 中文 + +通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、通过策略解决一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 + +此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、mode、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与人类问题属于 Web 和 TUI 模块。 + +## 插件 + +`apply(ctx, config)` 在 stdin/stdout 上打开 `AgentSideConnection` 并驱动 `ctx.agents`。Stdout 专用于协议帧。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `provider` | 无 | 每个已创建 agent 的初始提供方路由。 | +| `model` | 无 | 每个已创建 agent 的初始模型。 | + +两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。 + +## 协议契约 + +| 方法 | 行为 | +|---|---| +| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | +| `authenticate` | 空操作,因为服务器不公布身份验证方法。 | +| `session/new` | 使用绝对主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | +| `session/prompt` | 连接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并从该请求拥有的持久 `turn/end` 结算。 | +| `session/cancel` | 仅取消被定址的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | +| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 | +| `session/request_permission` | 为携带工具调用 id 的桥接层所有批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | + +一个连接可以拥有多个会话。桥接层使用带品牌的 session id 为记录建键,并在路由事件或权限请求前检查精确的 agent 标识。每个会话都有独立的提示词槽位、workspace、取消路径和 disposer。 + +已提交消息输出有意以逐 token 延迟换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。 + +## 生命周期 + +客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行释放所有已拥有的 agent handle,并等待它们的循环/会话清理完成。因此,仅 ACP 的插件重载不会遗留 agent。 + +## 运行 + +`pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。父 harness 可以通过 [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md) spawn 它;其他 ACP 客户端只需上述核心方法。 + +## 模型体验 + +### 提示词文本 + +#### 模型所见内容 + +`session/prompt` 文本块会原样连接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。 + +#### Token 影响 + +提示词 token 取决于数据,并保留在该会话的历史中直到压缩。并发 ACP 会话保留独立上下文。 + +#### KV Cache 影响 + +仅追加;新用户消息位于可复用请求前缀之后,不会使先前缓存条目失效。 + +### 权限决策 + +#### 模型所见内容 + +没有直接内容。拥有该决策的工具通过常规工具结果路径记录允许、拒绝、取消或不可用结果。 + +#### Token 影响 + +只有拥有该决策的工具结果会贡献 token。 + +#### KV Cache 影响 + +通过所属工具结果仅追加。 + +## 已知限制与延后工作 + +- **仅新会话**:不支持加载、列出、恢复、删除和 fork。 +- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会被展平为文本引用,而不是已获取内容。 +- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不上线。 +- **连接拥有的生命期**:一个连接会释放其所有会话;尚未实现每会话关闭。 diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml new file mode 100644 index 0000000000..6f9db27161 --- /dev/null +++ b/packages/bash/README.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 +README.md: 08b36270800cdd79c82d6781bbfb2e12e2dc2060 +README.zh.md: a98506a6cdf41e5b298b40e1b8e1faf0c4c917d2 diff --git a/packages/bash/README.md b/packages/bash/README.md index 2e2bb5692a..08b3627080 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -1,5 +1,7 @@ # bash/ — bash capability family +English | [中文](README.zh.md) + The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages. | Package | Role | ctx key | diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md new file mode 100644 index 0000000000..a98506a6cd --- /dev/null +++ b/packages/bash/README.zh.md @@ -0,0 +1,14 @@ +# bash/:bash 能力家族 + +[English](README.md) | 中文 + +规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品** 包。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇) | `ctx.bash` | +| `bash-local/` | 本地子进程 `BashExecutor` 实现 | (注册 `ctx.bash`) | +| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | +| `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | + +接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器配置项;受限实现还需选择一个 `ctx.sandbox` 提供方配置项(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml new file mode 100644 index 0000000000..c7e9587289 --- /dev/null +++ b/packages/bash/bash-local/README.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 +README.md: 1668f33e8acf6d749d4d3753478c12d48a19ac3c +README.zh.md: 0e0a4ad41b532e39f6f2470aa981a08b6d6230c1 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 5d99161fc0..1668f33e8a 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-bash-local +English | [中文](README.zh.md) + Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md new file mode 100644 index 0000000000..0e0a4ad41b --- /dev/null +++ b/packages/bash/bash-local/README.zh.md @@ -0,0 +1,49 @@ +# @deepseek-ai/dsh-bash-local + +[English](README.md) | 中文 + +`@deepseek-ai/dsh-bash` 执行器 seam 的本地子进程实现:`LocalBashExecutor` 每次调用都会在独立进程组中 spawn `bash -c `,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。 + +包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`;子进程管道细节保留在该实现包内部。 + +## 配置 + +```yaml +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: /path/to/workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace +``` + +## 行为(以及设计来源) + +设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: + +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/run.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 +- **使用逐步升级终止整个进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 `graceMs` 宽限期后再发送 SIGKILL(默认 3 秒,沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。主 shell 退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止命令结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **保留尾部的截断 + 有界 spill 文件**:输出超过 `maxOutputBytes` 后,内存中保留尾部(错误/结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到临时文件,并在可用时报告该路径。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台任务仍使用 `maxOutputBytes`。某个流大于 `maxSpillBytes` 时,会丢弃已不完整的 spill,仅返回带截断标记的尾部。如果最终关闭 spill 时报告延迟写回失败,执行器同样不会公布路径,以免声称存在不完整的文件。 +- **适合模型的环境变量 + 凭证清理**:以 `process.env` 为基础,移除形似凭证的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中的 `DSH_*` 名称,再设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果。spec 的普通 `env` 在清理后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止遗留嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 使用全流字节偏移量进行增量读取;dispose 会终止每个运行中的进程并等待其退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 + +## 模型体验 + +通过 `dsh-tool-bash` 间接影响;该工具会渲染此执行器有界的 stdout/stderr 尾部、后台进程增量、spill 文件路径与基础设施失败。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 +- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。 +- **仅支持 POSIX**:`bash` 二进制、独立进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 +- **凭证清理依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 +- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 + +原始进程处理位于 `src/run.ts`;`src/index.ts` 负责服务接线。 diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml new file mode 100644 index 0000000000..86168916fe --- /dev/null +++ b/packages/bash/bash-sandbox/README.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 +README.md: ca77a9c626784b29145712535d69de4afbd3a697 +README.zh.md: c1a65ead539ef3930d70d27f2b176a5346daded3 diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 93e0c9e6f2..ca77a9c626 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-bash-sandbox +English | [中文](README.zh.md) + Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields. The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal. diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md new file mode 100644 index 0000000000..c1a65ead53 --- /dev/null +++ b/packages/bash/bash-sandbox/README.zh.md @@ -0,0 +1,90 @@ +# @deepseek-ai/dsh-bash-sandbox + +[English](README.md) | 中文 + +消费 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 的沙箱实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);后者拥有默认模式 + 工作区根目录,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。 + +包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。 + +每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用(必须快速失败并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默无约束运行),属于提供方职责;本包只拥有 bash 侧。 + +| 模式 | 文件影响 | +|---|---| +| `read-only`(默认) | 任何位置都不可写(在 `/dev` 中只有 `/dev/null` 节点可写,因此 `>/dev/null` 仍可正常工作) | +| `workspace-write` | 只能写入 `workspaceRoot` + `/tmp`(在 bwrap 下为临时目录,在 Landlock 下为宿主 `/tmp`,在 Seatbelt 下为 `/private/tmp` 加每用户临时目录) | +| `danger-full-access` | 不作限制;绝不咨询提供方。前台结果携带 `sandbox: { mode, denied: false }`;后台进程句柄不携带沙箱事实。 | + +语义: + +- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 +- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,bash 产生方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 +- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent 调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。 +- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。 +- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 + +seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它驱动本包遵守的覆盖。 + +```yaml +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: read-only + workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd +- id: bash + name: '@deepseek-ai/dsh-bash-sandbox' +``` + +无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,在真实世界验证,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。 + +## 模型体验 + +### 间接的 Bash 工具 schema + +#### 模型看到的内容 + +基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布一个执行限制的 `sandboxMode`,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。 + +#### Token 影响 + +在 `bash` 可见的请求上增加少量固定 schema;模式切换不增加上下文 token。 + +#### KV Cache 影响 + +执行器持续公布相同沙箱能力时,前缀保持稳定。更改这些能力会改变 `bash` schema,可能使从该定义起的复用失效;每会话模式切换不会导致失效。 + +### 间接的 Bash 工具结果 + +#### 模型看到的内容 + +在普通有界输出之后,被拒绝的调用会精确追加 `[sandbox: file access denied under mode]`。当升权可用时,接下来精确追加 `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`。已结算的后台 runner 失败则追加 `[sandbox: the sandbox runner itself failed under mode — the command did not run; this is a sandbox problem, not a command failure]`。 + +#### Token 影响 + +除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到压缩。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +### 间接的 Bash 工具错误 + +#### 模型看到的内容 + +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误;它由 `dsh-sandbox` 持有](../../sandbox/sandbox/README.md#confinement-error-indirectly)。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。 + +#### Token 影响 + +该次调用可见的是有条件错误文本,并保留在历史记录中直到压缩。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。 +- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但匹配的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。 +- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。 +- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。 diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml new file mode 100644 index 0000000000..f32efb60a0 --- /dev/null +++ b/packages/bash/bash/README.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 +README.md: b4ee66a1fa2696254a1f2f411b7db5d3190f8370 +README.zh.md: 151d4bd7ab257234584b9008c96e6356d7e39351 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index 73fbb4fb3e..b4ee66a1fa 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-bash +English | [中文](README.zh.md) + The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime. This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently: diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md new file mode 100644 index 0000000000..151d4bd7ab --- /dev/null +++ b/packages/bash/bash/README.zh.md @@ -0,0 +1,49 @@ +# @deepseek-ai/dsh-bash + +[English](README.md) | 中文 + +**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 + +本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): + +| 包 | 职责 | +|---|---| +| `@deepseek-ai/dsh-bash`(本包) | 接口:抽象服务 + 词汇类型 | +| `@deepseek-ai/dsh-bash-local` | 实现:本地子进程 | +| `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 | +| `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema | + +该拆分与 LLM seam(`LlmService`/`LlmAdapter`)及 agent 工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。 + +## 服务 API(`ctx.bash`) + +| 成员 | 语义 | +|---|---| +| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止终止都会 resolve 为描述性 `BashRunResult`。 | +| `start(spec)` | 后台执行。立即返回不含任务语义的 `BashProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.tasks`。 | +| `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 | +| `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 | +| `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 | + +实现会继承 `BashExecutor` 并实现抽象方法。dispose 必须终止每个运行中的进程并等待其退出,详见 HMR 安全测试。 + +## 词汇 + +`BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。 + +每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 + +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,拒绝普通 `env` 中的这些名称,再合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 + +## 模型体验 + +通过 `dsh-tool-bash` 间接影响;该工具会将执行器输出与沙箱事实转为指引和保留的工具结果 token。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **没有交互式输入词汇**:`stdin` 只会在 spawn 时写入一次并关闭;seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。 +- **前台超时始终由执行器拥有**:seam 上的调用方拥有 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。 diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml new file mode 100644 index 0000000000..a529b56b56 --- /dev/null +++ b/packages/bash/tool-bash/README.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 +README.md: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46 +README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index e58145ee67..965ae25a5e 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-bash +English | [中文](README.zh.md) + The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`. Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md new file mode 100644 index 0000000000..60be5c5ca5 --- /dev/null +++ b/packages/bash/tool-bash/README.zh.md @@ -0,0 +1,158 @@ +# @deepseek-ai/dsh-tool-bash + +[English](README.md) | 中文 + +模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。 + +需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`);在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`)。 + +包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 + +插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。 + +## 工具 + +### `bash` + +| 参数 | 类型 | 说明 | +|---|---|---| +| `command` | string(必填) | 通过 `bash -c` 运行。调用之间不保留状态;请使用 `workdir`,不要使用 `cd`。 | +| `description` | string(必填) | 用一行主动语态概述命令(5~10 个词),仅用于 UI/日志显示,不影响执行。 | +| `timeoutMs` | number | 以毫秒为单位覆盖超时时间。执行器会应用其配置的默认值和上限。 | +| `workdir` | string | 本次调用的工作目录。默认为调用方 agent(智能体)会话 cwd 的文件系统标识(`session.header.cwd`),使每个会话都在自己的工作区中运行;相对 `workdir` 也以同一标识为基准解析。 | +| `run_in_background` | boolean | 立即返回 task id;不应用超时。 | +| `sandbox_permissions` | string enum | 仅当已挂载的执行器启用沙箱时才会公开(`ctx.bash.sandboxMode` 报告一个具有限制作用的默认值):被拒命令所需的更宽模式,取自封闭的目标词汇 `workspace-write`/`danger-full-access`(绝不能缩减为执行器默认值;有效模式按会话确定,执行时会基于它检查是否严格拓宽,未拓宽的请求直接失败,不会向任何人发起提示)。 | +| `justification` | string | 必须与 `sandbox_permissions` 一同提供(缺少任一项都会产生验证错误):用一句话向用户解释此命令为何需要这项更宽权限。 | + +执行前,`command`、`workdir` 和 `timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此执行器 seam(`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。 + +### 托管 shell 环境 + +每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。 + +`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。 + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tool-bash' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 + +结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。 + +已完成前台进程的规范成功值为 `{ kind: 'foreground', ...BashRunResult }`,已发布任务则为 `{ kind: 'background', taskId }`。Native renderer 保留上述文本,包括精确的 `started background task `;程序化消费方使用带类型字段,无需解析这些字符串。执行器的流上限仍是 `BashRunResult` 的采集限制,并携带其 spill 路径。 + +当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.tasks.start()`,把调用方 agent 注册为持有者,并将返回的 `BashProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时持有 id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。 + +## UI 展示 + +工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。 + +## 工具仅使用具名参数构建请求 + +`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 + +## 权限与升权 + +除非启用沙箱的执行器([`dsh-bash-sandbox`](../bash-sandbox/))限制命令,否则命令以执行器的完整权限运行。仅拒绝型沙箱会把拒绝作为结果事实报告,并在此渲染为拒绝标记;逐调用的允许/拒绝/询问策略由 `tools/pre-execute` waterfall(瀑布式事件)负责(参见 docs/architecture.md)。 + +需要升权的 bash 调用会在执行前解析 `ctx.approval`。`allowed-once` 只对该次调用应用请求模式;审批被拒、取消、不可用或缺少审批上下文时,命令完全不会执行,并返回不同的错误。发生真实拒绝后,模型可以在同一轮次中使用满足需要的最窄模式和理由重试同一命令一次;审批提示本身就是征求同意的步骤。升权绝不能预先推测,禁用或拒绝审批即为最终结果。其理由由 [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 持有。 + +## 逐会话模式切换 + +对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。提示词和切换通知均不公布当前常驻模式;拒绝结果会在边界相关时报告有效模式。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +此插件注册作用域内的每个请求都包含下方 bash 指引。启用沙箱的执行器不会添加模式声明或切换通知。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。 + +##### Bash 指引 + +```markdown +Check the [exit code: N] marker on every bash result; investigate failures before moving on. +``` + +#### Token 影响 + +插件活跃期间,每个请求都会产生少量固定输入开销,不受沙箱模式或模式切换影响。 + +#### KV Cache 影响 + +只要注册作用域和提示词文本不变,前缀即可稳定复用。插件激活或 dispose 可能从此提示词段落开始使复用失效;沙箱模式切换不会。 + +### 工具 schema + +#### 模型看到的内容 + +模型会看到生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。仅当此生产方启用 `run_in_background` 时,该字段才会出现;仅当已挂载执行器声明支持沙箱时,`sandbox_permissions` 和 `justification` 才会出现。Agent 作用域的工具限制可以移除该 agent 的定义。 + +#### Token 影响 + +工具可见的每个请求都会产生固定 schema 开销;沙箱支持会增加升权字段及其条件说明段落。 + +#### KV Cache 影响 + +只要可见性、后台支持和执行器沙箱功能保持不变,前缀即可稳定复用。限制、配置或执行器发生变化时,可能从首个变化的工具定义开始使复用失效。 + +### 前台结果 + +#### 模型看到的内容 + +renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` 和 stderr 尾部。没有输出时,它会精确输出 `(no output)`。条件行精确为 `[output truncated; full output: ]`、`[sandbox: file access denied under mode]`、`[timed out after ms]`、`[killed by signal: ]` 和 `[exit code: ]`;沙箱升权与 runner 故障行原文列于 [`dsh-bash-sandbox`](../bash-sandbox/README.md)。 + +#### Token 影响 + +调用前结果 token 为零。每条流的输出有界,每个已输出行则会保留在历史中,直至压缩(compaction)。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 后台任务上下文与结果 + +#### 模型看到的内容 + +启动会精确返回 `started background task `。此生产方会向通用任务运行时提供增量进程输出、可选的 `[some output was dropped from memory; full output: ]`、沙箱事实,以及 `exit code: ` 或 `signal: ` 等终止详情。[`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) 持有模型可见的状态行、完成通知、列表和取消响应。 + +#### Token 影响 + +启动确认很短并会保留;收集到的输出依数据而定,并受执行器流缓冲区限制。消费式读取不会重复先前输出。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 工具错误 + +#### 模型看到的内容 + +验证和策略失败统一为 `Error: `。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "" is not strictly wider than this call's current "" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`。 + +#### Token 影响 + +只有失败调用会增加这些保留 token;升权被拒时命令不会运行,因此不会添加命令输出。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill;这是仅影响展示的已知残留问题。 +- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。 +- **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml new file mode 100644 index 0000000000..0dd8860d65 --- /dev/null +++ b/packages/client/connection/README.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 +README.md: 80228a180faba0c556ff720e999b29b5bb1635b6 +README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 569670c274..80228a180f 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-connection +English | [中文](README.zh.md) + Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md new file mode 100644 index 0000000000..f4b857886b --- /dev/null +++ b/packages/client/connection/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-connection + +[English](README.md) | 中文 + +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 + +## 无密钥 fixture + +任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。 + +## 模型体验 + +无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。 +- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。 diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml new file mode 100644 index 0000000000..ce05fdae22 --- /dev/null +++ b/packages/client/hmr/README.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 +README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887 +README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index fc262bb086..2b2f63c25c 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-hmr +English | [中文](README.zh.md) + Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert. The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `