Merge remote-tracking branch 'origin/master' into worktree/context-source-cards

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
creatixchu
2026-08-05 19:14:56 +08:00
586 changed files with 5188 additions and 3470 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: d19b256b834110d3cbb540cc0e039e61c693e98c
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 79f944b74976f82d5233a8d55916eff49aa7bf86
2026-07-25-web-client-session-scope-and-provide-channel.md: 353cf35c9d6f5fa93a97fb0be60303ad6cef4d14
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b6d1a20073e1a43414d43b830ccc8ac2b1573bb2

View File

@@ -64,17 +64,17 @@ A session "materialized but with no first prompt" is governed by the summary-der
- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk.
- The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility.
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
- Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
- Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with `session.id === sessions.current`, its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally.
- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
### connectWorkspace: the sole entry point of New Session
`workspaces.connectWorkspace(workspaceId): Promise<SessionId>` (owned by WorkspacesService — it holds both the workspace canonical path and the sessions reference):
- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path` (direct equality on the host realpath canonical form); a hit returns that id directly, creating nothing.
- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing.
- The create arm: on a miss, `session.create({workspaceId})` returns the new id.
- An unknown workspaceId fails loud (never silently creating somewhere else).
- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and `sessions.binding(id)` resolves synchronously — `SessionsService.create` projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush.

View File

@@ -64,17 +64,17 @@ Session 实例与 scope 同生命周期,存活资格 = host listed一个判
- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息。live 会话 `summarize()` 内存直读cold 会话恒 `false`——lazy-create 契约保证 never-appended 会话根本不进 `persistence.list()`JSONL/SQLite 两后端均已实证真 lazyblank 从不落盘。
- wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true供别的 tab 按同一空会话状态入镜像)。
- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank与 host 权威对齐、继续显示为 `New Session`、保持 connectWorkspace 复用资格。
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank与 host 权威对齐、继续显示为 `New Session`在仍为该工作区成员时保持 connectWorkspace 复用资格。
- 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running首次 running 必然已非 blank
- 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
- 列表纪律store 保留全部行Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。
- 残留账零 GC刷新后 blank 会话带位回来,下次同 workspace 复用,普通单端路径使每个 workspace 至多保留一个host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
- 残留账零 GC刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
### connectWorkspaceNew Session 的唯一入口
`workspaces.connectWorkspace(workspaceId): Promise<SessionId>`(归属 WorkspacesService——它同时持有 workspace 规范 path 与 sessions 引用):
- 复用臂list mirror 中找 `blank && cwd == workspace.path`host realpath 规范 canon 直等比较),命中直接返回该 id不新建。
- 复用臂list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配CLI/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)命中直接返回该 id不新建。
- 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。
- 未知 workspaceId fail loud不静默创建到别处
- 解析保证两臂同契约promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionsService.create` 在 RPC 成功后同步投影列表再 resolve使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633
2026-07-30-web-config-plane.zh.md: 184299cf0c44b992e55a268c990b468c6d13f947
2026-07-30-web-config-plane.md: 5225460be1d66b85a05ff2fd5ae2826b0e6c41d7
2026-07-30-web-config-plane.zh.md: 53a21ddf31640d963c413e1793276de694547311

View File

@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-07-30-web-config-plane.zh.md)
> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change.
> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the local settings-document handoff, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change.
## Problem
@@ -12,15 +12,17 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
## Decision
**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/update/replace`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin.
**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin.
**`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value.
**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action.
**The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias.
**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently.
**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging once both land. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry this file's token spellings, not that branch's: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override.
## Alternatives considered
@@ -30,7 +32,8 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
- **Storing the typed key as a literal `apiKey` setting** — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe.
- **A `models` bridge plugin owning provider configuration** — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection.
- **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed.
- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `host.openPath` in the browser** — rejected because `settings-local.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener.
## Consequences
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential.
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential.

View File

@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-30-web-config-plane.md) | 中文
> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。
> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、本地设置文档交接、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。
## 问题
@@ -12,15 +12,17 @@ PR1 让 LLM大语言模型适配器配置在 seam 层面免重启,但唯
## 决策
**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/update/replace``credentials.describe/set/unset``llm.providers``llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}``host/credentials-changed {ref}``host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。写入 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置修改
**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/openDocument/update/replace/mutate``credentials.describe/set/unset``llm.providers``llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}``host/credentials-changed {ref}``host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。settings 读取、原生操作与写入 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问
**`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历object/dict/array 容器secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。
**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`Host 会在文本文档交接前再次解析提供方路径macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作Linux 上使用 `xdg-open`Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。
**llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册注销提交点触发listener 派发带异常隔离INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。
**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原rehydrate为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`另加 deepseek `reasoningEffort`pi-ai `reasoning`),其余每个字段`settings.yaml` 所有。校验仍会在写入前运行还原出的 schema因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败
**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原rehydrate为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`deepseek `reasoningEffort`pi-ai `reasoning`,另有直接 DeepSeek 模型行的 `id``name``contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段`settings.yaml` 所有。校验仍会在写入前运行还原出的 schema适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border``--surface``--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态每个模型一个带边框的条目ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K``M` 后缀(`1M` 即 1000K与容量的通行标注方式一致并存储纯数值字段持有焦点期间保留键入的文本因为若每次按键都从解析出的数值重新推导该文本`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:`--dsw-alias-border-subtle``--dsw-alias-text-tertiary``--dsw-alias-text-primary` 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile而破坏性确认会提交唯一一条 unset并在其完成前阻止重复提交。
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile而破坏性确认会提交唯一一条 unset并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。
## 曾考虑的替代方案
@@ -30,7 +32,8 @@ PR1 让 LLM大语言模型适配器配置在 seam 层面免重启,但唯
- **把键入的密钥存成字面 `apiKey` 设置**——v1「单个 API 密钥输入框」的需求本可以把字面量直接写进 profile但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。
- **由 `models` 桥接插件持有提供方配置**——与 PR1 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。
- **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧各自只多一个形状的成本,就让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。
- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `host.openPath` 回传 `documentPath`**——否决,因为 `settings-local.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。
## 后果
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件fixture测试前置数据、golden、文档、python未保留兼容别名。替换渲染器只花了一次提交且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明:取消后 profile 保持原样确认后会将其删除而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID名称上下文窗口、移除活动模型行并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件fixture测试前置数据、golden、文档、python未保留兼容别名。替换渲染器只花了一次提交且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。

View File

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

View File

@@ -0,0 +1,50 @@
# Agent Note: The conversation column reserves one scrollbar gutter for every view
Status: implemented
English | [中文](2026-08-04-composer-tab-gutter-reservation.zh.md)
## Problem
The composer seat is one node in one place in the tree, and it was laid out against a different edge depending on which view tab was shown.
In Chat it is a sticky CHILD of the column's scroller (`[data-conversation-scroll]`), so it rides that scroller's content box — the box a space-consuming scrollbar shortens by the bar's width. A view that declares `data-conversation-composer-overlay`, which Trajectory does, moves the column's scrolling into the view itself: the branch keyed on that attribute left the scroller `overflow: hidden` and positioned the seat absolutely, against the padding box, which no scrollbar reduces.
So for as long as the transcript overflowed — the ordinary state of any session with history — the two tabs disagreed by exactly the bar's width. The input card is centred, so switching tabs moved it 4px sideways on an 8px bar, and its right-hand clearance changed by the full 8. The same displacement appeared inside Chat alone at the moment a growing transcript started to scroll, and again between the hero phase and the first scrolling turn.
## Decision
`.scrollBody` declares `scrollbar-gutter: stable` unconditionally, and the overlay branch declares the same box a scroll container on both axes — `overflow-x: hidden; overflow-y: auto` — instead of `overflow: hidden`.
The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it.
The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari.
The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column.
The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason.
## Alternatives considered
**Inset the overlay seat by the bar's width.** The narrow reading of the bug — the two states differ by 8px, so subtract 8px from one. Rejected because the number is the engine's, not ours: the WebKit path draws the sheet's 8px bar, the Firefox path draws whatever `scrollbar-width: thin` resolves to, and a hardcoded inset would line the two states up in Chromium while drifting everywhere else. The gutter asks the engine to reserve its own bar's width, whatever that is.
**Keep `overflow: hidden` and add `scrollbar-gutter: stable` alone.** The one-line version. It fixes the visible symptom on the engine the browser lane runs, and leaves it in place on Safari, with no test failing anywhere — the failure mode the second half of the change exists to prevent.
**Move the composer seat out of the scroller in Chat too, making the overlay geometry the only geometry.** This deletes the difference at its root rather than reconciling it, and gives up a deliberate property: the sticky seat sits inside the scroll flow, so a wheel over the composer moves the transcript ([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)), and the fade mask above it is painted by the seat's own background. Both are owned behavior with their own coverage; rebuilding them to remove 8px of asymmetry is the larger change, not the smaller one.
**Pad the column by the bar's width instead of reserving a gutter.** Padding applies whether or not a bar is present, so it costs the width unconditionally in every state, and it pins a value in the stylesheet that the engine picks at layout time. Rejected for the same reason the sidebar list rejected it.
## Consequences
- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column.
- The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn.
- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one.
- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's.
## Testing
`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish.
The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped.
The pre-fix cascade is then applied in the page — `scrollbar-gutter: auto` on the scroller, `overflow: hidden` on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence.

View File

@@ -0,0 +1,50 @@
# Agent Note: 会话列为每个视图预留同一条滚动条槽
Status: implemented
[English](2026-08-04-composer-tab-gutter-reservation.md) | 中文
## 问题
composer 座位在组件树中只有一个节点、一个位置,但它究竟对齐到哪条边,取决于当前展示的是哪个视图标签页。
在 Chat 中它是会话列滚动容器(`[data-conversation-scroll]`)的 sticky **子元素**,因而依附于该容器的 content box——而占布局宽度的滚动条会把这个盒子收窄一条滚动条的宽度。声明了 `data-conversation-composer-overlay` 的视图Trajectory 即是其一)会把会话列的滚动搬进视图自身:以该属性为条件的那条分支把滚动容器留作 `overflow: hidden`,并把座位改为绝对定位——对齐的是 padding box而滚动条从不收窄这个盒子。
于是只要对话记录超出一屏——任何带历史的会话的常态——两个标签页就恰好相差一条滚动条的宽度。输入卡片是居中的,因此在 8px 的滚动条下切换标签页会让它横向移动 4px而右侧留白整整变化 8px。同一位移也出现在 Chat 内部:对话增长到开始滚动的那一刻,以及从 hero 态进入第一个可滚动轮次时。
## 决策
`.scrollBody` 无条件声明 `scrollbar-gutter: stable`overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`
这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`且自带裁剪座位不在常规流中因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动容器记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。
横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。
这条预留之所以值回它的代价前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为而是本客户端的选择ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。
## 曾考虑的替代方案
**把 overlay 座位按滚动条宽度内缩。** 这是对该缺陷最窄的一种解读——两种状态差 8px那就从一侧减去 8px。之所以否决是因为这个数字属于引擎而不属于我们WebKit 路径绘制样式表里的 8px 滚动条Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度,硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。滚动条槽是请引擎按它自己那条滚动条的宽度去预留,无论那是多少。
**保留 `overflow: hidden`,只加 `scrollbar-gutter: stable`。** 单行版本。它能在浏览器车道所用的引擎上修掉可见症状,却把症状原封不动留在 Safari 上,而且任何测试都不会失败——这正是改动的后一半所要防的失效模式。
**让 Chat 的 composer 座位也移出滚动容器,使 overlay 的几何成为唯一的几何。** 这是从根上删掉差异而不是调和它代价是放弃一项刻意的性质sticky 座位位于滚动流之内,因此在 composer 上滚轮会带动对话记录([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)),其上方的渐隐遮罩也由座位自身的背景绘制。两者都是有主、有覆盖的既有行为;为了消除 8px 的不对称而重建它们,是更大的改动而非更小的。
**给会话列加上一条滚动条宽度的内边距,而不是预留滚动条槽。** 内边距无论是否存在滚动条都会生效,因此在每种状态下都无条件付出这份宽度,而且它把一个由引擎在布局期决定的值钉死在样式表里。否决理由与侧边栏列表当初否决它时相同。
## 后果
- Chat 的内容列永久变窄 8px——hero 态与对话记录尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。
- 一条声明覆盖三种切换因为这三者本就是同一个差异Chat ↔ Trajectory、Chat 内部的短对话 ↔ 可滚动对话,以及 hero ↔ 第一个可滚动轮次。
- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。
- 提交的 golden 记录了预留的带宽,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。
## 测试
`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形分别取卡片处于宽度上限的视口与卡片随列收缩的视口并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两侧带宽都是 0去掉它则是 8 与 0。
随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把"卡片确实没动"与"标签页切换根本没到达布局"区分开的那一步。它把上报的症状复现为一个数字:每条边 4px恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture 承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md
2026-08-05-workspace-blank-session-reuse-membership.md: 910a10e9ada1a835df7a38a04fb04c504b0921df
2026-08-05-workspace-blank-session-reuse-membership.zh.md: 7e7aa899f73955b3d34a1eff3d9fedde097a17f0

View File

@@ -0,0 +1,29 @@
# Agent Note: Workspace New Session reuse hijacked cwd-matching unaccounted blank sessions
Status: implemented
English | [中文](2026-08-05-workspace-blank-session-reuse-membership.zh.md)
## Problem
Clicking the `+` on a Workspace group in the sidebar sometimes opened a session that the sidebar showed under Ungrouped instead of under the clicked Workspace — "entered a new session but the Workspace was not selected". The failure was specific to Workspaces registered at the directory the CLI runs from (in practice the harness checkout itself, i.e. `defaults.cwd = process.cwd()`), and appeared once a CLI-born blank session existed there.
Root cause: `connectWorkspace`'s blank-session reuse scanned the session list mirror on `cwd` equality alone. The host's own membership rule requires **both** an id in the Workspace account (`sessionIds`) **and** a session header whose canonical cwd equals the Workspace path ([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md)); a cwd match without the account slot is exactly the Ungrouped case. The reuse scan ignored the account slot, so any **live blank** session whose cwd matched qualified — including `main-session-*` sessions the CLI/TUI/headless entry points birth at the host cwd (`session.create({})` falls back to `defaults.cwd` and never attaches to a Workspace). When such a session was live and blank (no `turn/start` yet), the next `+` click on a Workspace registered at that path reused it and navigation opened a session no grouping surface can show under that Workspace. Workspaces at other paths were unaffected because no unaccounted blank sessions accumulate there; the host-cwd Workspace accumulated one per CLI run.
## Decision
The reuse scan now requires workspace membership: `blank` AND `summary.cwd === workspace.path` AND `workspace.sessionIds.includes(summary.id)` AND not archived. A cwd-only match falls through to `session.create({ workspaceId })`, which attaches the fresh session so the Workspace owns it — the same arm the flow already used for "no blank session exists".
## Alternatives considered
**Adopt the stray instead of minting.** `session.create({ workspaceId })` could attach a cwd-matching unaccounted blank session. Rejected: silently attaching CLI-born sessions to a Workspace crosses the account boundary by surprise, and the client cannot distinguish "stray" from "the Workspace's own blank" without the membership view — which is the fix itself.
**Attach on reuse via a new wire operation.** Requires a `workspace.attachSession` RPC in the navigation hot path and would still render the session under Ungrouped for a frame; no product need justifies the surface.
## Consequences
Stray blank sessions remain visible in Ungrouped (the user can still open them) but are never hijacked by a Workspace's New Session flow. Membership is a new condition on the reuse scan, and it has one observable stale-mirror edge: in the window where the session mirror is fresh but the Workspace account frame lags, the Workspace's own member blank can fail the membership check and a duplicate blank is minted where the old code reused — a second `New Session` row under that Workspace rather than the old failure shape (a session that no grouping surface shows). Both windows are transient and the per-Workspace coalescing still prevents duplicate creates racing one another. No host, wire, or durable-format change.
## Testing
`packages/client/runtime/tests/workspaces-service.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green.

View File

@@ -0,0 +1,29 @@
# Agent Note工作区新建会话复用了 cwd 匹配但未入账的空白会话
状态:已实现
[English](2026-08-05-workspace-blank-session-reuse-membership.md) | 中文
## 问题
在侧边栏某个工作区分组的 `+` 上创建会话时,有时会进入一个新会话,但侧边栏把它显示在「未分组」而不是点击的那个工作区下——「进入了新会话,但工作区没有被选中」。故障只出现在注册在 CLI 运行目录(即 `defaults.cwd = process.cwd()`,实际场景里就是 harness 检出目录本身)上的工作区,并且一旦该目录下存在 CLI 创建的空白会话就会出现。
根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组表面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。
## 决定
复用扫描现在要求工作区成员关系:`blank``summary.cwd === workspace.path``workspace.sessionIds.includes(summary.id)` 且未归档。仅 cwd 匹配的情况落到 `session.create({ workspaceId })`,创建并挂接新会话,使工作区拥有它——这与流程中「不存在空白会话」时的既有分支完全相同。
## 曾考虑的替代方案
**收养游离会话而不是新建。**`session.create({ workspaceId })` 挂接一个 cwd 匹配但未入账的空白会话。否决:静默地把 CLI 创建的会话挂到工作区上,越过了账户边界,令人意外;而且客户端没有成员视图就无法区分「游离会话」与「工作区自己的空白会话」——而成员视图本身就是本次修复。
**复用时就地挂接,新增一条 wire 操作。** 需要在导航热路径上新增 `workspace.attachSession` RPC并且会话仍会有一帧显示在「未分组」没有产品需求值得新增这个表面。
## 后果
游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。成员校验是复用扫描的新增条件,有一个可观察的镜像滞后边界:在会话镜像已新而工作区账户帧滞后的窗口里,工作区自己的成员空白会话可能因成员校验失败而错过复用,多创建一个空白——表现为该工作区下出现第二个「新会话」行,与旧故障形态(打开一个任何分组表面都无法显示的会话)不同。两个窗口都是瞬态的,按工作区的合并逻辑仍然防止并发创建互相竞争。无 host、wire 或持久化格式变更。
## 测试
`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPCcwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次 prompt 被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md
2026-07-24-web-session-model-selector.md: 003c0b5ac1c6963701e1d93e4c3ff8615fc45dd5
2026-07-24-web-session-model-selector.zh.md: a8e74fb1539348535f89f49d7c6d56b2bab79278
2026-07-24-web-session-model-selector.md: 05e923fb3b5df72485ffb8d6ff5aa0104b19a8a0
2026-07-24-web-session-model-selector.zh.md: bdfa2f856a799cc86600650829a2df62a7dfdc18

View File

@@ -12,11 +12,11 @@ The Web conversation displayed and sent through the Host's fixed provider/model
The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The provider/model/reasoning target starts from the latest `request/header` when the session has used a model, otherwise from the Host default route. `session.selectModel` changes the session-local mutable target, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed target persists through the existing full `request/header` snapshot, while a choice that has not reached a request remains process-local.
The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: the current model is inserted as an unlisted row when its registered provider omits it, while exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target.
The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. The two surfaces answer that state differently on purpose: the TUI still renders the unlisted current model as its own row and marks it current, while Web shows the unset trigger label and asks for a replacement. Web is the surface where a catalog is edited, so a target the user just deleted should read as a decision to make rather than a selection to keep; the TUI, which only picks from what exists, has no such edit to reconcile. The cost is real and accepted — a Web composer showing the unset label can still send to the routed target — and the divergence is deliberate, not a missed migration. Exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target.
The browser `ModelService` owns one `ModelDirectory` per live session. Its snapshot contains the current complete target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Mounting primes the trigger label and each menu open refreshes the directory. Directory and selection calls share an operation generation so older responses cannot replace a newer result; connection reset discards the process-local projection before restoring the Host target. Failures retain the previous current target and usable groups.
`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default.
`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the exact catalog model name and effective reasoning label. When the current target is absent from the groups, the trigger instead displays `Select model`, the model list marks no row active, and the Effort row stays absent; choosing a listed model replaces the complete target through the existing selection path. The upward menu otherwise first offers Model and Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default.
The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant.
@@ -40,4 +40,4 @@ Any Host-backed Web conversation, including a blank session, can switch among dy
## Testing
Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration, unlisted current targets, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, and effort submission. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids.
Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration without stale-row injection, advisory unlisted selection, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, effort submission, and the `Select model` fallback for a removed row. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids; the DeepSeek configuration fixture removes the active catalog row and pins the fallback before choosing a replacement.

View File

@@ -12,11 +12,11 @@ Web 对话原本通过 Host 固定的提供方与模型路由显示并发送消
Web Host 为每个新建或恢复的 agent智能体复用 `installAgentLlmTarget`。如果会话已经使用过模型提供方模型推理reasoning目标从最新的 `request/header` 开始;否则采用 Host 默认路由。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一个实际采用的目标通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。
会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM大语言模型注册表动态构建并按提供方分组每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载且彼此独立失败因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考如果当前模型的已注册提供方没有列出该模型,系统会将其作为未列出行插入;精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID并在更新目标前具体化适配器配置的默认值。
会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM大语言模型注册表动态构建并按提供方分组每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载且彼此独立失败因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考`session.models.current` 独立返回即使不在任何分组中也仍然可以路由但提供方停止公布该模型后Host 不会合成未列出行。两个前门有意对这一状态给出不同回答TUI 仍把未列出的当前模型渲染为独立一行并标记为当前Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 正是编辑目录所在的前门因此用户刚刚删除的目标应当读作一个有待作出的决定而不是一项可以保留的选择TUI 只在已存在的模型中挑选,没有这类编辑需要调和。这一代价真实存在且已被接受——显示未设置标签的 Web composer 仍会发送到实际路由的目标——这一分歧是有意为之,而不是一处遗漏的迁移。精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID并在更新目标前具体化适配器配置的默认值。
浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整目标、分组目录、提供方失败记录、操作错误,以及 `idle``loading``ready``selecting``error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 目标。失败时保留先前的当前目标和可用分组。
`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat并在同一目录上提供 `/model`。其紧凑型触发器显示目录中模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 EffortModel 可深入提供方分组Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。
`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat并在同一目录上提供 `/model`。其紧凑型触发器显示目录中精确模型名称与生效的推理强度标签。当前目标不在分组中时,触发器改为显示 `Select model`模型列表不标记任何活动行Effort 行也保持隐藏;选择一个已列出的模型,会通过现有选择路径替换完整目标。除此情形外,向上展开的菜单首先提供 Model EffortModel 可深入提供方分组Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。
生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest元数据清单将加载顺序置于运行时与命令功能之后Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。
@@ -40,4 +40,4 @@ Web Host 为每个新建或恢复的 agent智能体复用 `installAgentLlm
## 测试
Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复、当前未列出目标、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示推理强度提交。无密钥 built-app fixture测试前置数据加载生产模型插件选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID。
Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复且不注入陈旧行、建议性的未列出模型选择、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示推理强度提交,以及已删除模型行的 `Select model` 回退。无密钥 built-app fixture测试前置数据加载生产模型插件选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 IDDeepSeek 配置 fixture 会删除活动目录行,在选择替代模型之前固定该回退

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md
2026-07-04-doc-tiers-and-budgets.md: a52c40a9a147fd39fdec4c61079822f1b1115227
2026-07-04-doc-tiers-and-budgets.zh.md: c26efe0e06d73ff0bd5608c7f44d54ebbe373bed
2026-07-04-doc-tiers-and-budgets.md: e7b3421d09a1ae5ab9a9373e8040832c1b0d4b97
2026-07-04-doc-tiers-and-budgets.zh.md: 18bf98777c87512e6161a4529db66030ced47bfd

View File

@@ -1,4 +1,4 @@
# Agent Note: Documentation tiers, budgets, and the ceiling gate
# Agent Note: Documentation structure, tiers, and budgets
Status: implemented
@@ -6,13 +6,14 @@ English | [中文](2026-07-04-doc-tiers-and-budgets.zh.md)
## Problem
Standing docs accumulated repeated rules, retold incidents, duplicated package maps, and stale Agent Note summaries despite existing writing guidance. Because review alone did not prevent that growth, the repository needed a mechanical budget alongside its documentation taxonomy.
Standing docs accumulated repeated rules, retold incidents, duplicated package maps, and stale Agent Note summaries despite existing writing guidance. That guidance also did not define how a document's place in the hierarchy limits its scope or how ordered teaching differs from lookup-oriented material. Because review alone did not prevent that growth, the repository needed a mechanical budget alongside its documentation taxonomy.
## Decision
- **A tier taxonomy with one home per fact.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: it assigns every Markdown tier a single job (standing orders, system map, type catalog, decision records, incident stories, how-tos, per-package contracts, generated catalogs, workflows), forbids restating a fact outside its home tier (link instead), and carries the slop checklist used when writing or reviewing any doc.
- **Structure follows the documentation tree.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: a document owns detail about its subject, summarizes only the purpose, responsibility, and high-level behavior of direct children, and links to deeper owners. [Agent Notes](../../README.md) remain outside this structural contract. Every human-facing document is a tutorial with an ordered outcome or a reference with an explicit lookup scope; a [postmortem](../../../../docs/postmortem/README.md) is an incident-scoped reference whose chronology records evidence. Tutorials introduce concepts in prerequisite order for the reader's starting knowledge.
- **A tier taxonomy with one home per fact.** The standard assigns every Markdown tier one job, forbids restating a fact outside its home tier, and carries the slop checklist used when writing or reviewing any doc.
- **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them.
- **Ceilings are an enforcement frontier that ratchets.** A ceiling sits at least 5% above the doc's current size — working headroom, so routine wording edits pass while real growth still trips the gate — and ratchets down, keeping that margin, as the doc is brought to its target budget (root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600; `packages/README.md` ≤ 600). When the gate goes red the fix is to relocate or condense per the taxonomy; raising a ceiling is permitted only with explicit justification in the PR description, the manifest diff being the reviewable act.
- **Ceilings are an enforcement frontier that ratchets.** A doc at or below its target keeps at least 5% headroom as its ceiling ratchets down; a doc above target keeps a frozen ceiling that prevents growth until it reaches the target (root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600 except `packages/AGENTS.md` ≤ 650 and `docs/AGENTS.md` ≤ 1,250; `packages/README.md` ≤ 600). When the gate goes red, relocate or condense; raise a ceiling only with explicit PR justification.
- **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) carries the placement/audit/red-gate workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) over the i18n contract.
## Alternatives considered
@@ -23,6 +24,7 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m
## Consequences
- Adding to a budgeted doc now requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI.
- The bring-under-target rewrites land as stacked follow-ups that ratchet the manifest down as they merge; until each lands, its doc's frozen ceiling only prevents further growth.
- Adding to a budgeted doc requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI.
- Structural review starts with ownership and document form before sentence-level editing, so lower-level detail moves to its owner instead of being polished in the wrong place.
- Budgeted docs that remain above target cannot grow; reaching the target restores the 5% working headroom.
- Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly.

View File

@@ -1,4 +1,4 @@
# Agent Note: 文档分层、预算与上限门禁
# Agent Note: 文档结构、层级与预算
Status: implemented
@@ -6,14 +6,15 @@ Status: implemented
## 问题
尽管已有写作指导,常设文档仍不断累积重复规则、反复讲述的事、重复的包映射,以及陈旧的 Agent Note 摘要。仅靠评审无法阻止这种增长,因此仓库需要在文档分类体系之外再配一套可自动执行的预算。
尽管已有写作指导,常设文档仍不断累积重复规则、反复讲述的事、重复的包package映射,以及陈旧的 Agent Noteagent 决策记录)摘要。该指导也未明确文档在层级中的位置如何限定其内容范围,以及按顺序引导读者学习的内容与面向查阅的材料有何不同。仅靠评审无法阻止这种增长,因此仓库需要在文档分类之外再配一套机械预算。
## 决策
- **每项事实只归属一处的层级分类体系。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:它为每种 Markdown 层级分配单一职责(常设指令、系统图、类型目录、决策记录、事故叙事、操作指南、各包契约、生成的目录、工作流),禁止在事实归属层级之外重复陈述(应改为链接),并包含编写或评审任何文档时使用的赘余检查清单
- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都不得超过其词数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使文件重命名无法悄然摆脱预算约束。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md``packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md``docs/defensive-patterns.md`。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们
- **上限是会逐步收紧的执行红线。** 上限设定为文档当前词数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800子树 `AGENTS.md` ≤ 600`packages/README.md` ≤ 600。门禁变红时修复方式是按分类体系迁移或压缩内容只有在 PRPull Request描述中给出明确理由时才允许提高上限manifest元数据清单的 diff 本身就是可供评审的变更
- **精简的工作流 skill技能契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载文档放置、审计和门禁失败处理工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同
- **结构遵循文档树。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:文档负责承载其主题的详细内容,仅概述直接子项的目的、职责和高层行为,并链接到更深层内容的归属文档。[Agent Note](../../README.md) 仍不受这一结构契约约束。每份面向人的文档要么是按顺序引导读者达成结果的教程tutorial要么是查阅范围明确的参考文档reference[事故复盘postmortem](../../../../docs/postmortem/README.md) 是范围限定于单个事件的参考文档,其时间线记录证据。教程结合读者的起始知识,按前置依赖顺序介绍概念
- **每项事实只归属一处的层级分类。**文档标准为每种 Markdown 层级分配单一职责,禁止在事实归属层级之外重复陈述,并包含编写或评审任何文档时使用的赘余检查清单
- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其字数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md``packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md``docs/defensive-patterns.md`。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们
- **上限是只进不退的执行红线。** 达到或低于目标的文档在上限逐步下调时保留至少 5% 的余量;高于目标的文档则维持冻结的上限,在达到目标之前不得增长(根 `AGENTS.md` ≤ 1,600 词;`architecture.md` ≤ 1,800子树 `AGENTS.md` ≤ 600`packages/AGENTS.md` ≤ 650、`docs/AGENTS.md` ≤ 1,250`packages/README.md` ≤ 600。门禁变红时迁移或压缩内容只有在 PRPull Request描述中给出明确理由时才提高上限
- **精简的工作流 skill技能契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯门禁工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同。
## 曾考虑的替代方案
@@ -23,6 +24,7 @@ Status: implemented
## 后果
- 向受预算约束的文档添加内容现在需要腾挪空间:将新增内容迁移到其分类体系归属地并留下链接,或压缩现有行文来腾出空间。只增不减会导致 CI 失败。
- 精简到目标预算的重写以堆叠的后续 PR 落地,每次合并时同步下调 manifest 中的上限;在各自落地之前,文档冻结的上限仅阻止进一步膨胀
- 词数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻。
- 向受预算约束的文档添加内容需要置换:将新增内容迁移到其分类体系归属地并留下指针,或压缩现有行文来腾出空间。只增不减会导致 CI 失败。
- 结构评审先检查归属关系和文档形式,再进行句子层面的编辑,使较低层级的细节迁移到其归属文档,而不是在错误的位置加以润色
- 仍高于目标的受预算约束文档不得增长;达到目标后,将恢复 5% 的工作余量。
- 字数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻。