Просмотр исходного кода

feat(settings): resolve namespaces per named scope

A settings namespace is now a kind; each registration is an instance
filed under the caller's nearest named scope (dsh-scope gains
createScope(..., { id }) and scopeIdOf). Instances resolve defaults →
composition base → global user section → scopes.<id>.<ns>. Global writes
fan out to every instance gated on its own resolved value; scoped writes
commit one instance. Revisions are per section so a scope nothing has
registered yet can still be written when the kind exists. describe(),
the controller verbs, and both events take an optional trailing scope.
Presets name their standing scopes preset/<id>; skill-filesystem is the
first consumer, resolving customSkillDirs per scope with live updates.
Yichen Jiang 2 недель назад
Родитель
Сommit
80ea5fcdf5
63 измененных файлов с 1766 добавлено и 417 удалено
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.i18n.yaml
  2. 37 0
      .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md
  3. 37 0
      .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md
  4. 2 2
      docs/config-catalog.i18n.yaml
  5. 1 1
      docs/config-catalog.md
  6. 1 1
      docs/config-catalog.zh.md
  7. 2 2
      docs/event-producer-consumer.i18n.yaml
  8. 2 2
      docs/event-producer-consumer.md
  9. 2 2
      docs/event-producer-consumer.zh.md
  10. 2 2
      docs/module-graph.i18n.yaml
  11. 6 3
      docs/module-graph.md
  12. 6 3
      docs/module-graph.zh.md
  13. 2 2
      docs/subsystems/settings.i18n.yaml
  14. 107 50
      docs/subsystems/settings.md
  15. 107 50
      docs/subsystems/settings.zh.md
  16. 2 2
      packages/api/settings-controller/README.i18n.yaml
  17. 1 1
      packages/api/settings-controller/README.md
  18. 1 1
      packages/api/settings-controller/README.zh.md
  19. 2 0
      packages/api/settings-controller/package.json
  20. 41 18
      packages/api/settings-controller/src/index.ts
  21. 44 0
      packages/api/settings-controller/tests/settings-controller.host.spec.ts
  22. 3 0
      packages/api/settings-controller/tsconfig.json
  23. 2 0
      packages/client/connection/src/client/fixture.ts
  24. 1 0
      packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
  25. 1 0
      packages/client/ui-permission-presets/tests/settings-store.client.spec.ts
  26. 6 0
      packages/client/ui-settings-models/tests/components.client.spec.tsx
  27. 1 0
      packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx
  28. 1 0
      packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
  29. 1 1
      packages/client/ui-settings-plugins/tests/stores.client.spec.ts
  30. 1 1
      packages/client/ui-settings/tests/settings-mirror.client.spec.ts
  31. 1 0
      packages/client/ui-settings/tests/settings-scope.client.spec.ts
  32. 2 2
      packages/core/scope/README.i18n.yaml
  33. 2 2
      packages/core/scope/README.md
  34. 2 2
      packages/core/scope/README.zh.md
  35. 42 0
      packages/core/scope/src/index.ts
  36. 27 0
      packages/core/scope/tests/scope.spec.ts
  37. 51 40
      packages/extensions/tool-cordis/src/api-catalog.ts
  38. 16 1
      packages/preset/agent-presets/src/index.ts
  39. 14 0
      packages/preset/agent-presets/tests/overlay.spec.ts
  40. 2 2
      packages/settings/settings-file/README.i18n.yaml
  41. 1 1
      packages/settings/settings-file/README.md
  42. 1 1
      packages/settings/settings-file/README.zh.md
  43. 41 15
      packages/settings/settings-file/src/index.ts
  44. 106 0
      packages/settings/settings-file/tests/scopes.spec.ts
  45. 2 2
      packages/settings/settings/README.i18n.yaml
  46. 12 9
      packages/settings/settings/README.md
  47. 12 9
      packages/settings/settings/README.zh.md
  48. 2 0
      packages/settings/settings/package.json
  49. 470 159
      packages/settings/settings/src/index.ts
  50. 3 3
      packages/settings/settings/src/invariant.ts
  51. 36 4
      packages/settings/settings/src/types.ts
  52. 11 6
      packages/settings/settings/tests/memory.ts
  53. 359 0
      packages/settings/settings/tests/scopes.spec.ts
  54. 3 0
      packages/settings/settings/tsconfig.json
  55. 2 2
      packages/skill/skill-filesystem/README.i18n.yaml
  56. 3 1
      packages/skill/skill-filesystem/README.md
  57. 3 1
      packages/skill/skill-filesystem/README.zh.md
  58. 7 0
      packages/skill/skill-filesystem/package.json
  59. 56 2
      packages/skill/skill-filesystem/src/index.ts
  60. 29 0
      packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts
  61. 2 1
      packages/skill/skill-filesystem/tsconfig.json
  62. 17 8
      pnpm-lock.yaml
  63. 1 0
      scripts/gen-cordis-catalog.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md
+2026-09-04-settings-namespaces-resolve-per-scope.md: 8275a2b2289a3bb6e31d4fc827585c742de19619
+2026-09-04-settings-namespaces-resolve-per-scope.zh.md: 04d5b2f179d4e84b744cc89a6735ceb01febb541

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md

@@ -0,0 +1,37 @@
+# Agent Note: A settings namespace resolves per scope
+
+Status: implemented
+
+English | [中文](2026-09-04-settings-namespaces-resolve-per-scope.zh.md)
+
+## Problem
+
+The settings seam registered a namespace once per process. That fit host rows, which exist once, and broke for agent presets, which mount the same plugin under several standing scopes: the second preset's `ctx.settings.register` threw "already registered" inside a `ctx.inject` continuation, the throw vanished into a nested fiber, and the second preset read the first's value. A person who wanted `skill-filesystem` to scan one extra root for the `research` preset only had no place to say so — the document had one section per namespace, and the plugin manager's per-preset configuration story needed one.
+
+## Decision
+
+**`dsh-scope` names scopes.** `createScope(ctx, key, { id })` records a stable name on the key, and `scopeIdOf(ctx)` answers the nearest named scope along a context's parent chain, so an agent's context resolves to the preset it joined. `dsh-agent-presets` names its standing scopes `preset/<id>`. Settings depends on `dsh-scope` alone and never on presets.
+
+**A namespace is a kind; a registration is an instance under a scope.** `register` reads the caller's scope through `scopeIdOf(this.ctx)` — the traceable service proxy binds `this.ctx` to the caller — and files the instance under it, the global scope being the absence of a name. Every registrant of a kind must carry the same schema envelope (`schema.toJSON()` compared with the seam's equality); a different one fails loud, as does a second instance under one scope. The first registrant's `validate` and `applies` are the kind's.
+
+**Four layers.** An instance resolves schema defaults, its own composition `base`, the document's global section, and its scope's section (`scopes.<id>.<ns>`) in that order, with the existing field-level merge. A global write re-resolves every instance of the kind, each gated on its own resolved value, so a scope that overrides the changed field is not disturbed; a scoped write commits that instance alone. Revisions are per section, kept off the registrations so a section written for a scope nothing has registered yet — a preset no session composed — is versioned like any other; such a write is accepted when the kind exists and is judged by the shared schema. `scopes` is a reserved namespace.
+
+**Describe per scope.** `describe()` answers one descriptor per kind under the global scope; `describe({ scope })` one per kind under a named scope, with `registered`, the scope's own `user` section, and `inherited` — the value without that section — so a surface can mark a field as overridden, inherited, or default. The controller's `describe(scope?)` and its three write verbs take the same optional trailing `scope`, and both events carry the scope as a trailing argument that is absent for the global instance, which keeps every existing listener and the forwarded-event carrier unchanged.
+
+**`skill-filesystem` is the first consumer.** Its `customSkillDirs` resolves through `installSection` with the composition value as base; a change replaces the provider's roots and invalidates the catalog, so a person adds a root for one preset from the settings document while the process runs.
+
+## Alternatives considered
+
+**A per-preset settings document.** Rejected: the document is one file with one provider; a second file per preset would need its own watcher, lock, and reload path for the same format.
+
+**Keying instances by the preset id inside the settings seam.** Rejected: settings would then know presets; a named scope is the general form, and a future named scope — a workspace, a team — costs nothing.
+
+**One registration per kind with the scope passed on every read.** Rejected: a plugin reads its handle without knowing it runs in a preset; the instance's context already says where it is.
+
+## Consequences
+
+Two presets mounting one plugin keep separate values, and the silent collision is gone. A settings surface can offer "all presets" and "this preset" over one namespace. The global `describe()` still answers one row per kind, so the existing settings page renders unchanged while the plugin manager's page (next PR) reads per scope. A kind's `validate` judges every instance, and a scope-only instance appears in the global view as `registered: false`.
+
+## Testing
+
+`packages/settings/settings/tests/scopes.spec.ts` pins registration under a named and an inherited scope, kind-level schema agreement, duplicate and reserved refusals, scoped resolution, global-write fan-out with deep-equal gating, scoped writes through the handle and the provider, per-section revisions and conflicts, writes to an unregistered scope, external scope edits, malformed scope sections, and the scoped and global describers with redaction. `packages/settings/settings-file/tests/scopes.spec.ts` pins the `scopes.<id>.<ns>` layout in YAML and JSON with comments intact. `packages/api/settings-controller/tests/settings-controller.host.spec.ts` pins the scoped Remote reads and writes; `packages/core/scope/tests/scope.spec.ts` the named-scope resolution; `packages/preset/agent-presets/tests/overlay.spec.ts` the preset scope name; `packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts` the live root change through settings.

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md

@@ -0,0 +1,37 @@
+# Agent Note:settings 命名空间按 scope 解析
+
+Status: implemented
+
+[English](2026-09-04-settings-namespaces-resolve-per-scope.md) | 中文
+
+## 问题
+
+settings seam 每个进程只允许注册一次命名空间。这对只存在一次的宿主行是对的,对 agent preset 却是坏的:同一个插件挂在多个常驻 scope 下,第二个 preset 的 `ctx.settings.register` 在 `ctx.inject` 延续里抛出"already registered",这个 throw 消失在嵌套 fiber 中,第二个 preset 读到的是第一个的值。想让 `skill-filesystem` 只为 `research` preset 多扫一个根目录的人无处可说——文档每个命名空间只有一段,而插件管理器的按 preset 配置需要这样一段。
+
+## 决定
+
+**`dsh-scope` 给 scope 起名。** `createScope(ctx, key, { id })` 在 key 上记录一个稳定的名字,`scopeIdOf(ctx)` 沿上下文的父链回答最近的有名 scope,于是 agent 的上下文解析到它加入的 preset。`dsh-agent-presets` 把常驻 scope 命名为 `preset/<id>`。settings 只依赖 `dsh-scope`,从不依赖 preset。
+
+**命名空间是一种 kind;注册是某个 scope 下的一个 instance。** `register` 经 `scopeIdOf(this.ctx)` 读取调用者的 scope——可追踪的服务代理把 `this.ctx` 绑到调用者——并把 instance 归档在其下,全局 scope 就是没有名字。同一 kind 的每个注册者必须带相同的 schema 信封(`schema.toJSON()` 用 seam 自己的相等谓词比较);不同的信封大声失败,同一 scope 下的第二个 instance 也是。第一个注册者的 `validate` 与 `applies` 属于 kind。
+
+**四层。** 一个 instance 按顺序解析 schema 默认值、自己的组合 `base`、文档的全局段、以及其 scope 的段(`scopes.<id>.<ns>`),沿用既有的字段级合并。全局写入重新解析该 kind 的每个 instance,各自按自身解析值门控,因此覆盖了被改字段的 scope 不会被打扰;scoped 写入只提交那个 instance。revision 按段记录,与注册分离,于是为尚无注册的 scope——还没有会话组合过的 preset——写入的段与其它段同样被版本化;只要 kind 存在,这样的写入就被接受并由共享 schema 判定。`scopes` 是保留的命名空间。
+
+**按 scope 描述。** `describe()` 在全局 scope 下每个 kind 回答一条描述符;`describe({ scope })` 在某个具名 scope 下每个 kind 回答一条,附带 `registered`、该 scope 自己的 `user` 段,以及 `inherited`——没有该段时的值——让界面能把字段标为已覆盖、继承或默认。controller 的 `describe(scope?)` 与三个写入动词接受同样的可选尾随 `scope`,两个事件都把 scope 作为尾随参数携带、全局 instance 时缺席,这让每个既有监听器与转发事件载体保持不变。
+
+**`skill-filesystem` 是第一个消费方。** 它的 `customSkillDirs` 以组合值为 base 经 `installSection` 解析;变化会替换 provider 的根目录并让目录失效,于是一个人可以在进程运行中从 settings 文档为某个 preset 添加一个根目录。
+
+## 考虑过的替代方案
+
+**每个 preset 一份 settings 文档。** 否决:文档是一个文件配一个 provider;每个 preset 再来一个文件,就要为同一格式再养一套 watcher、锁与重载路径。
+
+**在 settings seam 内以 preset id 作为 instance 的键。** 否决:settings 就此认识了 preset;具名 scope 是通用形式,将来的具名 scope——工作区、团队——不花任何代价。
+
+**每个 kind 只注册一次,每次读取传入 scope。** 否决:插件读取自己的句柄时并不知道自己跑在 preset 里;instance 的上下文已经说明它在哪。
+
+## 后果
+
+挂同一插件的两个 preset 各自保有值,静默碰撞消失。settings 界面可以在一个命名空间上提供"所有 preset"与"本 preset"。全局 `describe()` 仍然每个 kind 回答一行,因此既有设置页原样渲染,而插件管理器的页面(下一 PR)按 scope 读取。kind 的 `validate` 判定每个 instance,仅在 scope 下存在的 instance 在全局视图中呈现为 `registered: false`。
+
+## 测试
+
+`packages/settings/settings/tests/scopes.spec.ts` 钉住具名与继承 scope 下的注册、kind 级 schema 一致性、重复与保留名拒绝、scoped 解析、带 deep-equal 门控的全局写入扇出、经句柄与 provider 的 scoped 写入、按段的 revision 与冲突、对未注册 scope 的写入、外部 scope 编辑、畸形 scope 段,以及带脱敏的 scoped 与全局描述。`packages/settings/settings-file/tests/scopes.spec.ts` 钉住 YAML 与 JSON 中 `scopes.<id>.<ns>` 的布局且注释完好。`packages/api/settings-controller/tests/settings-controller.host.spec.ts` 钉住 scoped 的 Remote 读写;`packages/core/scope/tests/scope.spec.ts` 钉住具名 scope 解析;`packages/preset/agent-presets/tests/overlay.spec.ts` 钉住 preset 的 scope 名;`packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts` 钉住经 settings 的根目录在线变更。

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 17a9aafed2def073be11f738c1b02e9cde6aaf32
-config-catalog.zh.md: cb531c531d6fb80227af497a0168ca638c17faa8
+config-catalog.md: 1bc6497abbf1a7abf7b9aed26e512c88702ba1c8
+config-catalog.zh.md: ada410954aa2e30cf73dff1bf5dd96b9c7b27072

+ 1 - 1
docs/config-catalog.md

@@ -2156,7 +2156,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts)
+Source: [`packages/skill/skill-filesystem/src/index.ts:68`](../packages/skill/skill-filesystem/src/index.ts)
 
 <a id="deepseek-aidsh-spill-local"></a>
 

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

@@ -2158,7 +2158,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts)
+来源:[`packages/skill/skill-filesystem/src/index.ts:68`](../packages/skill/skill-filesystem/src/index.ts)
 
 <a id="deepseek-aidsh-spill-local"></a>
 

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 47aa3f6588c61b5843f1fd8c871192adc09efe28
-event-producer-consumer.zh.md: f83a5ea9f68276c3fa515e1bcb16287f7cddca91
+event-producer-consumer.md: 09c2dddca63ef98381b4f828cd9d54b00be2b54f
+event-producer-consumer.zh.md: a945d7f7ccc87beda0f2459f4e92435aceff98e1

+ 2 - 2
docs/event-producer-consumer.md

@@ -52,8 +52,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
-| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
-| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
+| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:137`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
+| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:123`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 2 - 2
docs/event-producer-consumer.zh.md

@@ -54,8 +54,8 @@
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
-| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
-| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
+| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:137`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
+| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:123`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 18a54edf93d052e87a7e0d5f7430cebc902f5c3a
-module-graph.zh.md: ff41b5e11862b75eb075d3752b59c7eef6909e07
+module-graph.md: a5106b0afc58a85a322247cfa41f696a561efdb6
+module-graph.zh.md: c740d26586ca371c07d97e88e4a2bb3cd25adaad

+ 6 - 3
docs/module-graph.md

@@ -445,6 +445,7 @@ flowchart TD
   pkg_session_projection --> pkg_session
   pkg_settings --> pkg_brand
   pkg_settings --> pkg_invariants
+  pkg_settings --> pkg_scope
   pkg_settings --> pkg_session
   pkg_session_snapshot --> pkg_http_proxy
   pkg_session_snapshot --> pkg_session
@@ -535,6 +536,7 @@ flowchart TD
   pkg_fs_observation_policy --> pkg_fs
   pkg_skill_filesystem --> pkg_fs
   pkg_skill_filesystem --> pkg_home_paths
+  pkg_skill_filesystem --> pkg_settings
   pkg_skill_filesystem --> pkg_skill
   pkg_web_search_deepseek --> pkg_agent
   pkg_web_search_deepseek --> pkg_credentials
@@ -908,6 +910,7 @@ flowchart TD
   pkg_api_settings_controller --> pkg_agent_presets
   pkg_api_settings_controller --> pkg_credentials
   pkg_api_settings_controller --> pkg_native_command
+  pkg_api_settings_controller --> pkg_scope
   pkg_api_settings_controller --> pkg_session
   pkg_api_settings_controller --> pkg_settings
   pkg_api_settings_controller --> pkg_typert_protocol
@@ -1287,7 +1290,7 @@ flowchart TD
 | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
 | [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
-| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
 | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
 | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
@@ -1309,7 +1312,7 @@ flowchart TD
 | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
 | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
 | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) |
-| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) |
+| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill) |
 | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) |
 | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
 | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
@@ -1383,7 +1386,7 @@ flowchart TD
 | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
 | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
+| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 6 - 3
docs/module-graph.zh.md

@@ -447,6 +447,7 @@ flowchart TD
   pkg_session_projection --> pkg_session
   pkg_settings --> pkg_brand
   pkg_settings --> pkg_invariants
+  pkg_settings --> pkg_scope
   pkg_settings --> pkg_session
   pkg_session_snapshot --> pkg_http_proxy
   pkg_session_snapshot --> pkg_session
@@ -537,6 +538,7 @@ flowchart TD
   pkg_fs_observation_policy --> pkg_fs
   pkg_skill_filesystem --> pkg_fs
   pkg_skill_filesystem --> pkg_home_paths
+  pkg_skill_filesystem --> pkg_settings
   pkg_skill_filesystem --> pkg_skill
   pkg_web_search_deepseek --> pkg_agent
   pkg_web_search_deepseek --> pkg_credentials
@@ -910,6 +912,7 @@ flowchart TD
   pkg_api_settings_controller --> pkg_agent_presets
   pkg_api_settings_controller --> pkg_credentials
   pkg_api_settings_controller --> pkg_native_command
+  pkg_api_settings_controller --> pkg_scope
   pkg_api_settings_controller --> pkg_session
   pkg_api_settings_controller --> pkg_settings
   pkg_api_settings_controller --> pkg_typert_protocol
@@ -1289,7 +1292,7 @@ flowchart TD
 | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
 | [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
-| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
 | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
 | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
@@ -1311,7 +1314,7 @@ flowchart TD
 | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
 | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
 | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) |
-| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) |
+| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill) |
 | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) |
 | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
 | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
@@ -1385,7 +1388,7 @@ flowchart TD
 | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
 | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
+| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 2 - 2
docs/subsystems/settings.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/settings.md
-settings.md: d8e3cbc46eb697315d4938b696e921a0c2e11828
-settings.zh.md: 772fd7832ff80cab9444c16c9a1dc7ae1875d51e
+settings.md: ef06544b500ec21234ce3922d9d7b7a30c3ecc2e
+settings.zh.md: b5537332933043df60a62b4ebd8c39af985ebaad

+ 107 - 50
docs/subsystems/settings.md

@@ -17,7 +17,7 @@ type SettingsNamespace = Branded<'SettingsNamespace'>
 
 ## Registration
 
-Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer, the owner's effect timing, and an optional check for what the schema cannot express.
+Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer, the owner's effect timing, and an optional check for what the schema cannot express. A namespace is one kind of setting; each registration is an instance of it under the caller's nearest named `dsh-scope` scope (an agent preset's `preset/<id>`, or the global scope), and every registrant of a kind shares its schema. An instance resolves schema defaults, its own `base`, the document's global section, and its scope's own section, in that order.
 
 ```ts type-equiv
 /** Registration options beyond the namespace schema. */
@@ -43,6 +43,9 @@ interface SettingsRegisterOptions<T> {
    * registration there is no last good value yet, so a stored section that
    * already fails rejects the registration itself — again exactly as a schema
    * failure does.
+   *
+   * The check belongs to the namespace kind: the first registrant's check
+   * judges every instance, because every instance is the same plugin.
    * @param value - the resolved section, schema-valid by construction.
    */
   validate?: (value: T) => void
@@ -65,7 +68,7 @@ The scope is the owner-facing handle. `update` merges a sparse patch over the us
 ```ts type-equiv
 /** Owner-facing handle for one registered namespace. */
 interface SettingsScope<T> {
-  /** Current resolved value: schema defaults, then `base`, then the user layer. */
+  /** Current resolved value: schema defaults, then `base`, then the user layers. */
   get(): T
   /**
    * Observe committed changes to this namespace's resolved value. Invocations
@@ -78,14 +81,15 @@ interface SettingsScope<T> {
    */
   watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
   /**
-   * Merge a partial patch into this namespace's user layer and persist it.
+   * Merge a partial patch into this registration's user section — the scope's
+   * own section for a scoped registration — and persist it.
    * @param patch - plain-object patch over the user section; JSON-compatible data
    * only (non-JSON values reject with their path before anything persists).
    */
   update(patch: object): Promise<void>
   /**
-   * Replace this namespace's user section wholesale; absent keys re-inherit
-   * the composition `base` and schema defaults (`replace({})` resets all).
+   * Replace this registration's user section wholesale; absent keys re-inherit
+   * the layers below (`replace({})` resets the section).
    * @param section - the complete next user section; JSON-compatible data only,
    * as for {@link update}.
    */
@@ -100,8 +104,18 @@ interface SettingsScope<T> {
 ```ts type-equiv
 /** One registered namespace as surfaced to configuration UIs. */
 interface SettingsDescriptor {
+  // TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the
+  // public API, provider contract, implementations, tests, and consumers.
   /** The registered namespace. */
   ns: SettingsNamespace
+  /** The named scope the descriptor resolves under; absent for the global scope. */
+  scope?: SettingsScopeId
+  /**
+   * Whether an owner registered the namespace under this scope. False for a
+   * scope described from the kind alone — a preset no session composed yet —
+   * whose value then carries no composition `base`.
+   */
+  registered: boolean
   /** Serialized schemastery schema (`schema.toJSON()`). */
   schema: unknown
   /** Current resolved value. */
@@ -116,8 +130,15 @@ interface SettingsDescriptor {
   /**
    * Raw user section from the stored document (detached), when one exists and
    * is well-formed; a field's presence here is what marks it user-overridden.
+   * For a scoped descriptor this is the scope's own section, not the global one.
    */
   user?: unknown
+  /**
+   * For a scoped descriptor: the value the scope resolves without its own
+   * user section — defaults, base, and the global section — so a surface can
+   * tell a field the scope overrides from one it inherits.
+   */
+  inherited?: unknown
   /** Owner's declared effect timing. */
   applies: SettingsApplies
   /** Schema-declared secret positions; present only under `redactSecrets`. */
@@ -149,6 +170,12 @@ interface SettingsDescribeOptions {
    * the verbatim default exists for same-process configuration UIs only.
    */
   redactSecrets?: boolean
+  /**
+   * Describe every namespace kind under this named scope instead of the
+   * global scope. A kind with no registration under the scope is described
+   * from the kind alone, `registered: false`.
+   */
+  scope?: string
 }
 ```
 
@@ -191,13 +218,20 @@ prepareDocument(): Promise<string | undefined>
 /**
  * Register a namespace schema and receive its owner scope. The registration
  * is an effect on the calling plugin's fiber: disposing that fiber removes
- * the namespace and its observers. An invalid stored section fails the
+ * the instance and its observers. An invalid stored section fails the
  * registration itself — the earliest point where the schema can judge it.
- * @param ns - unique namespace; duplicate registration fails loud.
+ *
+ * The instance registers under the caller's nearest named scope: a plugin
+ * mounted inside an agent preset resolves that preset's section over the
+ * global one, and two presets mounting the same plugin hold two instances
+ * of one kind. A second registrant of a namespace must carry the same
+ * schema envelope; a different one is a different setting under a taken
+ * name and fails loud.
+ * @param ns - the namespace; a second registration under the same scope fails loud.
  * @param schema - schemastery schema resolving this namespace's value.
  * @param options - composition `base` layer and effect timing.
  * @returns the owner scope for reads, observation, and updates.
- * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
+ * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier or is reserved.
  */
 register<const Namespace extends string, T>( ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, options?: SettingsRegisterOptions<T>, ): SettingsScope<T>
 
@@ -215,63 +249,78 @@ register<const Namespace extends string, T>( ns: Namespace & SettingsNamespaceIn
 installSection<const Namespace extends string, T>( owner: Context, ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, entry: T, hooks: SettingsSectionHooks<T>, ): void
 
 /**
- * Describe every registered namespace for configuration surfaces, including
- * the composition `base` and raw user layers so a form can mark which fields
- * the user overrode (presence in `user`) and what a reset returns to.
- * @param options - redaction switch; wire surfaces must redact.
- * @returns one descriptor per registered namespace, in registration order.
+ * Describe every namespace kind for configuration surfaces, under the
+ * global scope or one named scope: the composition `base` and raw user
+ * layers so a form can mark which fields the user overrode (presence in
+ * `user`) and what a reset returns to, and for a scoped read the
+ * `inherited` value the scope's own section is layered over. A kind with
+ * no instance under the requested scope is described from the kind alone.
+ * @param options - redaction switch (wire surfaces must redact) and scope.
+ * @returns one descriptor per namespace kind, in registration order.
+ * @throws {TypeError} when `scope` is not a well-formed scope id.
  */
 describe(options?: SettingsDescribeOptions): SettingsDescriptor[]
 
+/**
+ * Every named scope some namespace is registered under, in first-seen order.
+ * @returns the scope ids.
+ */
+scopes(): SettingsScopeId[]
+
 /**
  * Read one registered namespace's resolved value.
  * @param ns - the namespace to read.
- * @returns the resolved value, or `undefined` while unregistered.
+ * @param scope - the named scope of the instance; the global instance when omitted.
+ * @returns the resolved value, or `undefined` while unregistered under that scope.
  * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
  */
-get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>): unknown
+get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>, scope?: string): unknown
 
 /**
- * Merge a patch into one registered namespace's user layer, validate the
- * resolved candidate, persist through the provider, then commit and emit.
- * A validation failure rejects before anything is persisted. Writes to one
- * namespace are serialized: concurrent updates apply in call order, each
- * merging over the previous write's committed section.
+ * Merge a patch into one namespace's user section, validate the resolved
+ * candidates, persist through the provider, then commit and emit. A
+ * validation failure rejects before anything is persisted. Writes to one
+ * section are serialized: concurrent updates apply in call order, each
+ * merging over the previous write's committed section. A global write
+ * re-resolves every instance of the kind; a scoped write only that scope's.
  * @param ns - the registered namespace to update.
  * @param patch - plain-object patch over the user section.
  * @param expectedRevision - the descriptor `revision` the caller read; a
- *   namespace that moved past it rejects with {@link SettingsConflictError}.
+ *   section that moved past it rejects with {@link SettingsConflictError}.
+ * @param scope - the named scope whose section to write; the global section when omitted.
  * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
  */
-async update<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, patch: object, expectedRevision?: number, ): Promise<void>
+async update<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, patch: object, expectedRevision?: number, scope?: string, ): Promise<void>
 
 /**
- * Replace one registered namespace's user section wholesale, validate,
- * persist, then commit and emit. Keys absent from `section` fall back to the
- * composition `base` and schema defaults — this is the removal/reset path a
- * merge-only patch cannot express (`replace({})` re-inherits everything).
+ * Replace one namespace's user section wholesale, validate, persist, then
+ * commit and emit. Keys absent from `section` fall back to the layers
+ * below — this is the removal/reset path a merge-only patch cannot express
+ * (`replace({})` re-inherits everything).
  * @param ns - the registered namespace to replace.
  * @param section - the complete next user section.
  * @param expectedRevision - the descriptor `revision` the caller read; a
- *   namespace that moved past it rejects with {@link SettingsConflictError}.
+ *   section that moved past it rejects with {@link SettingsConflictError}.
+ * @param scope - the named scope whose section to write; the global section when omitted.
  * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
  */
-async replace<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, section: object, expectedRevision?: number, ): Promise<void>
+async replace<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, section: object, expectedRevision?: number, scope?: string, ): Promise<void>
 
 /**
- * Apply path-addressed edits to one registered namespace's user section,
- * validate, persist, then commit and emit. The ops are applied to the
- * section as it stands when the write reaches the front of the queue, so a
- * caller never has to restate fields it did not touch — and, crucially,
- * cannot delete fields it never saw. This is the write path for any caller
- * holding a redacted view; `replace` remains the wholesale reset.
+ * Apply path-addressed edits to one namespace's user section, validate,
+ * persist, then commit and emit. The ops are applied to the section as it
+ * stands when the write reaches the front of the queue, so a caller never
+ * has to restate fields it did not touch — and, crucially, cannot delete
+ * fields it never saw. This is the write path for any caller holding a
+ * redacted view; `replace` remains the wholesale reset.
  * @param ns - the registered namespace to edit.
  * @param ops - ordered path edits; later ops observe earlier ones.
  * @param expectedRevision - the descriptor `revision` the caller read; a
- *   namespace that moved past it rejects with {@link SettingsConflictError}.
+ *   section that moved past it rejects with {@link SettingsConflictError}.
+ * @param scope - the named scope whose section to write; the global section when omitted.
  * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
  */
-async mutate<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, ops: readonly SettingsPathOp[], expectedRevision?: number, ): Promise<void>
+async mutate<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, ops: readonly SettingsPathOp[], expectedRevision?: number, scope?: string, ): Promise<void>
 ```
 
 Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts)
@@ -284,12 +333,15 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote
 
 ```ts cordis-catalog
 /**
- * Describe every registered namespace for a configuration page: redacted
- * layered values plus the serialized schema the page renders its form from.
- * @returns provider writability, local-document presence, and one view per namespace.
- * @throws RemoteError when no settings provider is mounted.
+ * Describe every namespace kind for a configuration page: redacted layered
+ * values plus the serialized schema the page renders its form from, under
+ * the global scope or one named scope (an agent preset's `preset/<id>`).
+ * @param scope - the named scope to describe; the global scope when omitted.
+ * @returns provider writability, local-document presence, one view per
+ * namespace kind, and every scope some namespace is registered under.
+ * @throws RemoteError when no settings provider is mounted or the scope id is malformed.
  */
-@Remote describe(): SettingsDescribeValue
+@Remote describe(scope?: string): SettingsDescribeValue
 
 /**
  * Report whether this deployment can open an authored Agent preset directory natively.
@@ -302,20 +354,22 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote
  * @param ns - namespace key to write.
  * @param patch - fields to merge into the user section.
  * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
- * @returns the namespace's redacted view after the write.
+ * @param scope - the named scope whose section to write; the global section when omitted.
+ * @returns the namespace's redacted view under that scope after the write.
  * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
  */
-@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>
+@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>
 
 /**
  * Replace one namespace's stored user section wholesale.
  * @param ns - namespace key to write.
  * @param section - complete replacement user section.
  * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
- * @returns the namespace's redacted view after the write.
+ * @param scope - the named scope whose section to write; the global section when omitted.
+ * @returns the namespace's redacted view under that scope after the write.
  * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
  */
-@Remote replace( ns: string, section: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>
+@Remote replace( ns: string, section: Record<string, JsonValue>, expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>
 
 /**
  * Apply path-addressed edits to one namespace's user section, resolved against
@@ -324,10 +378,11 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote
  * @param ns - namespace key to write.
  * @param ops - the edits to apply, in order.
  * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
- * @returns the namespace's redacted view after the write.
+ * @param scope - the named scope whose section to write; the global section when omitted.
+ * @returns the namespace's redacted view under that scope after the write.
  * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
  */
-@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>
+@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>
 
 /**
  * Materialize the provider-owned settings document and open it in a native text editor.
@@ -368,10 +423,11 @@ One registered namespace's RAW user section changed, whether or not the resolved
  * resolved value, different meaning) and that their held revision is
  * stale. Listener containment matches `settings/updated`.
  * @param ns - the namespace whose stored section changed.
- * @param revision - the namespace's new revision.
+ * @param revision - the section's new revision.
+ * @param scope - the named scope whose section changed; absent for the global section.
  * @mode emit
  */
-'settings/document-updated'(ns: SettingsNamespace, revision: number): void
+'settings/document-updated'(ns: SettingsNamespace, revision: number, scope?: SettingsScopeId): void
 ```
 
 Source: [`packages/settings/settings/src/types.ts`](../../packages/settings/settings/src/types.ts)
@@ -396,9 +452,10 @@ Committed change to one registered namespace's resolved value. Emitted after the
  * @param next - the new resolved value.
  * @param prev - the previous resolved value.
  * @param source - whether the change entered through `update()` or the provider.
+ * @param scope - the named scope whose registration changed; absent for the global scope.
  * @mode emit
  */
-'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
+'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource, scope?: SettingsScopeId): void
 ```
 
 Source: [`packages/settings/settings/src/types.ts`](../../packages/settings/settings/src/types.ts)

+ 107 - 50
docs/subsystems/settings.zh.md

@@ -17,7 +17,7 @@ type SettingsNamespace = Branded<'SettingsNamespace'>
 
 ## 注册
 
-注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose(资源释放)该 fiber 即移除 namespace 及其观察者。options 携带组合层、owner 的生效时机,以及一个可选的、用于校验 schema 表达不了的约束的钩子。
+注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose(资源释放)该 fiber 即移除 namespace 及其观察者。options 携带组合层、owner 的生效时机,以及一个可选的、用于校验 schema 表达不了的约束的钩子。namespace 是一种设置的 kind;每次注册是它在调用方最近的具名 `dsh-scope` 作用域(agent preset 的 `preset/<id>`,或全局作用域)下的一个 instance,同一 kind 的每个注册者共享其 schema。instance 按顺序解析 schema 默认值、自己的 `base`、文档的全局分节与其作用域自己的分节。
 
 ```ts type-equiv
 /** Registration options beyond the namespace schema. */
@@ -43,6 +43,9 @@ interface SettingsRegisterOptions<T> {
    * registration there is no last good value yet, so a stored section that
    * already fails rejects the registration itself — again exactly as a schema
    * failure does.
+   *
+   * The check belongs to the namespace kind: the first registrant's check
+   * judges every instance, because every instance is the same plugin.
    * @param value - the resolved section, schema-valid by construction.
    */
   validate?: (value: T) => void
@@ -65,7 +68,7 @@ scope 是面向 owner 的句柄。`update` 把稀疏 patch 只合并进用户分
 ```ts type-equiv
 /** Owner-facing handle for one registered namespace. */
 interface SettingsScope<T> {
-  /** Current resolved value: schema defaults, then `base`, then the user layer. */
+  /** Current resolved value: schema defaults, then `base`, then the user layers. */
   get(): T
   /**
    * Observe committed changes to this namespace's resolved value. Invocations
@@ -78,14 +81,15 @@ interface SettingsScope<T> {
    */
   watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
   /**
-   * Merge a partial patch into this namespace's user layer and persist it.
+   * Merge a partial patch into this registration's user section — the scope's
+   * own section for a scoped registration — and persist it.
    * @param patch - plain-object patch over the user section; JSON-compatible data
    * only (non-JSON values reject with their path before anything persists).
    */
   update(patch: object): Promise<void>
   /**
-   * Replace this namespace's user section wholesale; absent keys re-inherit
-   * the composition `base` and schema defaults (`replace({})` resets all).
+   * Replace this registration's user section wholesale; absent keys re-inherit
+   * the layers below (`replace({})` resets the section).
    * @param section - the complete next user section; JSON-compatible data only,
    * as for {@link update}.
    */
@@ -100,8 +104,18 @@ interface SettingsScope<T> {
 ```ts type-equiv
 /** One registered namespace as surfaced to configuration UIs. */
 interface SettingsDescriptor {
+  // TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the
+  // public API, provider contract, implementations, tests, and consumers.
   /** The registered namespace. */
   ns: SettingsNamespace
+  /** The named scope the descriptor resolves under; absent for the global scope. */
+  scope?: SettingsScopeId
+  /**
+   * Whether an owner registered the namespace under this scope. False for a
+   * scope described from the kind alone — a preset no session composed yet —
+   * whose value then carries no composition `base`.
+   */
+  registered: boolean
   /** Serialized schemastery schema (`schema.toJSON()`). */
   schema: unknown
   /** Current resolved value. */
@@ -116,8 +130,15 @@ interface SettingsDescriptor {
   /**
    * Raw user section from the stored document (detached), when one exists and
    * is well-formed; a field's presence here is what marks it user-overridden.
+   * For a scoped descriptor this is the scope's own section, not the global one.
    */
   user?: unknown
+  /**
+   * For a scoped descriptor: the value the scope resolves without its own
+   * user section — defaults, base, and the global section — so a surface can
+   * tell a field the scope overrides from one it inherits.
+   */
+  inherited?: unknown
   /** Owner's declared effect timing. */
   applies: SettingsApplies
   /** Schema-declared secret positions; present only under `redactSecrets`. */
@@ -149,6 +170,12 @@ interface SettingsDescribeOptions {
    * the verbatim default exists for same-process configuration UIs only.
    */
   redactSecrets?: boolean
+  /**
+   * Describe every namespace kind under this named scope instead of the
+   * global scope. A kind with no registration under the scope is described
+   * from the kind alone, `registered: false`.
+   */
+  scope?: string
 }
 ```
 
@@ -191,13 +218,20 @@ prepareDocument(): Promise<string | undefined>
 /**
  * Register a namespace schema and receive its owner scope. The registration
  * is an effect on the calling plugin's fiber: disposing that fiber removes
- * the namespace and its observers. An invalid stored section fails the
+ * the instance and its observers. An invalid stored section fails the
  * registration itself — the earliest point where the schema can judge it.
- * @param ns - unique namespace; duplicate registration fails loud.
+ *
+ * The instance registers under the caller's nearest named scope: a plugin
+ * mounted inside an agent preset resolves that preset's section over the
+ * global one, and two presets mounting the same plugin hold two instances
+ * of one kind. A second registrant of a namespace must carry the same
+ * schema envelope; a different one is a different setting under a taken
+ * name and fails loud.
+ * @param ns - the namespace; a second registration under the same scope fails loud.
  * @param schema - schemastery schema resolving this namespace's value.
  * @param options - composition `base` layer and effect timing.
  * @returns the owner scope for reads, observation, and updates.
- * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
+ * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier or is reserved.
  */
 register<const Namespace extends string, T>( ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, options?: SettingsRegisterOptions<T>, ): SettingsScope<T>
 
@@ -215,63 +249,78 @@ register<const Namespace extends string, T>( ns: Namespace & SettingsNamespaceIn
 installSection<const Namespace extends string, T>( owner: Context, ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, entry: T, hooks: SettingsSectionHooks<T>, ): void
 
 /**
- * Describe every registered namespace for configuration surfaces, including
- * the composition `base` and raw user layers so a form can mark which fields
- * the user overrode (presence in `user`) and what a reset returns to.
- * @param options - redaction switch; wire surfaces must redact.
- * @returns one descriptor per registered namespace, in registration order.
+ * Describe every namespace kind for configuration surfaces, under the
+ * global scope or one named scope: the composition `base` and raw user
+ * layers so a form can mark which fields the user overrode (presence in
+ * `user`) and what a reset returns to, and for a scoped read the
+ * `inherited` value the scope's own section is layered over. A kind with
+ * no instance under the requested scope is described from the kind alone.
+ * @param options - redaction switch (wire surfaces must redact) and scope.
+ * @returns one descriptor per namespace kind, in registration order.
+ * @throws {TypeError} when `scope` is not a well-formed scope id.
  */
 describe(options?: SettingsDescribeOptions): SettingsDescriptor[]
 
+/**
+ * Every named scope some namespace is registered under, in first-seen order.
+ * @returns the scope ids.
+ */
+scopes(): SettingsScopeId[]
+
 /**
  * Read one registered namespace's resolved value.
  * @param ns - the namespace to read.
- * @returns the resolved value, or `undefined` while unregistered.
+ * @param scope - the named scope of the instance; the global instance when omitted.
+ * @returns the resolved value, or `undefined` while unregistered under that scope.
  * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
  */
-get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>): unknown
+get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>, scope?: string): unknown
 
 /**
- * Merge a patch into one registered namespace's user layer, validate the
- * resolved candidate, persist through the provider, then commit and emit.
- * A validation failure rejects before anything is persisted. Writes to one
- * namespace are serialized: concurrent updates apply in call order, each
- * merging over the previous write's committed section.
+ * Merge a patch into one namespace's user section, validate the resolved
+ * candidates, persist through the provider, then commit and emit. A
+ * validation failure rejects before anything is persisted. Writes to one
+ * section are serialized: concurrent updates apply in call order, each
+ * merging over the previous write's committed section. A global write
+ * re-resolves every instance of the kind; a scoped write only that scope's.
  * @param ns - the registered namespace to update.
  * @param patch - plain-object patch over the user section.
  * @param expectedRevision - the descriptor `revision` the caller read; a
- *   namespace that moved past it rejects with {@link SettingsConflictError}.
+ *   section that moved past it rejects with {@link SettingsConflictError}.
+ * @param scope - the named scope whose section to write; the global section when omitted.
  * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
  */
-async update<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, patch: object, expectedRevision?: number, ): Promise<void>
+async update<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, patch: object, expectedRevision?: number, scope?: string, ): Promise<void>
 
 /**
- * Replace one registered namespace's user section wholesale, validate,
- * persist, then commit and emit. Keys absent from `section` fall back to the
- * composition `base` and schema defaults — this is the removal/reset path a
- * merge-only patch cannot express (`replace({})` re-inherits everything).
+ * Replace one namespace's user section wholesale, validate, persist, then
+ * commit and emit. Keys absent from `section` fall back to the layers
+ * below — this is the removal/reset path a merge-only patch cannot express
+ * (`replace({})` re-inherits everything).
  * @param ns - the registered namespace to replace.
  * @param section - the complete next user section.
  * @param expectedRevision - the descriptor `revision` the caller read; a
- *   namespace that moved past it rejects with {@link SettingsConflictError}.
+ *   section that moved past it rejects with {@link SettingsConflictError}.
+ * @param scope - the named scope whose section to write; the global section when omitted.
  * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
  */
-async replace<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, section: object, expectedRevision?: number, ): Promise<void>
+async replace<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, section: object, expectedRevision?: number, scope?: string, ): Promise<void>
 
 /**
- * Apply path-addressed edits to one registered namespace's user section,
- * validate, persist, then commit and emit. The ops are applied to the
- * section as it stands when the write reaches the front of the queue, so a
- * caller never has to restate fields it did not touch — and, crucially,
- * cannot delete fields it never saw. This is the write path for any caller
- * holding a redacted view; `replace` remains the wholesale reset.
+ * Apply path-addressed edits to one namespace's user section, validate,
+ * persist, then commit and emit. The ops are applied to the section as it
+ * stands when the write reaches the front of the queue, so a caller never
+ * has to restate fields it did not touch — and, crucially, cannot delete
+ * fields it never saw. This is the write path for any caller holding a
+ * redacted view; `replace` remains the wholesale reset.
  * @param ns - the registered namespace to edit.
  * @param ops - ordered path edits; later ops observe earlier ones.
  * @param expectedRevision - the descriptor `revision` the caller read; a
- *   namespace that moved past it rejects with {@link SettingsConflictError}.
+ *   section that moved past it rejects with {@link SettingsConflictError}.
+ * @param scope - the named scope whose section to write; the global section when omitted.
  * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
  */
-async mutate<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, ops: readonly SettingsPathOp[], expectedRevision?: number, ): Promise<void>
+async mutate<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, ops: readonly SettingsPathOp[], expectedRevision?: number, scope?: string, ): Promise<void>
 ```
 
 Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts)
@@ -284,12 +333,15 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote
 
 ```ts cordis-catalog
 /**
- * Describe every registered namespace for a configuration page: redacted
- * layered values plus the serialized schema the page renders its form from.
- * @returns provider writability, local-document presence, and one view per namespace.
- * @throws RemoteError when no settings provider is mounted.
+ * Describe every namespace kind for a configuration page: redacted layered
+ * values plus the serialized schema the page renders its form from, under
+ * the global scope or one named scope (an agent preset's `preset/<id>`).
+ * @param scope - the named scope to describe; the global scope when omitted.
+ * @returns provider writability, local-document presence, one view per
+ * namespace kind, and every scope some namespace is registered under.
+ * @throws RemoteError when no settings provider is mounted or the scope id is malformed.
  */
-@Remote describe(): SettingsDescribeValue
+@Remote describe(scope?: string): SettingsDescribeValue
 
 /**
  * Report whether this deployment can open an authored Agent preset directory natively.
@@ -302,20 +354,22 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote
  * @param ns - namespace key to write.
  * @param patch - fields to merge into the user section.
  * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
- * @returns the namespace's redacted view after the write.
+ * @param scope - the named scope whose section to write; the global section when omitted.
+ * @returns the namespace's redacted view under that scope after the write.
  * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
  */
-@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>
+@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>
 
 /**
  * Replace one namespace's stored user section wholesale.
  * @param ns - namespace key to write.
  * @param section - complete replacement user section.
  * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
- * @returns the namespace's redacted view after the write.
+ * @param scope - the named scope whose section to write; the global section when omitted.
+ * @returns the namespace's redacted view under that scope after the write.
  * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
  */
-@Remote replace( ns: string, section: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>
+@Remote replace( ns: string, section: Record<string, JsonValue>, expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>
 
 /**
  * Apply path-addressed edits to one namespace's user section, resolved against
@@ -324,10 +378,11 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote
  * @param ns - namespace key to write.
  * @param ops - the edits to apply, in order.
  * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
- * @returns the namespace's redacted view after the write.
+ * @param scope - the named scope whose section to write; the global section when omitted.
+ * @returns the namespace's redacted view under that scope after the write.
  * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
  */
-@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>
+@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>
 
 /**
  * Materialize the provider-owned settings document and open it in a native text editor.
@@ -368,10 +423,11 @@ One registered namespace's RAW user section changed, whether or not the resolved
  * resolved value, different meaning) and that their held revision is
  * stale. Listener containment matches `settings/updated`.
  * @param ns - the namespace whose stored section changed.
- * @param revision - the namespace's new revision.
+ * @param revision - the section's new revision.
+ * @param scope - the named scope whose section changed; absent for the global section.
  * @mode emit
  */
-'settings/document-updated'(ns: SettingsNamespace, revision: number): void
+'settings/document-updated'(ns: SettingsNamespace, revision: number, scope?: SettingsScopeId): void
 ```
 
 Source: [`packages/settings/settings/src/types.ts`](../../packages/settings/settings/src/types.ts)
@@ -396,9 +452,10 @@ Committed change to one registered namespace's resolved value. Emitted after the
  * @param next - the new resolved value.
  * @param prev - the previous resolved value.
  * @param source - whether the change entered through `update()` or the provider.
+ * @param scope - the named scope whose registration changed; absent for the global scope.
  * @mode emit
  */
-'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
+'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource, scope?: SettingsScopeId): void
 ```
 
 Source: [`packages/settings/settings/src/types.ts`](../../packages/settings/settings/src/types.ts)

+ 2 - 2
packages/api/settings-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/settings-controller/README.md
-README.md: 761e4c43751675eec141a2efcf85caf52cc91590
-README.zh.md: 541c9deb3dbc35914e4e95abdda2af5cdce2bbd0
+README.md: 3a458bbb0cd20730cb530195883d13afc601dbe7
+README.zh.md: 554cd258d0c2789b4a379714cd8023f4cbf72118

+ 1 - 1
packages/api/settings-controller/README.md

@@ -27,7 +27,7 @@ Mount this package as a Loader entry in a profile that serves browser configurat
 
 `describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. Secret values cross in this direction only: no method here returns one.
 
-`settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`.
+`settings.describe(scope?)` returns deployment facts and every namespace kind under `redactSecrets: true` — under the global scope, or under one named scope such as an agent preset's `preset/<id>`, where each view says whether an owner registered the namespace there and carries the `inherited` value the scope's own section is layered over — together with every scope some namespace is registered under. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations, take the same optional trailing `scope`, and return the namespace's new redacted view under that scope; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`.
 
 `settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text-editor intent. `settings.canOpenAgentPresetDirectory()` reports native-opening availability when the preset page becomes visible. `settings.openAgentPresetDirectory(id)` resolves only a user-authored preset and either opens its directory or returns the path when native opening is unavailable; neither open method accepts a browser-supplied filesystem target.
 

+ 1 - 1
packages/api/settings-controller/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 `describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——provider 返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把 provider 拒绝报告为 `credential-rejected`,携带 provider 的消息,details 中只有该引用。密钥值只在这个方向跨越 wire:这里没有任何方法会返回它。
 
-`settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings service 的三种写入操作,并返回该 namespace 的新脱敏视图;过期写入使用 `settings-conflict`,其他 provider 拒绝使用 `settings-rejected`。
+`settings.describe(scope?)` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace kind——在全局 scope 下,或在某个具名 scope(如 agent preset 的 `preset/<id>`)下,此时每条视图都说明该 scope 下是否有 owner 注册了此 namespace,并携带该 scope 自己分节所叠加的 `inherited` 值——连同所有有 namespace 注册于其下的 scope。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings service 的三种写入操作,接受同样的可选尾随 `scope`,并返回该 namespace 在该 scope 下的新脱敏视图;过期写入使用 `settings-conflict`,其他 provider 拒绝使用 `settings-rejected`。
 
 `settings.openSettingsDocument()` 准备 provider 持有的文档,并用原生文本编辑器意图将其打开。`settings.canOpenAgentPresetDirectory()` 在 preset 页面显示时报告原生打开能力。`settings.openAgentPresetDirectory(id)` 只解析用户创作的 preset,并在原生打开不可用时返回目录路径;两个打开方法都不接受浏览器提供的文件系统目标。
 

+ 2 - 0
packages/api/settings-controller/package.json

@@ -52,6 +52,7 @@
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-credentials": "workspace:^",
     "@deepseek-ai/dsh-native-command": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^"
@@ -61,6 +62,7 @@
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-credentials": "workspace:^",
     "@deepseek-ai/dsh-native-command": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^"

+ 41 - 18
packages/api/settings-controller/src/index.ts

@@ -30,7 +30,7 @@ import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from '.
 export { CredentialsController } from './credentials.ts'
 export type * from './types.ts'
 
-const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) })
+const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1), scope: z.string().min(1).optional() })
 
 /** Native document-opening policy. */
 export interface Config {
@@ -61,10 +61,13 @@ export interface SettingsControllerInternals {
 function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
   return {
     ns: String(descriptor.ns),
+    ...descriptor.scope === undefined ? {} : { scope: String(descriptor.scope) },
+    registered: descriptor.registered,
     schema: descriptor.schema as JsonValue,
     value: descriptor.value as JsonValue,
     ...descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue },
     ...descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue },
+    ...descriptor.inherited === undefined ? {} : { inherited: descriptor.inherited as JsonValue },
     applies: descriptor.applies,
     secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
     revision: descriptor.revision,
@@ -108,18 +111,29 @@ export class SettingsController extends TypertRemoteService {
   }
 
   /**
-   * Describe every registered namespace for a configuration page: redacted
-   * layered values plus the serialized schema the page renders its form from.
-   * @returns provider writability, local-document presence, and one view per namespace.
-   * @throws RemoteError when no settings provider is mounted.
+   * Describe every namespace kind for a configuration page: redacted layered
+   * values plus the serialized schema the page renders its form from, under
+   * the global scope or one named scope (an agent preset's `preset/<id>`).
+   * @param scope - the named scope to describe; the global scope when omitted.
+   * @returns provider writability, local-document presence, one view per
+   * namespace kind, and every scope some namespace is registered under.
+   * @throws RemoteError when no settings provider is mounted or the scope id is malformed.
    */
   @Remote
-  describe(): SettingsDescribeValue {
+  describe(scope?: string): SettingsDescribeValue {
     const settings = this.provider()
+    let views: SettingsNamespaceView[]
+    try {
+      views = settings.describe({ redactSecrets: true, ...scope === undefined ? {} : { scope } }).map(namespaceView)
+    } catch (error: unknown) {
+      throw new RemoteError('gateway/bad-request', `invalid scope for settings.describe: ${messageOf(error)}`, {}, { cause: error })
+    }
     return {
       writable: settings.writable,
       hasDocument: settings.documentPath !== undefined,
-      namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
+      namespaces: views,
+      ...scope === undefined ? {} : { scope },
+      scopes: settings.scopes().map(String),
     }
   }
 
@@ -137,7 +151,8 @@ export class SettingsController extends TypertRemoteService {
    * @param ns - namespace key to write.
    * @param patch - fields to merge into the user section.
    * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
-   * @returns the namespace's redacted view after the write.
+   * @param scope - the named scope whose section to write; the global section when omitted.
+   * @returns the namespace's redacted view under that scope after the write.
    * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
    */
   @Remote
@@ -145,8 +160,9 @@ export class SettingsController extends TypertRemoteService {
     ns: string,
     patch: Record<string, JsonValue>,
     expectedRevision: number | undefined,
+    scope?: string,
   ): Promise<SettingsNamespaceView> {
-    return this.write(ns, 'update', patch, expectedRevision)
+    return this.write(ns, 'update', patch, expectedRevision, scope)
   }
 
   /**
@@ -154,7 +170,8 @@ export class SettingsController extends TypertRemoteService {
    * @param ns - namespace key to write.
    * @param section - complete replacement user section.
    * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
-   * @returns the namespace's redacted view after the write.
+   * @param scope - the named scope whose section to write; the global section when omitted.
+   * @returns the namespace's redacted view under that scope after the write.
    * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
    */
   @Remote
@@ -162,8 +179,9 @@ export class SettingsController extends TypertRemoteService {
     ns: string,
     section: Record<string, JsonValue>,
     expectedRevision: number | undefined,
+    scope?: string,
   ): Promise<SettingsNamespaceView> {
-    return this.write(ns, 'replace', section, expectedRevision)
+    return this.write(ns, 'replace', section, expectedRevision, scope)
   }
 
   /**
@@ -173,7 +191,8 @@ export class SettingsController extends TypertRemoteService {
    * @param ns - namespace key to write.
    * @param ops - the edits to apply, in order.
    * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
-   * @returns the namespace's redacted view after the write.
+   * @param scope - the named scope whose section to write; the global section when omitted.
+   * @returns the namespace's redacted view under that scope after the write.
    * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
    */
   @Remote
@@ -181,8 +200,9 @@ export class SettingsController extends TypertRemoteService {
     ns: string,
     ops: SettingsPathOpView[],
     expectedRevision: number | undefined,
+    scope?: string,
   ): Promise<SettingsNamespaceView> {
-    return this.write(ns, 'mutate', ops, expectedRevision)
+    return this.write(ns, 'mutate', ops, expectedRevision, scope)
   }
 
   /**
@@ -262,21 +282,24 @@ export class SettingsController extends TypertRemoteService {
     mode: 'update' | 'replace' | 'mutate',
     input: Record<string, JsonValue> | SettingsPathOpView[],
     expectedRevision: number | undefined,
+    scope: string | undefined,
   ): Promise<SettingsNamespaceView> {
-    const parsed = settingsNamespaceRequestSchema.safeParse({ ns })
+    const parsed = settingsNamespaceRequestSchema.safeParse({ ns, ...scope === undefined ? {} : { scope } })
     if (!parsed.success) {
       throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues })
     }
     const settings = this.provider()
     const namespace = parsed.data.ns
     try {
-      if (mode === 'update') await settings.update(namespace, input, expectedRevision)
-      else if (mode === 'replace') await settings.replace(namespace, input, expectedRevision)
-      else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision)
+      if (mode === 'update') await settings.update(namespace, input, expectedRevision, scope)
+      else if (mode === 'replace') await settings.replace(namespace, input, expectedRevision, scope)
+      else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision, scope)
     } catch (error: unknown) {
       throw rejected(ns, error)
     }
-    const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === namespace)
+    const descriptor = settings
+      .describe({ redactSecrets: true, ...scope === undefined ? {} : { scope } })
+      .find(candidate => candidate.ns === namespace)
     if (descriptor === undefined) {
       // The write committed but the namespace vanished before this read: only a
       // concurrent registrant disposal can produce it.

+ 44 - 0
packages/api/settings-controller/tests/settings-controller.host.spec.ts

@@ -432,3 +432,47 @@ describe('the settings Remote namespace a configuration page calls', () => {
       .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: desktop unavailable' })
   })
 })
+
+describe('scoped reads and writes', () => {
+  it('describes one named scope from the kind, lists every registered scope, and refuses a malformed scope id', async () => {
+    const { controller, ctx } = await boot(MemorySettings, { doc: { [NS]: { preference: 'dark' } } })
+    const { createScope } = await import('@deepseek-ai/dsh-scope')
+    let scoped!: Awaited<ReturnType<typeof createScope>>
+    await ctx.plugin((host: Context) => { scoped = createScope(host, { id: 'standard' }, { id: 'preset/standard' }) })
+    await scoped.ctx.plugin({ inject: ['settings'], apply(inner: Context) { inner.settings.register(NS, Profile, { base: { preference: 'light' } }) } })
+
+    const global = controller.describe()
+    expect(global.scopes).toEqual(['preset/standard'])
+    expect(global.scope).toBeUndefined()
+    expect(global.namespaces[0]).toMatchObject({ ns: NS, registered: true, value: { preference: 'dark' } })
+
+    const standard = controller.describe('preset/standard')
+    expect(standard.scope).toBe('preset/standard')
+    expect(standard.namespaces[0]).toMatchObject({
+      ns: NS, scope: 'preset/standard', registered: true, base: { preference: 'light' }, inherited: { preference: 'dark' }, value: { preference: 'dark' },
+    })
+    const other = controller.describe('preset/other')
+    expect(other.namespaces[0]).toMatchObject({ ns: NS, scope: 'preset/other', registered: false, inherited: { preference: 'dark' } })
+    expect(other.namespaces[0]?.base).toBeUndefined()
+
+    const failure = await Promise.resolve().then(() => controller.describe('Not Valid')).catch((error: unknown) => error)
+    expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
+  })
+
+  it('writes one scope\'s section and answers with that scope\'s view', async () => {
+    const { controller, ctx } = await boot(MemorySettings, { doc: { [NS]: { preference: 'dark' } } })
+    const provider = ctx.get('settings') as MemorySettings
+
+    const updated = await controller.update(NS, { preference: 'light' }, undefined, 'preset/standard')
+    expect(updated).toMatchObject({ ns: NS, scope: 'preset/standard', registered: false, value: { preference: 'light' }, user: { preference: 'light' }, revision: 1 })
+    expect(provider.doc).toEqual({ [NS]: { preference: 'dark' }, scopes: { 'preset/standard': { [NS]: { preference: 'light' } } } })
+
+    const replaced = await controller.replace(NS, {}, 1, 'preset/standard')
+    expect(replaced).toMatchObject({ scope: 'preset/standard', value: { preference: 'dark' }, revision: 2 })
+    const mutated = await controller.mutate(NS, [{ op: 'set', path: ['preference'], value: 'light' }], undefined, 'preset/standard')
+    expect(mutated).toMatchObject({ scope: 'preset/standard', value: { preference: 'light' }, revision: 3 })
+
+    const failure = await controller.update(NS, {}, undefined, '').catch((error: unknown) => error)
+    expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
+  })
+})

+ 3 - 0
packages/api/settings-controller/tsconfig.json

@@ -26,6 +26,9 @@
     {
       "path": "../../util/native-command"
     },
+    {
+      "path": "../../core/scope"
+    },
     {
       "path": "../../settings/settings"
     },

+ 2 - 0
packages/client/connection/src/client/fixture.ts

@@ -1844,8 +1844,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         value: {
           writable: true,
           hasDocument: true,
+          scopes: [],
           namespaces: [{
             ns: 'llm-deepseek',
+            registered: true,
             schema: {},
             value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
             applies: 'live',

+ 1 - 0
packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx

@@ -34,6 +34,7 @@ const SCHEMA = {
 function view(defaultPreset: string, revision = 0): SettingsNamespaceView {
   return {
     ns: 'permission',
+    registered: true,
     schema: SCHEMA,
     value: { defaultPreset },
     base: { defaultPreset: 'read-only' },

+ 1 - 0
packages/client/ui-permission-presets/tests/settings-store.client.spec.ts

@@ -27,6 +27,7 @@ function resolveDefault(view: SettingsNamespaceView) {
 function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView {
   return {
     ns: 'permission',
+    registered: true,
     schema,
     value: { defaultPreset },
     base: { defaultPreset: 'read-only' },

+ 6 - 0
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -94,6 +94,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
   return [
     {
       ns: 'llm-deepseek',
+      registered: true,
       schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
       value: {
         apiKeyEnv: 'DEEPSEEK_API_KEY',
@@ -110,6 +111,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
     },
     {
       ns: 'llm-plain',
+      registered: true,
       schema: JSON.parse(JSON.stringify(Schema.object({
         profiles: Schema.dict(Schema.object({ note: Schema.string() })),
       }).toJSON())) as JsonValue,
@@ -120,6 +122,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
     },
     {
       ns: 'llm-pi-ai',
+      registered: true,
       schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as JsonValue,
       value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
       user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
@@ -129,6 +132,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
     },
     {
       ns: 'subagent-model-selection',
+      registered: true,
       schema: JSON.parse(JSON.stringify(Schema.object({ enabled: Schema.boolean().default(false) }).toJSON())) as JsonValue,
       value: { enabled: false },
       applies: 'live',
@@ -749,6 +753,7 @@ describe('ModelsSection', () => {
     const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
     const overridden: SettingsNamespaceView = {
       ns: 'llm-deepseek',
+      registered: true,
       schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
       value: { ...stored, defaultContextWindow: 1_000_000 },
       ...base === undefined ? {} : { base },
@@ -981,6 +986,7 @@ describe('ModelsSection', () => {
     const { face } = scriptedFace()
     const bare: SettingsNamespaceView = {
       ns: 'llm-deepseek',
+      registered: true,
       schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
       value: {},
       applies: 'live',

+ 1 - 0
packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx

@@ -48,6 +48,7 @@ function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
   const value = apiKeyEnv === null ? {} : { apiKeyEnv }
   return {
     ns: 'llm-deepseek',
+    registered: true,
     schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
     value,
     base: value,

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

@@ -80,6 +80,7 @@ function piAiNamespace(
 ): SettingsNamespaceView {
   return {
     ns: 'llm-pi-ai',
+    registered: true,
     schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as JsonValue,
     // `value` is the effective section; `user` is only the layer this page
     // writes. They differ whenever a composition `base` supplies something.

+ 1 - 1
packages/client/ui-settings-plugins/tests/stores.client.spec.ts

@@ -1137,7 +1137,7 @@ describe('ConfigurablePluginsTabController', () => {
         writable: true,
         hasDocument: true,
         namespaces: [{
-          ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1,
+          ns: 'bash', registered: true, schema: {}, value: {}, applies: 'live', secrets: [], revision: 1,
         }],
       },
       error: null,

+ 1 - 1
packages/client/ui-settings/tests/settings-mirror.client.spec.ts

@@ -22,7 +22,7 @@ function ctxWith(describeCall: unknown) {
 }
 
 function view(ns: string, revision = 0): SettingsNamespaceView {
-  return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision }
+  return { ns, registered: true, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision }
 }
 
 function described(namespaces: SettingsNamespaceView[]): Answer<SettingsDescribeView> {

+ 1 - 0
packages/client/ui-settings/tests/settings-scope.client.spec.ts

@@ -42,6 +42,7 @@ function ctxWith(settings: object) {
 function view(value: JsonValue, revision = 0): SettingsNamespaceView {
   return {
     ns: 'ui-test',
+    registered: true,
     // `toJSON()` already produced the wire envelope; its declared type is the
     // schema builder's, so one cast names what the Host actually sends.
     schema: ENVELOPE as unknown as JsonValue,

+ 2 - 2
packages/core/scope/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/scope/README.md
-README.md: 20027403a88ddd5cd1f85ec5b907a8c88a260da7
-README.zh.md: f98eb5faaa618a5f3b2f0c9859da2320d6f6b2e0
+README.md: e5c6e6b172f75debfc3a5afdaafd84a3dc670e63
+README.zh.md: 7cf486ba69da9e7d8566dbf0e6914d6f74ec64c9

+ 2 - 2
packages/core/scope/README.md

@@ -28,7 +28,7 @@ Plugin authors use `dsh-scope` to give one agent (or one group) its own registra
 
 ### Mint a scope
 
-`createScope(ctx, key)` creates a scope under `ctx`'s fiber: its `ctx` carries the scope tag, and everything registered through it is both scope-visible and scope-lifetime. `dispose()` unwinds every registration through the scope; `rawDispose` is the exact Cordis disposer for nesting the teardown in an ordered composite effect.
+`createScope(ctx, key)` creates a scope under `ctx`'s fiber: its `ctx` carries the scope tag, and everything registered through it is both scope-visible and scope-lifetime. `dispose()` unwinds every registration through the scope; `rawDispose` is the exact Cordis disposer for nesting the teardown in an ordered composite effect. Pass `id` (`createScope(ctx, key, { id: 'preset/standard' })`) to name the scope: `scopeIdOf(ctx)` then answers the nearest named scope along a context's parent chain — an agent's context resolves to the preset it joined — which is the name consumers keeping per-scope state outside the process, such as the settings document, address it by; `scopeIdOfKey(key)` reads one key's own name.
 
 ```text
 const scope = createScope(ctx, agent)
@@ -63,7 +63,7 @@ The registration context determines both visibility and ownership: a registratio
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | `createScope`, `scopeOf`, `scopeTarget`, `bindScopeParent`/`scopeParentOf`/`scopeChainOf`, carrier marks |
+| [`src/index.ts`](src/index.ts) | `createScope` (with named scopes), `scopeOf`, `scopeIdOf`/`scopeIdOfKey`, `scopeTarget`, `bindScopeParent`/`scopeParentOf`/`scopeChainOf`, carrier marks |
 | [`src/store.ts`](src/store.ts) | `ScopedLayers`, `NamedEntries`, `AnonymousEntries`, `ScopeLayer` |
 | [`src/invariant.ts`](src/invariant.ts) | Invariant companion over the generated scoped-event map |
 | [`src/scoped-events.generated.ts`](src/scoped-events.generated.ts) | Generated resolver map of declared scoped events |

+ 2 - 2
packages/core/scope/README.zh.md

@@ -28,7 +28,7 @@ kind: "package-library"
 
 ### 创建作用域
 
-`createScope(ctx, key)` 在 `ctx` 的 fiber 下创建作用域:其 `ctx` 携带作用域标签,通过它进行的每项注册既具备作用域可见性,也服从作用域生命周期。`dispose()` 撤销通过该作用域进行的每项注册;`rawDispose` 是确切 Cordis disposer,用于把 teardown 嵌套进有序组合 effect。
+`createScope(ctx, key)` 在 `ctx` 的 fiber 下创建作用域:其 `ctx` 携带作用域标签,通过它进行的每项注册既具备作用域可见性,也服从作用域生命周期。`dispose()` 撤销通过该作用域进行的每项注册;`rawDispose` 是确切 Cordis disposer,用于把 teardown 嵌套进有序组合 effect。传入 `id`(`createScope(ctx, key, { id: 'preset/standard' })`)给作用域起名:此后 `scopeIdOf(ctx)` 沿上下文的父链回答最近的具名作用域——agent 的上下文解析到它加入的 preset——这正是把按作用域状态放在进程之外(如 settings 文档)的消费方用来寻址它的名字;`scopeIdOfKey(key)` 读取单个 key 自己的名字。
 
 ```text
 const scope = createScope(ctx, agent)
@@ -63,7 +63,7 @@ await scope.dispose()   // unwinds every registration made through scope.ctx
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | `createScope`、`scopeOf`、`scopeTarget`、`bindScopeParent`/`scopeParentOf`/`scopeChainOf`、载体标记 |
+| [`src/index.ts`](src/index.ts) | `createScope`(含具名作用域)、`scopeOf`、`scopeIdOf`/`scopeIdOfKey`、`scopeTarget`、`bindScopeParent`/`scopeParentOf`/`scopeChainOf`、载体标记 |
 | [`src/store.ts`](src/store.ts) | `ScopedLayers`、`NamedEntries`、`AnonymousEntries`、`ScopeLayer` |
 | [`src/invariant.ts`](src/invariant.ts) | 基于生成的作用域事件映射的不变式配套 |
 | [`src/scoped-events.generated.ts`](src/scoped-events.generated.ts) | 已声明带作用域事件的生成解析器映射 |

+ 42 - 0
packages/core/scope/src/index.ts

@@ -120,10 +120,23 @@ async function quiesceFiber(fiber: Fiber): Promise<void> {
 /** Shared no-op plugin used as the backing scope fiber. */
 function scope(): void {}
 
+/**
+ * The stable name of each named scope key. A name is what a process-external
+ * record — a settings document section, a log line — can refer to a scope by,
+ * where the key object itself cannot travel.
+ */
+const scopeIds = new WeakMap<ScopeKey, string>()
+
 /** Options accepted by {@link createScope}. */
 export interface CreateScopeOptions {
   /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */
   parent?: ScopeKey
+  /**
+   * Stable name for the scope, such as `preset/standard`. Named scopes are
+   * addressable by consumers that keep per-scope state outside the process
+   * ({@link scopeIdOf}); an unnamed scope is addressable by its key alone.
+   */
+  id?: string
 }
 
 /**
@@ -136,6 +149,10 @@ export interface CreateScopeOptions {
  */
 export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {
   if (options?.parent !== undefined) bindScopeParent(key, options.parent)
+  if (options?.id !== undefined) {
+    if (options.id.length === 0) throw new Error('dsh-scope: a scope id must not be empty')
+    scopeIds.set(key, options.id)
+  }
   const fiber = ctx.plugin(scope)
   const scoped: Context = fiber.ctx.extend({ [kScope]: key })
   let disposing: Promise<void> | undefined
@@ -155,6 +172,31 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
   return (ctx as Context & { [kScope]?: ScopeKey })[kScope]
 }
 
+/**
+ * The name of one scope key, when {@link createScope} gave it one.
+ * @param key - the scope key to name.
+ * @returns the id, or `undefined` for an unnamed key.
+ */
+export function scopeIdOfKey(key: ScopeKey): string | undefined {
+  return scopeIds.get(key)
+}
+
+/**
+ * The nearest named scope a context belongs to: its own scope when that is
+ * named, else the nearest named ancestor along the parent chain
+ * ({@link bindScopeParent}). An agent's context therefore resolves to the
+ * preset it joined, which is the name per-scope state is kept under.
+ * @param ctx - context to inspect.
+ * @returns the nearest named scope's id, or `undefined` when none of the chain is named.
+ */
+export function scopeIdOf(ctx: Context): string | undefined {
+  for (const key of scopeChainOf(scopeOf(ctx))) {
+    const id = scopeIds.get(key)
+    if (id !== undefined) return id
+  }
+  return undefined
+}
+
 /**
  * Build an opaque receiver that preserves the base filter, admits untagged
  * listeners globally, and admits tagged listeners for a matching key or any

+ 27 - 0
packages/core/scope/tests/scope.spec.ts

@@ -220,3 +220,30 @@ describe('scope parent chain', () => {
     expect(seen.sort()).toEqual(['preset', 'untagged'])
   })
 })
+
+describe('named scopes', () => {
+  it('names a scope and resolves the nearest named scope along the chain', async () => {
+    const { createScope: mint, scopeIdOf: idOf, scopeIdOfKey: idOfKey } = await import('@deepseek-ai/dsh-scope')
+    const ctx = new Context()
+    const presetKey = { name: 'preset' }
+    const agentKey = { name: 'agent' }
+    const nestedKey = { name: 'nested' }
+    let preset!: Scope
+    await ctx.plugin((inner: Context) => { preset = mint(inner, presetKey, { id: 'preset/standard' }) })
+    const agent = mint(preset.ctx, agentKey, { parent: presetKey })
+    const nested = mint(agent.ctx, nestedKey, { parent: agentKey, id: 'session/one' })
+
+    expect(idOfKey(presetKey)).toBe('preset/standard')
+    expect(idOfKey(agentKey)).toBeUndefined()
+    expect(idOf(ctx)).toBeUndefined()
+    expect(idOf(preset.ctx)).toBe('preset/standard')
+    // An unnamed scope resolves to its nearest named ancestor; a named one to itself.
+    expect(idOf(agent.ctx)).toBe('preset/standard')
+    expect(idOf(nested.ctx)).toBe('session/one')
+    expect(() => mint(ctx, { name: 'empty' }, { id: '' })).toThrow('must not be empty')
+
+    await nested.dispose()
+    await agent.dispose()
+    await preset.dispose()
+  })
+})

+ 51 - 40
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1995,10 +1995,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'register<const Namespace extends string, T>( ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, options?: SettingsRegisterOptions<T>, ): SettingsScope<T>',
-        description: 'Register a namespace schema and receive its owner scope. The registration is an effect on the calling plugin\'s fiber: disposing that fiber removes the namespace and its observers. An invalid stored section fails the registration itself — the earliest point where the schema can judge it.',
-        parameters: [{ name: 'ns', description: 'unique namespace; duplicate registration fails loud.' }, { name: 'schema', description: 'schemastery schema resolving this namespace\'s value.' }, { name: 'options', description: 'composition `base` layer and effect timing.' }],
+        description: 'Register a namespace schema and receive its owner scope. The registration is an effect on the calling plugin\'s fiber: disposing that fiber removes the instance and its observers. An invalid stored section fails the registration itself — the earliest point where the schema can judge it.\n\nThe instance registers under the caller\'s nearest named scope: a plugin mounted inside an agent preset resolves that preset\'s section over the global one, and two presets mounting the same plugin hold two instances of one kind. A second registrant of a namespace must carry the same schema envelope; a different one is a different setting under a taken name and fails loud.',
+        parameters: [{ name: 'ns', description: 'the namespace; a second registration under the same scope fails loud.' }, { name: 'schema', description: 'schemastery schema resolving this namespace\'s value.' }, { name: 'options', description: 'composition `base` layer and effect timing.' }],
         returns: 'the owner scope for reads, observation, and updates.',
-        throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'],
+        throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier or is reserved.'],
       },
       {
         signature: 'installSection<const Namespace extends string, T>( owner: Context, ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, entry: T, hooks: SettingsSectionHooks<T>, ): void',
@@ -2008,33 +2008,40 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'describe(options?: SettingsDescribeOptions): SettingsDescriptor[]',
-        description: 'Describe every registered namespace for configuration surfaces, including the composition `base` and raw user layers so a form can mark which fields the user overrode (presence in `user`) and what a reset returns to.',
-        parameters: [{ name: 'options', description: 'redaction switch; wire surfaces must redact.' }],
-        returns: 'one descriptor per registered namespace, in registration order.',
+        description: 'Describe every namespace kind for configuration surfaces, under the global scope or one named scope: the composition `base` and raw user layers so a form can mark which fields the user overrode (presence in `user`) and what a reset returns to, and for a scoped read the `inherited` value the scope\'s own section is layered over. A kind with no instance under the requested scope is described from the kind alone.',
+        parameters: [{ name: 'options', description: 'redaction switch (wire surfaces must redact) and scope.' }],
+        returns: 'one descriptor per namespace kind, in registration order.',
+        throws: ['{TypeError} when `scope` is not a well-formed scope id.'],
+      },
+      {
+        signature: 'scopes(): SettingsScopeId[]',
+        description: 'Every named scope some namespace is registered under, in first-seen order.',
+        parameters: [],
+        returns: 'the scope ids.',
       },
       {
-        signature: 'get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>): unknown',
+        signature: 'get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>, scope?: string): unknown',
         description: 'Read one registered namespace\'s resolved value.',
-        parameters: [{ name: 'ns', description: 'the namespace to read.' }],
-        returns: 'the resolved value, or `undefined` while unregistered.',
+        parameters: [{ name: 'ns', description: 'the namespace to read.' }, { name: 'scope', description: 'the named scope of the instance; the global instance when omitted.' }],
+        returns: 'the resolved value, or `undefined` while unregistered under that scope.',
         throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'],
       },
       {
-        signature: 'async update<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, patch: object, expectedRevision?: number, ): Promise<void>',
-        description: 'Merge a patch into one registered namespace\'s user layer, validate the resolved candidate, persist through the provider, then commit and emit. A validation failure rejects before anything is persisted. Writes to one namespace are serialized: concurrent updates apply in call order, each merging over the previous write\'s committed section.',
-        parameters: [{ name: 'ns', description: 'the registered namespace to update.' }, { name: 'patch', description: 'plain-object patch over the user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a namespace that moved past it rejects with {@link SettingsConflictError}.' }],
+        signature: 'async update<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, patch: object, expectedRevision?: number, scope?: string, ): Promise<void>',
+        description: 'Merge a patch into one namespace\'s user section, validate the resolved candidates, persist through the provider, then commit and emit. A validation failure rejects before anything is persisted. Writes to one section are serialized: concurrent updates apply in call order, each merging over the previous write\'s committed section. A global write re-resolves every instance of the kind; a scoped write only that scope\'s.',
+        parameters: [{ name: 'ns', description: 'the registered namespace to update.' }, { name: 'patch', description: 'plain-object patch over the user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a section that moved past it rejects with {@link SettingsConflictError}.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }],
         throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'],
       },
       {
-        signature: 'async replace<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, section: object, expectedRevision?: number, ): Promise<void>',
-        description: 'Replace one registered namespace\'s user section wholesale, validate, persist, then commit and emit. Keys absent from `section` fall back to the composition `base` and schema defaults — this is the removal/reset path a merge-only patch cannot express (`replace({})` re-inherits everything).',
-        parameters: [{ name: 'ns', description: 'the registered namespace to replace.' }, { name: 'section', description: 'the complete next user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a namespace that moved past it rejects with {@link SettingsConflictError}.' }],
+        signature: 'async replace<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, section: object, expectedRevision?: number, scope?: string, ): Promise<void>',
+        description: 'Replace one namespace\'s user section wholesale, validate, persist, then commit and emit. Keys absent from `section` fall back to the layers below — this is the removal/reset path a merge-only patch cannot express (`replace({})` re-inherits everything).',
+        parameters: [{ name: 'ns', description: 'the registered namespace to replace.' }, { name: 'section', description: 'the complete next user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a section that moved past it rejects with {@link SettingsConflictError}.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }],
         throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'],
       },
       {
-        signature: 'async mutate<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, ops: readonly SettingsPathOp[], expectedRevision?: number, ): Promise<void>',
-        description: 'Apply path-addressed edits to one registered namespace\'s user section, validate, persist, then commit and emit. The ops are applied to the section as it stands when the write reaches the front of the queue, so a caller never has to restate fields it did not touch — and, crucially, cannot delete fields it never saw. This is the write path for any caller holding a redacted view; `replace` remains the wholesale reset.',
-        parameters: [{ name: 'ns', description: 'the registered namespace to edit.' }, { name: 'ops', description: 'ordered path edits; later ops observe earlier ones.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a namespace that moved past it rejects with {@link SettingsConflictError}.' }],
+        signature: 'async mutate<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, ops: readonly SettingsPathOp[], expectedRevision?: number, scope?: string, ): Promise<void>',
+        description: 'Apply path-addressed edits to one namespace\'s user section, validate, persist, then commit and emit. The ops are applied to the section as it stands when the write reaches the front of the queue, so a caller never has to restate fields it did not touch — and, crucially, cannot delete fields it never saw. This is the write path for any caller holding a redacted view; `replace` remains the wholesale reset.',
+        parameters: [{ name: 'ns', description: 'the registered namespace to edit.' }, { name: 'ops', description: 'ordered path edits; later ops observe earlier ones.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a section that moved past it rejects with {@link SettingsConflictError}.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }],
         throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'],
       },
     ],
@@ -2045,11 +2052,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     description: 'Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role(\'secret\')` field cannot ride a response. Writes expose the settings service\'s merge, replacement, and path-addressed operations, and classify every provider refusal as `settings/conflict` or `settings/rejected` with the service\'s message.',
     methods: [
       {
-        signature: '@Remote describe(): SettingsDescribeValue',
-        description: 'Describe every registered namespace for a configuration page: redacted layered values plus the serialized schema the page renders its form from.',
-        parameters: [],
-        returns: 'provider writability, local-document presence, and one view per namespace.',
-        throws: ['RemoteError when no settings provider is mounted.'],
+        signature: '@Remote describe(scope?: string): SettingsDescribeValue',
+        description: 'Describe every namespace kind for a configuration page: redacted layered values plus the serialized schema the page renders its form from, under the global scope or one named scope (an agent preset\'s `preset/<id>`).',
+        parameters: [{ name: 'scope', description: 'the named scope to describe; the global scope when omitted.' }],
+        returns: 'provider writability, local-document presence, one view per namespace kind, and every scope some namespace is registered under.',
+        throws: ['RemoteError when no settings provider is mounted or the scope id is malformed.'],
       },
       {
         signature: '@Remote canOpenAgentPresetDirectory(): boolean',
@@ -2058,24 +2065,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'true when the matching open operation is available.',
       },
       {
-        signature: '@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>',
+        signature: '@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>',
         description: 'Merge a patch into one namespace\'s stored user section.',
-        parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'patch', description: 'fields to merge into the user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }],
-        returns: 'the namespace\'s redacted view after the write.',
+        parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'patch', description: 'fields to merge into the user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }],
+        returns: 'the namespace\'s redacted view under that scope after the write.',
         throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'],
       },
       {
-        signature: '@Remote replace( ns: string, section: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>',
+        signature: '@Remote replace( ns: string, section: Record<string, JsonValue>, expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>',
         description: 'Replace one namespace\'s stored user section wholesale.',
-        parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'section', description: 'complete replacement user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }],
-        returns: 'the namespace\'s redacted view after the write.',
+        parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'section', description: 'complete replacement user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }],
+        returns: 'the namespace\'s redacted view under that scope after the write.',
         throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'],
       },
       {
-        signature: '@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>',
+        signature: '@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>',
         description: 'Apply path-addressed edits to one namespace\'s user section, resolved against the section as stored rather than against whatever the caller last read, then answer with that namespace\'s new redacted view.',
-        parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'ops', description: 'the edits to apply, in order.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }],
-        returns: 'the namespace\'s redacted view after the write.',
+        parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'ops', description: 'the edits to apply, in order.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }],
+        returns: 'the namespace\'s redacted view under that scope after the write.',
         throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'],
       },
       {
@@ -3311,18 +3318,18 @@ export const EVENT_API: readonly EventApiEntry[] = [
   {
     name: 'settings/document-updated',
     mode: 'emit',
-    signature: '\'settings/document-updated\'(ns: SettingsNamespace, revision: number): void',
+    signature: '\'settings/document-updated\'(ns: SettingsNamespace, revision: number, scope?: SettingsScopeId): void',
     summary: 'One registered namespace\'s RAW user section changed, whether or not the resolved value did.',
     description: 'One registered namespace\'s RAW user section changed, whether or not the resolved value did. `settings/updated` is the consumer-facing event and stays deep-equal-gated; this one exists for configuration surfaces, which must learn that a field went from inherited to overridden (same resolved value, different meaning) and that their held revision is stale. Listener containment matches `settings/updated`.',
-    parameters: [{ name: 'ns', description: 'the namespace whose stored section changed.' }, { name: 'revision', description: 'the namespace\'s new revision.' }],
+    parameters: [{ name: 'ns', description: 'the namespace whose stored section changed.' }, { name: 'revision', description: 'the section\'s new revision.' }, { name: 'scope', description: 'the named scope whose section changed; absent for the global section.' }],
   },
   {
     name: 'settings/updated',
     mode: 'emit',
-    signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void',
+    signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource, scope?: SettingsScopeId): void',
     summary: 'Committed change to one registered namespace\'s resolved value.',
     description: 'Committed change to one registered namespace\'s resolved value. Emitted after the provider persisted (for `update`) or published (`provider`) the change; never emitted when the resolved value is deep-equal. Listener failures are contained and logged — a sync throw and an async rejection alike — except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.',
-    parameters: [{ name: 'ns', description: 'the namespace whose resolved value changed.' }, { name: 'next', description: 'the new resolved value.' }, { name: 'prev', description: 'the previous resolved value.' }, { name: 'source', description: 'whether the change entered through `update()` or the provider.' }],
+    parameters: [{ name: 'ns', description: 'the namespace whose resolved value changed.' }, { name: 'next', description: 'the new resolved value.' }, { name: 'prev', description: 'the previous resolved value.' }, { name: 'source', description: 'whether the change entered through `update()` or the provider.' }, { name: 'scope', description: 'the named scope whose registration changed; absent for the global scope.' }],
   },
   {
     name: 'skills/change',
@@ -5450,15 +5457,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SettingsDescribeOptions',
-    declaration: 'export interface SettingsDescribeOptions {\n    redactSecrets?: boolean;\n}',
+    declaration: 'export interface SettingsDescribeOptions {\n    redactSecrets?: boolean;\n    scope?: string;\n}',
   },
   {
     name: 'SettingsDescribeValue',
-    declaration: 'export interface SettingsDescribeValue {\n    writable: boolean;\n    hasDocument: boolean;\n    namespaces: SettingsNamespaceView[];\n}',
+    declaration: 'export interface SettingsDescribeValue {\n    writable: boolean;\n    hasDocument: boolean;\n    namespaces: SettingsNamespaceView[];\n    scope?: string;\n    scopes: string[];\n}',
   },
   {
     name: 'SettingsDescriptor',
-    declaration: 'export interface SettingsDescriptor {\n    ns: SettingsNamespace;\n    schema: unknown;\n    value: unknown;\n    revision: number;\n    base?: unknown;\n    user?: unknown;\n    applies: SettingsApplies;\n    secrets?: RedactedSecret[];\n}',
+    declaration: 'export interface SettingsDescriptor {\n    ns: SettingsNamespace;\n    scope?: SettingsScopeId;\n    registered: boolean;\n    schema: unknown;\n    value: unknown;\n    revision: number;\n    base?: unknown;\n    user?: unknown;\n    inherited?: unknown;\n    applies: SettingsApplies;\n    secrets?: RedactedSecret[];\n}',
   },
   {
     name: 'SettingsDocumentOpenValue',
@@ -5470,7 +5477,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SettingsNamespaceView',
-    declaration: 'export interface SettingsNamespaceView {\n    ns: string;\n    schema: JsonValue;\n    value: JsonValue;\n    base?: JsonValue;\n    user?: JsonValue;\n    applies: \'live\' | \'restart\';\n    secrets: SettingsSecretView[];\n    revision: number;\n}',
+    declaration: 'export interface SettingsNamespaceView {\n    ns: string;\n    scope?: string;\n    registered: boolean;\n    inherited?: JsonValue;\n    schema: JsonValue;\n    value: JsonValue;\n    base?: JsonValue;\n    user?: JsonValue;\n    applies: \'live\' | \'restart\';\n    secrets: SettingsSecretView[];\n    revision: number;\n}',
   },
   {
     name: 'SettingsPathOp',
@@ -5484,6 +5491,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SettingsRegisterOptions',
     declaration: 'export interface SettingsRegisterOptions<T> {\n    base?: Partial<T>;\n    applies?: SettingsApplies;\n    validate?: (value: T) => void;\n}',
   },
+  {
+    name: 'SettingsScopeId',
+    declaration: 'export type SettingsScopeId = Branded<\'SettingsScopeId\'>;',
+  },
   {
     name: 'SettingsSecretView',
     declaration: 'export interface SettingsSecretView {\n    path: string[];\n    set: boolean;\n}',

+ 16 - 1
packages/preset/agent-presets/src/index.ts

@@ -60,6 +60,19 @@ export { overlayFacts } from './composition-inventory.ts'
 /** Settings namespace carrying the user's chosen default preset. */
 export const SETTINGS_NAMESPACE = 'agent-presets'
 
+/** The `dsh-scope` name prefix of a preset's standing scope. */
+export const PRESET_SCOPE_PREFIX = 'preset/'
+
+/**
+ * The named-scope id of one preset's standing composition, under which the
+ * plugins it mounts register their settings.
+ * @param presetId - the preset id.
+ * @returns `preset/<id>`.
+ */
+export function presetScopeId(presetId: string): string {
+  return `${PRESET_SCOPE_PREFIX}${presetId}`
+}
+
 /** Refuse an empty preset id before invoking a domain operation. */
 function validatePresetId(value: string, field: 'agentPreset' | 'from'): void {
   if (value.length === 0) {
@@ -823,7 +836,9 @@ export class AgentPresets extends TypertRemoteService {
         return reused
       }
       const key: ScopeKey = { agentPreset: preset.id }
-      const scope = createScope(this.selfCtx, key)
+      // Named, so a plugin mounted in the preset registers its settings under
+      // `preset/<id>` and per-scope state outside the process can address it.
+      const scope = createScope(this.selfCtx, key, { id: presetScopeId(preset.id) })
       try {
         if (stamp === undefined) {
           const reason = `composition file is unreadable: ${preset.path}`

+ 14 - 0
packages/preset/agent-presets/tests/overlay.spec.ts

@@ -340,3 +340,17 @@ describe('authoring', () => {
     await expect(ctx.agentPresets.removeOverlay('standard')).rejects.toMatchObject({ code: 'agent-preset/read-only' })
   })
 })
+
+describe('the standing scope', () => {
+  it('is named after the preset, so plugins it mounts register settings under preset/<id>', async () => {
+    const { scopeIdOf } = await import('@deepseek-ai/dsh-scope')
+    const { presetScopeId } = await import('@deepseek-ai/dsh-agent-presets')
+    const root = await userRoot()
+    const ctx = await harness(rosterOver(root))
+
+    const agent = await agentOn(ctx, 'sess-named-scope', 'standard')
+
+    expect(presetScopeId('standard')).toBe('preset/standard')
+    expect(scopeIdOf(agent.ctx)).toBe('preset/standard')
+  })
+})

+ 2 - 2
packages/settings/settings-file/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/settings/settings-file/README.md
-README.md: ea578a1739e1bd5d7b7f47be596998b124337ed5
-README.zh.md: 376d985a454e90402bddd7664e343bfe7df23c7c
+README.md: 9e2fbd414f0778b76f4a093a756760ac7cc0328f
+README.zh.md: ec708d68e4004af161ef4846a928f44f78b31eb6

+ 1 - 1
packages/settings/settings-file/README.md

@@ -50,7 +50,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### Editing the document
 
-The document is a YAML or JSON mapping of namespace to user section. Users can edit it directly: any change takes effect automatically, and deleting the file resets every namespace to defaults and `base`. A document that exists but is invalid fails plugin load at boot — the provider never silently ignores or overwrites it. Once live, an unreadable or unparsable edit warns and keeps the last good sections, so a hand-edit mistake cannot take the process down.
+The document is a YAML or JSON mapping of namespace to user section, plus a reserved `scopes` map holding each named scope's own sections — `scopes.preset/standard.skill-filesystem` is the section the `standard` preset resolves over the global `skill-filesystem` one. Users can edit it directly: any change takes effect automatically, and deleting the file resets every namespace to defaults and `base`. A document that exists but is invalid fails plugin load at boot — the provider never silently ignores or overwrites it. Once live, an unreadable or unparsable edit warns and keeps the last good sections, so a hand-edit mistake cannot take the process down.
 
 ### Writing through the service
 

+ 1 - 1
packages/settings/settings-file/README.zh.md

@@ -50,7 +50,7 @@ kind: "package-reference"
 
 ### 编辑文档
 
-文档是 namespace 到用户分节的 YAML 或 JSON 映射。用户可以直接编辑:任何变更都会自动生效,删除文件则让所有 namespace 回到默认值与 `base`。存在但非法的文档在启动时使插件加载失败——提供方绝不会静默忽略或覆盖它。运行中不可读或不可解析的编辑只告警并保留最后可用分节,因此手改出错不会拖垮进程。
+文档是 namespace 到用户分节的 YAML 或 JSON 映射,外加一个保留的 `scopes` 映射,存放每个具名 scope 自己的分节——`scopes.preset/standard.skill-filesystem` 就是 `standard` preset 叠加在全局 `skill-filesystem` 分节之上解析的那一段。用户可以直接编辑:任何变更都会自动生效,删除文件则让所有 namespace 回到默认值与 `base`。存在但非法的文档在启动时使插件加载失败——提供方绝不会静默忽略或覆盖它。运行中不可读或不可解析的编辑只告警并保留最后可用分节,因此手改出错不会拖垮进程。
 
 ### 经服务写入
 

+ 41 - 15
packages/settings/settings-file/src/index.ts

@@ -15,7 +15,7 @@ import { dirname, extname, join, resolve } from 'node:path'
 import { Document, parseDocument } from 'yaml'
 import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
-import { SettingsProvider, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
+import { SettingsProvider, type SettingsNamespace, type SettingsScopeId } from '@deepseek-ai/dsh-settings'
 import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
 
 /** Plugin config: file location and hot-reload behavior. */
@@ -92,6 +92,21 @@ function patchNode(document: Document, path: readonly string[], current: unknown
   if (!deepEqualJson(current, next)) document.setIn([...path], next)
 }
 
+/** The value at `path` inside a parsed document, or undefined when any step is not a map. */
+function valueAt(root: unknown, path: readonly string[]): unknown {
+  let current: unknown = root
+  for (const key of path) {
+    if (!isMapLike(current)) return undefined
+    current = current[key]
+  }
+  return current
+}
+
+/** One section nested under its path, for a document that has nothing yet. */
+function nestAt(path: readonly string[], section: Record<string, unknown>): Record<string, unknown> {
+  return path.reduceRight<Record<string, unknown>>((inner, key) => ({ [key]: inner }), section)
+}
+
 /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
 function isENOENT(error: unknown): boolean {
   return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
@@ -182,12 +197,12 @@ export class FileSettingsProvider extends SettingsProvider {
     return doc
   }
 
-  protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
+  protected persist(ns: SettingsNamespace, section: Record<string, unknown>, scope: SettingsScopeId | undefined): Promise<void> {
     // One document backs every namespace, so writes from different namespace
     // queues serialize with each other and with watcher reloads on the one
     // operation chain: each render must see the text the previous operation
     // committed, or a sibling section silently vanishes from disk.
-    return this.enqueue(() => this.persistSection(ns, section))
+    return this.enqueue(() => this.persistSection(scope === undefined ? [ns] : ['scopes', scope, ns], section))
   }
 
   /** Queue one exclusive document operation behind every earlier one. */
@@ -208,7 +223,11 @@ export class FileSettingsProvider extends SettingsProvider {
     })
   }
 
-  private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
+  /**
+   * Write one section at `path` — `[ns]` for a global section, `['scopes',
+   * scope, ns]` for a scoped one — into the document on disk.
+   */
+  private async persistSection(path: readonly string[], section: Record<string, unknown>): Promise<void> {
     // The writer lock's exclusive create needs the parent to exist before
     // writeFileAtomic gets its own chance to create it.
     // 0700: the harness home holds user-private documents.
@@ -222,8 +241,8 @@ export class FileSettingsProvider extends SettingsProvider {
       // a user's manual edit.
       await this.reconcileFromDisk()
       const output = this.spec.format === 'yaml'
-        ? this.renderYaml(ns, section)
-        : this.renderJson(ns, section)
+        ? this.renderYaml(path, section)
+        : this.renderJson(path, section)
       // 0600: a document that may hold personal values is never world-readable.
       await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 })
       this.text = output
@@ -339,31 +358,38 @@ export class FileSettingsProvider extends SettingsProvider {
   }
 
   /**
-   * Render the next YAML text by patching one namespace in the
+   * Render the next YAML text by patching one section in the
    * comment-preserving document. The next section lands as a leaf-level diff
    * against the stored one — only changed values set, only removed keys
    * delete — so comments inside the section survive edits to their siblings,
-   * not just comments outside it.
+   * not just comments outside it. A scoped section's `scopes.<scope>` map is
+   * created on the way when the document has none.
    */
-  private renderYaml(ns: SettingsNamespace, section: Record<string, unknown>): string {
+  private renderYaml(path: readonly string[], section: Record<string, unknown>): string {
     if (this.text === undefined) {
-      return new Document({ [ns]: section }).toString()
+      return new Document(nestAt(path, section)).toString()
     }
     // this.text only ever caches content that parsed successfully, so this
     // re-parse (for the mutable comment-preserving tree) cannot fail, and
     // parse() already rejected any non-map root.
     const document = parseDocument(this.text)
-    const root: unknown = document.toJS()
-    patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section)
+    patchNode(document, path, valueAt(document.toJS(), path), section)
     return document.toString()
   }
 
-  /** Render the next JSON text by replacing one namespace key. */
-  private renderJson(ns: SettingsNamespace, section: Record<string, unknown>): string {
+  /** Render the next JSON text by replacing one section at its path. */
+  private renderJson(path: readonly string[], section: Record<string, unknown>): string {
     const root = this.text === undefined
       ? {}
       : this.parse(this.text)
-    root[ns] = section
+    let container: Record<string, unknown> = root
+    for (const key of path.slice(0, -1)) {
+      const child = container[key]
+      const next = isMapLike(child) ? child : {}
+      container[key] = next
+      container = next
+    }
+    container[path[path.length - 1] as string] = section
     return `${JSON.stringify(root, null, 2)}\n`
   }
 }

+ 106 - 0
packages/settings/settings-file/tests/scopes.spec.ts

@@ -0,0 +1,106 @@
+/**
+ * The document layout of scoped sections: `scopes.<scope id>.<namespace>`,
+ * written as the same comment-preserving leaf diff a global section gets,
+ * in YAML and in JSON, and read back the same way.
+ */
+
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { FileSettingsProvider } from '../src/index.ts'
+
+const ThemeSchema = z.object({
+  theme: z.union(['dark', 'light']).default('dark'),
+  fontSize: z.number().default(14),
+})
+
+const cleanups: Array<() => Promise<void>> = []
+
+afterEach(async () => {
+  while (cleanups.length > 0) await cleanups.pop()!()
+})
+
+async function tempDir(): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-scopes-'))
+  cleanups.push(() => rm(dir, { recursive: true, force: true }))
+  return dir
+}
+
+async function boot(path: string): Promise<Context> {
+  const ctx = new Context()
+  const fiber = ctx.plugin(FileSettingsProvider, { path, watch: false })
+  cleanups.push(async () => { await fiber.dispose() })
+  await fiber
+  ctx.settings.register('ui-theme', ThemeSchema)
+  return ctx
+}
+
+describe('scoped sections on disk', () => {
+  it('writes a scoped section under scopes.<id>.<ns>, creating the map, and keeps comments elsewhere', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, '# my settings\nui-theme:\n  fontSize: 18 # big\n')
+    const ctx = await boot(path)
+
+    await ctx.settings.update('ui-theme', { theme: 'light' }, undefined, 'preset/standard')
+    await ctx.settings.update('ui-theme', { fontSize: 9 }, undefined, 'preset/standard')
+
+    expect(await readFile(path, 'utf8')).toBe([
+      '# my settings',
+      'ui-theme:',
+      '  fontSize: 18 # big',
+      'scopes:',
+      '  preset/standard:',
+      '    ui-theme:',
+      '      theme: light',
+      '      fontSize: 9',
+      '',
+    ].join('\n'))
+    expect(ctx.settings.describe({ scope: 'preset/standard' })[0]).toMatchObject({
+      value: { theme: 'light', fontSize: 9 },
+      user: { theme: 'light', fontSize: 9 },
+      inherited: { theme: 'dark', fontSize: 18 },
+    })
+  })
+
+  it('starts an absent document with the scoped section alone', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    const ctx = await boot(path)
+
+    await ctx.settings.update('ui-theme', { theme: 'light' }, undefined, 'preset/standard')
+
+    expect(await readFile(path, 'utf8')).toBe('scopes:\n  preset/standard:\n    ui-theme:\n      theme: light\n')
+  })
+
+  it('reads a scoped section back on the next boot', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    const first = await boot(path)
+    await first.settings.update('ui-theme', { theme: 'light' }, undefined, 'preset/standard')
+    await first.settings.update('ui-theme', { fontSize: 20 })
+
+    const second = await boot(path)
+
+    expect(second.settings.describe({ scope: 'preset/standard' })[0]?.value).toEqual({ theme: 'light', fontSize: 20 })
+    expect(second.settings.describe()[0]?.value).toEqual({ theme: 'dark', fontSize: 20 })
+  })
+
+  it('writes scoped sections into a JSON document', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.json')
+    await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } }))
+    const ctx = await boot(path)
+
+    await ctx.settings.update('ui-theme', { theme: 'light' }, undefined, 'preset/standard')
+    await ctx.settings.update('ui-theme', { fontSize: 9 }, undefined, 'preset/research')
+
+    expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({
+      'ui-theme': { fontSize: 18 },
+      scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } }, 'preset/research': { 'ui-theme': { fontSize: 9 } } },
+    })
+  })
+})

+ 2 - 2
packages/settings/settings/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/settings/settings/README.md
-README.md: 5c7313ac5015f64cca6b3d91ee75d44f27ab356c
-README.zh.md: 337effed483a1bf28d42b76e95c69bc01f805667
+README.md: 2b09635ba1862b825f89c3cdc7818cdc9720dbc8
+README.zh.md: 02f282d8627f887e08ed597370c5af831bf609f2

+ 12 - 9
packages/settings/settings/README.md

@@ -55,25 +55,27 @@ const theme = scope.get()              // deep-frozen resolved snapshot
 scope.update({ density: 'compact' })   // merges into the user section and persists
 ```
 
-Literal namespace arguments are checked by TypeScript against the lowercase letter, digit, and hyphen grammar; dynamically supplied strings receive the same validation at runtime. `ctx.settings.installSection(owner, ns, schema, entry, hooks)` packages the optional-service wiring for a consumer plugin: while a settings service exists it registers the namespace with the plugin's composition entry as `base`; when the service goes away the plugin falls back to its entry config and keeps working exactly as composed.
+Literal namespace arguments are checked by TypeScript against the lowercase letter, digit, and hyphen grammar; dynamically supplied strings receive the same validation at runtime, and `scopes` is reserved for the document's per-scope sections. `ctx.settings.installSection(owner, ns, schema, entry, hooks)` packages the optional-service wiring for a consumer plugin: while a settings service exists it registers the namespace with the plugin's composition entry as `base`; when the service goes away the plugin falls back to its entry config and keeps working exactly as composed.
+
+A namespace is one kind of setting, and a registration is one instance of it under a scope. The instance registers under the caller's nearest named `dsh-scope` scope — a plugin mounted inside an agent preset registers under `preset/<id>`, a host row under the global scope — and resolves the document's global section with that scope's own section (`scopes.preset/<id>.<ns>`) layered over it. Two presets mounting the same plugin therefore hold two instances of one kind, each with its own composition `base`; every registrant of a namespace must carry the same schema envelope, and a second registration under the same scope fails loud.
 
 ### Reading and observing values
 
-`get(ns)` returns the resolved value as a deep-frozen snapshot, `undefined` while the namespace is unregistered. `watch(callback)` invokes the callback after each committed change with `(next, prev)`: invocations of one callback run one at a time in commit order, and failures are contained and logged, so a slow or throwing observer never blocks or breaks other observers.
+`get(ns, scope?)` returns the resolved value of one instance as a deep-frozen snapshot — the global instance when no scope is named — and `undefined` while the namespace is unregistered under that scope. `watch(callback)` invokes the callback after each committed change with `(next, prev)`: invocations of one callback run one at a time in commit order, and failures are contained and logged, so a slow or throwing observer never blocks or breaks other observers.
 
 ### Writing values
 
-`update(ns, patch)` deep-merges a plain-object patch into the user section only — never into `base` — validates the resolved candidate, persists through the provider, then commits. `replace(ns, section)` sets the user section wholesale, which is the removal/reset path: `replace({})` re-inherits `base` and schema defaults. `mutate(ns, ops)` applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue — the removal path for a caller holding an incomplete (for example redacted) view, because rebuilding a section from what a wire surface returned and replacing it wholesale would delete every field the wire never sent back.
+`update(ns, patch)` deep-merges a plain-object patch into the user section only — never into `base` — validates the resolved candidate, persists through the provider, then commits. Every write verb takes an optional trailing `scope`: a scoped write edits `scopes.<scope>.<ns>` and commits that scope's instance alone, a global write edits the top-level section and re-resolves every instance of the kind, each gated on its own resolved value so a scope that overrides the changed field is not disturbed. A scope nothing has registered yet still accepts a write when the namespace is registered somewhere — the section is judged by the shared schema and takes effect when an owner mounts there. The owner handle's `update`/`replace` write the handle's own scope. `replace(ns, section)` sets the user section wholesale, which is the removal/reset path: `replace({})` re-inherits `base` and schema defaults. `mutate(ns, ops)` applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue — the removal path for a caller holding an incomplete (for example redacted) view, because rebuilding a section from what a wire surface returned and replacing it wholesale would delete every field the wire never sent back.
 
 Every write rejects non-JSON-compatible data (a `Date`, `Map`, `BigInt`, non-finite number, or circular reference fails with its `$`-rooted path before anything persists), rejects on a read-only provider, and accepts an optional `expectedRevision`: pass back the `revision` from a descriptor, and a namespace that moved past it refuses the write with `SettingsConflictError` instead of overwriting the writer that landed first.
 
 ### Configuration surfaces
 
-`describe()` returns one descriptor per registered namespace: the serialized schema, the resolved value, the detached `base` and `user` layers (a field's presence in `user` marks it user-overridden), the effect timing, and the namespace's revision. Pass `redactSecrets: true` on every wire surface: it strips `role('secret')` fields from every layer and enumerates them as `{ path, set }` slots so a page can render write-only inputs without ever receiving a secret. `documentPath` and `prepareDocument()` expose the provider's user-editable file to a native editor when one exists.
+`describe()` returns one descriptor per namespace kind under the global scope, and `describe({ scope })` one per kind under a named scope: the serialized schema, the resolved value, the detached `base` and `user` layers (a field's presence in `user` marks it user-overridden; for a scoped descriptor `user` is the scope's own section), the effect timing, the section's revision, `registered` (whether an owner registered the namespace under that scope — a scope no session composed yet is described from the kind alone, without a `base`), and for a scoped descriptor `inherited`, the value the scope resolves without its own section. `scopes()` lists every named scope some namespace is registered under. Pass `redactSecrets: true` on every wire surface: it strips `role('secret')` fields from every layer and enumerates them as `{ path, set }` slots so a page can render write-only inputs without ever receiving a secret. `documentPath` and `prepareDocument()` expose the provider's user-editable file to a native editor when one exists.
 
 ### Events and failures
 
-`settings/updated (ns, next, prev, source)` fires after each committed change — an in-process write (`source: 'update'`) or an externally observed edit (`source: 'provider'`) — and never when the resolved value is deep-equal. `settings/document-updated (ns, revision)` fires whenever the raw user section changed, even when the resolved value did not, which is what an open editor needs to learn that a field went from inherited to overridden. A stored section the schema rejects keeps the namespace's last good value and warns on reload; at registration the same failure rejects the registration itself.
+`settings/updated (ns, next, prev, source, scope?)` fires after each committed change of one instance — an in-process write (`source: 'update'`) or an externally observed edit (`source: 'provider'`) — and never when the resolved value is deep-equal; `scope` is absent for the global instance. `settings/document-updated (ns, revision, scope?)` fires whenever one raw section changed, even when the resolved value did not, which is what an open editor needs to learn that a field went from inherited to overridden; every section, global or scoped, registered or not yet, is versioned on its own. A stored section the schema rejects keeps the namespace's last good value and warns on reload; at registration the same failure rejects the registration itself.
 
 -----
 
@@ -97,14 +99,14 @@ This section explains the design decisions behind the service and points at the
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Service Definition: namespace validation, registration, resolution, write queue, describe/redaction, events, `installSection` |
+| [`src/index.ts`](src/index.ts) | Service Definition: namespace and scope validation, kind and instance registration, scoped resolution, per-section write queues and revisions, describe/redaction, events, `installSection` |
 | [`src/redact.ts`](src/redact.ts) | `redactSecrets` walker: strip `role('secret')` fields and enumerate their slots |
-| [`src/types.ts`](src/types.ts) | Client-safe type surface: event declarations, `SettingsNamespace`, `SettingsUpdateSource` |
+| [`src/types.ts`](src/types.ts) | Client-safe type surface: event declarations, `SettingsNamespace`, `SettingsScopeId`, `SettingsUpdateSource`, the wire views |
 | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: `settings/updated` fires only for a registered namespace, only on a resolved-value change, with the authoritative value |
 
 ### Resolution and write paths
 
-Each write snapshots its input at call time (detaching and validating JSON-shaped data), then queues on the namespace's serialized chain. At the front of the queue the service re-reads the section as it stands, checks `expectedRevision`, merges/replaces/mutates, resolves and validates the candidate through the schema plus the owner's optional `validate`, persists through the provider, and only then commits and emits. A write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody; teardown refuses new writes and drains queued writes and started watcher invocations before disposal completes.
+Each write snapshots its input at call time (detaching and validating JSON-shaped data), then queues on the section's serialized chain — one chain per namespace and scope. At the front of the queue the service re-reads the section as it stands, checks `expectedRevision`, merges/replaces/mutates, resolves and validates the candidate for every instance the section feeds (all of the kind for the global section, one for a scoped one) through the schema plus the kind's `validate`, persists through the provider with the scope, and only then commits and emits. Resolution layers schema defaults, the instance's `base`, the global section, and the instance's scoped section, in that order. A write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody; teardown refuses new writes and drains queued writes and started watcher invocations before disposal completes.
 
 ### Change detection and events
 
@@ -146,7 +148,8 @@ No direct invalidation; a consumer that folds a settings value into the request
 
 These limits define when the service is a poor fit or needs special care. They are current package constraints, not a task backlog.
 
-- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; it does not record which layer supplied each resolved value.
+- **Two user layers, no provenance per field** — resolution knows schema defaults, one composition `base` per instance, the global section, and one scoped section; a descriptor's `user` and `inherited` let a surface tell an override from an inherited value, but the service does not record which layer supplied each resolved field.
+- **The kind's `validate` and `applies` come from the first registrant** — a later instance's differing check or effect timing is ignored, because every instance is the same plugin.
 - **`redactSecrets` is not a proven wire boundary** — the walker follows `object`/`dict`/`array` containers, so a `role('secret')` field reachable only through a union, intersection, or transform is returned verbatim with an empty `secrets` list, and the serialized schema carries a secret field's default to every client. Neither case is rejected; a schema whose secrets are not reachable through the walked containers must not be registered on a wire-exposed namespace. A fail-closed `describeForWire()` — one that refuses a schema it cannot prove safe and sanitizes the serialized envelope and error text — is the deferred answer.
 - **Cross-process concurrency is provider-defined** — the service serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins).
 

+ 12 - 9
packages/settings/settings/README.zh.md

@@ -55,25 +55,27 @@ const theme = scope.get()              // deep-frozen resolved snapshot
 scope.update({ density: 'compact' })   // merges into the user section and persists
 ```
 
-TypeScript 会按小写字母、数字与连字符文法检查字面量 namespace 参数;运行时动态传入的字符串接受相同校验。`ctx.settings.installSection(owner, ns, schema, entry, hooks)` 为消费方插件封装可选服务接线:只要设置服务存在,它就用插件的组合配置作为 `base` 注册 namespace;服务消失时插件回退到组合配置,行为与原先完全一致。
+TypeScript 会按小写字母、数字与连字符文法检查字面量 namespace 参数;运行时动态传入的字符串接受相同校验,`scopes` 保留给文档的按 scope 分节。`ctx.settings.installSection(owner, ns, schema, entry, hooks)` 为消费方插件封装可选服务接线:只要设置服务存在,它就用插件的组合配置作为 `base` 注册 namespace;服务消失时插件回退到组合配置,行为与原先完全一致。
+
+namespace 是一种设置的 kind,一次注册是它在某个 scope 下的一个 instance。instance 注册在调用者最近的具名 `dsh-scope` 作用域之下——挂在 agent preset 内的插件注册在 `preset/<id>` 下,宿主行注册在全局 scope 下——并以该 scope 自己的分节(`scopes.preset/<id>.<ns>`)叠加在文档的全局分节之上来解析。因此挂同一插件的两个 preset 持有同一 kind 的两个 instance,各自带自己的组合 `base`;同一 namespace 的每个注册者必须携带相同的 schema 信封,同一 scope 下的第二次注册大声失败。
 
 ### 读取与观察值
 
-`get(ns)` 以深冻结快照返回解析值,namespace 未注册时为 `undefined`。`watch(callback)` 在每次已提交变更后以 `(next, prev)` 调用回调:同一回调的调用按提交顺序逐个执行,异常被隔离并记入日志,因此慢或抛错的观察者绝不会阻塞或破坏其他观察者。
+`get(ns, scope?)` 以深冻结快照返回某个 instance 的解析值——未指名 scope 时为全局 instance——该 scope 下 namespace 未注册时为 `undefined`。`watch(callback)` 在每次已提交变更后以 `(next, prev)` 调用回调:同一回调的调用按提交顺序逐个执行,异常被隔离并记入日志,因此慢或抛错的观察者绝不会阻塞或破坏其他观察者。
 
 ### 写入值
 
-`update(ns, patch)` 把普通对象 patch 深合并进用户分节——绝不进 `base`——校验解析候选值、经提供方持久化后提交。`replace(ns, section)` 整体替换用户分节,是删除/重置路径:`replace({})` 重新继承 `base` 与 schema 默认值。`mutate(ns, ops)` 在写入排到队首那一刻的分节上按序施加 `{ op: 'set' | 'unset', path }` 编辑——这是持有不完整(例如脱敏后)视图的调用方的删除路径,因为按协议接口返回的内容重建分节再整体替换,会删掉协议从未回传的每个字段。
+`update(ns, patch)` 把普通对象 patch 深合并进用户分节——绝不进 `base`——校验解析候选值、经提供方持久化后提交。每个写入动词都接受可选的尾随 `scope`:scoped 写入编辑 `scopes.<scope>.<ns>` 并只提交该 scope 的 instance,全局写入编辑顶层分节并重新解析该 kind 的每个 instance,各自按自身解析值门控,因此覆盖了被改字段的 scope 不会被打扰。尚无注册的 scope 只要该 namespace 在别处已注册就仍接受写入——分节由共享 schema 判定,并在有 owner 挂到那里时生效。owner 句柄的 `update`/`replace` 写入句柄自己的 scope。`replace(ns, section)` 整体替换用户分节,是删除/重置路径:`replace({})` 重新继承 `base` 与 schema 默认值。`mutate(ns, ops)` 在写入排到队首那一刻的分节上按序施加 `{ op: 'set' | 'unset', path }` 编辑——这是持有不完整(例如脱敏后)视图的调用方的删除路径,因为按协议接口返回的内容重建分节再整体替换,会删掉协议从未回传的每个字段。
 
 每次写入都会拒绝与 JSON 不兼容的数据(`Date`、`Map`、`BigInt`、非有限数或循环引用会在任何内容持久化前以 `$` 为根的路径报错)、拒绝只读提供方上的写入,并可接受可选的 `expectedRevision`:把 descriptor 中的 `revision` 传回,namespace 已越过该值时写入会被 `SettingsConflictError` 拒绝,而不是覆盖先完成写入的一方。
 
 ### 配置界面
 
-`describe()` 为每个已注册 namespace 返回一条 descriptor:序列化 schema、解析值、分离的 `base` 与 `user` 层(字段出现在 `user` 中即标记为用户覆盖)、生效时机与 namespace 的 revision。每个协议接口都必须传入 `redactSecrets: true`:它从每一层剥离 `role('secret')` 字段,并把它们枚举为 `{ path, set }` slot,让页面可以渲染只写输入而不接触任何机密。`documentPath` 与 `prepareDocument()` 在提供方拥有用户可编辑文件时把它暴露给原生编辑器。
+`describe()` 在全局 scope 下为每个 namespace kind 返回一条 descriptor,`describe({ scope })` 在某个具名 scope 下为每个 kind 返回一条:序列化 schema、解析值、分离的 `base` 与 `user` 层(字段出现在 `user` 中即标记为用户覆盖;scoped descriptor 的 `user` 是该 scope 自己的分节)、生效时机、该分节的 revision、`registered`(该 scope 下是否有 owner 注册了此 namespace——尚无会话组合过的 scope 仅由 kind 描述,没有 `base`),以及 scoped descriptor 的 `inherited`——该 scope 不带自己分节时解析出的值。`scopes()` 列出所有有 namespace 注册于其下的具名 scope。每个协议接口都必须传入 `redactSecrets: true`:它从每一层剥离 `role('secret')` 字段,并把它们枚举为 `{ path, set }` slot,让页面可以渲染只写输入而不接触任何机密。`documentPath` 与 `prepareDocument()` 在提供方拥有用户可编辑文件时把它暴露给原生编辑器。
 
 ### 事件与失败
 
-`settings/updated (ns, next, prev, source)` 在每次已提交变更后触发——进程内写入(`source: 'update'`)或外部观察到的编辑(`source: 'provider'`)——解析值深相等时绝不触发。`settings/document-updated (ns, revision)` 在原始用户分节发生变化时触发,即使解析值没有变——已打开的编辑器正需要它来得知字段从继承变为覆盖。schema 拒绝的存量分节在重载时保留该 namespace 的最后可用值并告警;注册时同样的失败会直接拒绝注册。
+`settings/updated (ns, next, prev, source, scope?)` 在某个 instance 每次已提交变更后触发——进程内写入(`source: 'update'`)或外部观察到的编辑(`source: 'provider'`)——解析值深相等时绝不触发;全局 instance 时 `scope` 缺席。`settings/document-updated (ns, revision, scope?)` 在某个原始分节发生变化时触发,即使解析值没有变——已打开的编辑器正需要它来得知字段从继承变为覆盖;每个分节,无论全局或 scoped、已注册或尚未,都各自版本化。schema 拒绝的存量分节在重载时保留该 namespace 的最后可用值并告警;注册时同样的失败会直接拒绝注册。
 
 -----
 
@@ -97,14 +99,14 @@ TypeScript 会按小写字母、数字与连字符文法检查字面量 namespac
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Service Definition:namespace 校验、注册、解析、写队列、describe/脱敏、事件、`installSection` |
+| [`src/index.ts`](src/index.ts) | Service Definition:namespace 与 scope 校验、kind 与 instance 注册、按 scope 解析、按分节的写队列与 revision、describe/脱敏、事件、`installSection` |
 | [`src/redact.ts`](src/redact.ts) | `redactSecrets` 遍历器:剥离 `role('secret')` 字段并枚举其 slot |
-| [`src/types.ts`](src/types.ts) | 客户端安全类型面:事件声明、`SettingsNamespace`、`SettingsUpdateSource` |
+| [`src/types.ts`](src/types.ts) | 客户端安全类型面:事件声明、`SettingsNamespace`、`SettingsScopeId`、`SettingsUpdateSource`、wire 视图 |
 | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:`settings/updated` 只对已注册 namespace、只在解析值变化时、且携带权威值触发 |
 
 ### 解析与写入路径
 
-每次写入都在调用时对输入做快照(分离并校验 JSON 形状的数据),然后排上该 namespace 的串行链。在队首,服务按当前状态重读分节、检查 `expectedRevision`、合并/替换/编辑、经 schema 与 owner 的可选 `validate` 解析并校验候选值、经提供方持久化,然后才提交并发出事件。registrant fiber 在写入途中被 dispose 的写入仍到达存储,但不会提交、也不会通知任何人;卸载先拒绝新写入,并排干排队写入与已启动的 watcher 调用后才完成。
+每次写入都在调用时对输入做快照(分离并校验 JSON 形状的数据),然后排上该分节的串行链——每个 namespace 与 scope 各一条。在队首,服务按当前状态重读分节、检查 `expectedRevision`、合并/替换/编辑、为该分节供给的每个 instance(全局分节是该 kind 的全部,scoped 分节是那一个)经 schema 与 kind 的 `validate` 解析并校验候选值、带着 scope 经提供方持久化,然后才提交并发出事件。解析按顺序叠加 schema 默认值、instance 的 `base`、全局分节与 instance 的 scoped 分节。registrant fiber 在写入途中被 dispose 的写入仍到达存储,但不会提交、也不会通知任何人;卸载先拒绝新写入,并排干排队写入与已启动的 watcher 调用后才完成。
 
 ### 变更检测与事件
 
@@ -146,7 +148,8 @@ TypeScript 会按小写字母、数字与连字符文法检查字面量 namespac
 
 这些限制说明本服务何时不合适或需要特别注意。它们是当前包约束,不是任务积压。
 
-- **单一用户层**——解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;它不记录每个解析值由哪一层提供。
+- **两个用户层,无逐字段来源**——解析只认识 schema 默认值、每个 instance 一个组合 `base`、全局分节与一个 scoped 分节;descriptor 的 `user` 与 `inherited` 让界面分辨覆盖与继承,但服务不记录每个解析字段由哪一层提供。
+- **kind 的 `validate` 与 `applies` 取自第一个注册者**——后来 instance 不同的检查或生效时机被忽略,因为每个 instance 都是同一个插件。
 - **`redactSecrets` 并非一条可被证明的协议边界**——遍历器只跟随 `object`/`dict`/`array` 容器,因此只能经由 union、intersection 或 transform 抵达的 `role('secret')` 字段会被原样返回,且 `secrets` 列表为空;序列化 schema 还会把 secret 字段的默认值带给每个客户端。两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema,绝不可注册到暴露于协议的 namespace 上。fail-closed 的 `describeForWire()`——拒绝自己无法证明安全的 schema,并对序列化封装与错误文本做净化——是暂缓的答案。
 - **跨进程并发由提供方定义**——服务仅在进程内按 namespace 串行写入;跨进程并发按提供方行为收敛(文件提供方在写锁下读-改-写,因此并发写入者不会丢掉彼此的 namespace,同 namespace 冲突按后写胜出解决)。
 

+ 2 - 0
packages/settings/settings/package.json

@@ -40,6 +40,7 @@
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^"
   },
@@ -47,6 +48,7 @@
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^"
   },

+ 470 - 159
packages/settings/settings/src/index.ts

@@ -2,22 +2,38 @@
  * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
  * per-namespace sections; plugins register a namespace schema and read the
  * resolved value, which layers schema defaults, the registrant's composition
- * `base`, and the user document section, in that order.
+ * `base`, the user document's global section, and — for a registrant inside
+ * a named scope — that scope's own section, in that order.
+ *
+ * A namespace is one KIND of setting: every registrant of a namespace shares
+ * its schema, and each registers one INSTANCE under the scope its context
+ * belongs to (`dsh-scope`'s nearest named scope, or the global scope when
+ * none is named). Two agent presets mounting the same plugin therefore hold
+ * two instances of one kind, each resolving the document's global section
+ * plus its own `scopes.<id>.<ns>` section.
  * @module @deepseek-ai/dsh-settings
  */
 
 import { Context, Service } from '@deepseek-ai/cordis'
 import type z from '@deepseek-ai/schemastery'
+import { scopeIdOf } from '@deepseek-ai/dsh-scope'
 import { deepEqualJson, deepFreeze } from '@deepseek-ai/dsh-util-values'
 import { redactSecrets } from './redact.ts'
 import type { RedactedSecret } from './redact.ts'
-import type { SettingsNamespace, SettingsUpdateSource } from './types.ts'
+import type { SettingsNamespace, SettingsScopeId, SettingsUpdateSource } from './types.ts'
 
 export { redactSecrets } from './redact.ts'
 export type { RedactedSecret, RedactedValue } from './redact.ts'
-export type { SettingsNamespace, SettingsUpdateSource } from './types.ts'
+export type { SettingsNamespace, SettingsScopeId, SettingsUpdateSource } from './types.ts'
 
 const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
+/**
+ * Top-level document keys that are not namespaces. `scopes` holds the
+ * per-scope sections, so a namespace of that name would collide with them.
+ */
+const RESERVED_NAMESPACES: ReadonlySet<string> = new Set(['scopes'])
+/** The grammar of a scope id as a document key: `preset/standard`, `preset/my-preset`. */
+const SCOPE_PATTERN = /^[a-z][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)*$/
 type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
   | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
 type DecimalDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
@@ -39,9 +55,31 @@ function parseSettingsNamespace(value: string): SettingsNamespace {
   if (!NAMESPACE_PATTERN.test(value)) {
     throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
   }
+  if (RESERVED_NAMESPACES.has(value)) {
+    throw new TypeError(`settings namespace "${value}" is reserved for the document's per-scope sections`)
+  }
   return value as SettingsNamespace
 }
 
+/**
+ * Validate one scope id at the seam boundary.
+ * @param value - the id a caller or a named scope supplied.
+ * @returns the branded id.
+ * @throws {TypeError} when the id is not a `/`-separated lowercase path.
+ */
+export function parseSettingsScopeId(value: string): SettingsScopeId {
+  if (!SCOPE_PATTERN.test(value)) {
+    throw new TypeError(`settings scope "${value}" must match ${String(SCOPE_PATTERN)}`)
+  }
+  return value as SettingsScopeId
+}
+
+/** The named scope of a registrant's context, validated for use as a document key. */
+function scopeOfContext(ctx: Context): SettingsScopeId | undefined {
+  const id = scopeIdOf(ctx)
+  return id === undefined ? undefined : parseSettingsScopeId(id)
+}
+
 /** When a namespace's changes take effect for its owner. */
 export type SettingsApplies = 'live' | 'restart'
 
@@ -68,6 +106,9 @@ export interface SettingsRegisterOptions<T> {
    * registration there is no last good value yet, so a stored section that
    * already fails rejects the registration itself — again exactly as a schema
    * failure does.
+   *
+   * The check belongs to the namespace kind: the first registrant's check
+   * judges every instance, because every instance is the same plugin.
    * @param value - the resolved section, schema-valid by construction.
    */
   validate?: (value: T) => void
@@ -79,6 +120,14 @@ export interface SettingsDescriptor {
   // public API, provider contract, implementations, tests, and consumers.
   /** The registered namespace. */
   ns: SettingsNamespace
+  /** The named scope the descriptor resolves under; absent for the global scope. */
+  scope?: SettingsScopeId
+  /**
+   * Whether an owner registered the namespace under this scope. False for a
+   * scope described from the kind alone — a preset no session composed yet —
+   * whose value then carries no composition `base`.
+   */
+  registered: boolean
   /** Serialized schemastery schema (`schema.toJSON()`). */
   schema: unknown
   /** Current resolved value. */
@@ -93,8 +142,15 @@ export interface SettingsDescriptor {
   /**
    * Raw user section from the stored document (detached), when one exists and
    * is well-formed; a field's presence here is what marks it user-overridden.
+   * For a scoped descriptor this is the scope's own section, not the global one.
    */
   user?: unknown
+  /**
+   * For a scoped descriptor: the value the scope resolves without its own
+   * user section — defaults, base, and the global section — so a surface can
+   * tell a field the scope overrides from one it inherits.
+   */
+  inherited?: unknown
   /** Owner's declared effect timing. */
   applies: SettingsApplies
   /** Schema-declared secret positions; present only under `redactSecrets`. */
@@ -109,11 +165,17 @@ export interface SettingsDescribeOptions {
    * the verbatim default exists for same-process configuration UIs only.
    */
   redactSecrets?: boolean
+  /**
+   * Describe every namespace kind under this named scope instead of the
+   * global scope. A kind with no registration under the scope is described
+   * from the kind alone, `registered: false`.
+   */
+  scope?: string
 }
 
 /** Owner-facing handle for one registered namespace. */
 export interface SettingsScope<T> {
-  /** Current resolved value: schema defaults, then `base`, then the user layer. */
+  /** Current resolved value: schema defaults, then `base`, then the user layers. */
   get(): T
   /**
    * Observe committed changes to this namespace's resolved value. Invocations
@@ -126,14 +188,15 @@ export interface SettingsScope<T> {
    */
   watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
   /**
-   * Merge a partial patch into this namespace's user layer and persist it.
+   * Merge a partial patch into this registration's user section — the scope's
+   * own section for a scoped registration — and persist it.
    * @param patch - plain-object patch over the user section; JSON-compatible data
    * only (non-JSON values reject with their path before anything persists).
    */
   update(patch: object): Promise<void>
   /**
-   * Replace this namespace's user section wholesale; absent keys re-inherit
-   * the composition `base` and schema defaults (`replace({})` resets all).
+   * Replace this registration's user section wholesale; absent keys re-inherit
+   * the layers below (`replace({})` resets the section).
    * @param section - the complete next user section; JSON-compatible data only,
    * as for {@link update}.
    */
@@ -303,27 +366,37 @@ interface SettingsWatcher {
   active: boolean
 }
 
-/** One live namespace registration owned by a registrant fiber. */
-interface SettingsRegistration {
+/**
+ * One namespace kind: the schema every registrant shares, and the instances
+ * registered under each scope. The kind exists while at least one instance
+ * does.
+ */
+interface SettingsKind {
   ns: SettingsNamespace
   schema: z<unknown>
-  base: unknown
+  /** `schema.toJSON()`, the envelope a later registrant must match. */
+  schemaJson: unknown
   applies: SettingsApplies
-  /** Owner-supplied check for constraints the schema cannot express. */
+  /** The first registrant's check, judging every instance. */
   validate?: (value: unknown) => void
+  /** Instances by scope id; the global scope is the empty key. */
+  instances: Map<string, SettingsRegistration>
+}
+
+/** One live registration of a namespace under one scope, owned by a registrant fiber. */
+interface SettingsRegistration {
+  kind: SettingsKind
+  scope: SettingsScopeId | undefined
+  base: unknown
   resolved: unknown
-  /**
-   * Monotonic counter over this namespace's RAW user section — bumped by any
-   * change to what is stored, including one whose resolved value is
-   * unchanged (adding an override equal to the composition base). Editors
-   * carry it as `expectedRevision` to detect a concurrent write, and the
-   * document event carries it so another tab learns a field went from
-   * inherited to overridden.
-   */
-  revision: number
   watchers: Set<SettingsWatcher>
 }
 
+/** The map key of one instance: its scope id, or the empty key for the global scope. */
+function instanceKey(scope: SettingsScopeId | undefined): string {
+  return scope ?? ''
+}
+
 /**
  * Abstract settings service. Providers implement raw-document storage
  * (`load`/`persist`) and push external changes through {@link Settings.publish};
@@ -331,11 +404,21 @@ interface SettingsRegistration {
  * detection, and the `settings/updated` commit event.
  */
 export abstract class SettingsProvider extends Service {
-  private readonly registrations = new Map<SettingsNamespace, SettingsRegistration>()
+  private readonly kinds = new Map<SettingsNamespace, SettingsKind>()
   /** Latest published raw document; empty until the provider's first publish. */
   private document: Record<string, unknown> = {}
-  /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
-  private readonly writeQueues = new Map<SettingsNamespace, Promise<unknown>>()
+  /**
+   * Monotonic counter per stored section (namespace and scope) — bumped by
+   * any change to what is stored, including one whose resolved value is
+   * unchanged (adding an override equal to the composition base). Editors
+   * carry it as `expectedRevision` to detect a concurrent write, and the
+   * document event carries it so another tab learns a field went from
+   * inherited to overridden. Kept off the registrations so a section written
+   * for a scope nothing has registered yet is versioned the same way.
+   */
+  private readonly revisions = new Map<string, number>()
+  /** Per-section write chains; settled tails, so a failure never poisons the queue. */
+  private readonly writeQueues = new Map<string, Promise<unknown>>()
   /** In-flight watcher invocation segments, drained by the dispose teardown. */
   private readonly pendingTails = new Set<Promise<void>>()
   /** Set at service dispose: refuse new writes while queued ones drain. */
@@ -393,28 +476,39 @@ export abstract class SettingsProvider extends Service {
   }
 
   /**
-   * Read the provider's current raw document (namespace to raw section).
+   * Read the provider's current raw document (namespace to raw section, with
+   * per-scope sections under `scopes.<scope id>.<namespace>`).
    * @returns the detached raw document.
    */
   protected abstract load(): Promise<Record<string, unknown>>
 
   /**
-   * Durably store one namespace's merged user section.
+   * Durably store one section's merged user content.
    * @param ns - the namespace being written.
    * @param section - the complete merged user section to store.
+   * @param scope - the named scope whose section is written; `undefined` for
+   * the global section. A provider that predates scopes and ignores the
+   * argument stores every write in the global section.
    */
-  protected abstract persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void>
+  protected abstract persist(ns: SettingsNamespace, section: Record<string, unknown>, scope: SettingsScopeId | undefined): Promise<void>
 
   /**
    * Register a namespace schema and receive its owner scope. The registration
    * is an effect on the calling plugin's fiber: disposing that fiber removes
-   * the namespace and its observers. An invalid stored section fails the
+   * the instance and its observers. An invalid stored section fails the
    * registration itself — the earliest point where the schema can judge it.
-   * @param ns - unique namespace; duplicate registration fails loud.
+   *
+   * The instance registers under the caller's nearest named scope: a plugin
+   * mounted inside an agent preset resolves that preset's section over the
+   * global one, and two presets mounting the same plugin hold two instances
+   * of one kind. A second registrant of a namespace must carry the same
+   * schema envelope; a different one is a different setting under a taken
+   * name and fails loud.
+   * @param ns - the namespace; a second registration under the same scope fails loud.
    * @param schema - schemastery schema resolving this namespace's value.
    * @param options - composition `base` layer and effect timing.
    * @returns the owner scope for reads, observation, and updates.
-   * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
+   * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier or is reserved.
    */
   register<const Namespace extends string, T>(
     ns: Namespace & SettingsNamespaceInput<Namespace>,
@@ -422,27 +516,48 @@ export abstract class SettingsProvider extends Service {
     options?: SettingsRegisterOptions<T>,
   ): SettingsScope<T> {
     const parsedNs = parseSettingsNamespace(ns)
-    if (this.registrations.has(parsedNs)) {
-      throw new Error(`settings namespace "${parsedNs}" is already registered`)
+    const scope = scopeOfContext(this.ctx)
+    const key = instanceKey(scope)
+    const schemaJson: unknown = schema.toJSON()
+    const existing = this.kinds.get(parsedNs)
+    if (existing?.instances.has(key) === true) {
+      throw new Error(`settings namespace "${parsedNs}" is already registered${scope === undefined ? '' : ` under scope "${scope}"`}`)
     }
-    const registration: SettingsRegistration = {
+    if (existing !== undefined && !deepEqualJson(existing.schemaJson, schemaJson)) {
+      throw new Error(
+        `settings namespace "${parsedNs}" is already registered with a different schema; `
+        + 'a namespace is one kind of setting, so every registrant of it must share the schema',
+      )
+    }
+    const kind: SettingsKind = existing ?? {
       ns: parsedNs,
       schema: schema as z<unknown>,
-      base: options?.base,
+      schemaJson,
       applies: options?.applies ?? 'live',
       ...options?.validate === undefined
         ? {}
         : { validate: options.validate as (value: unknown) => void },
-      resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)),
-      revision: 0,
+      instances: new Map(),
+    }
+    const registration: SettingsRegistration = {
+      kind,
+      scope,
+      base: options?.base,
+      resolved: deepFreeze(this.resolve(kind.schema, options?.base, this.userLayer(parsedNs, scope), kind.validate)),
       watchers: new Set(),
     }
     this.ctx.effect(() => {
-      this.registrations.set(parsedNs, registration)
+      this.kinds.set(parsedNs, kind)
+      kind.instances.set(key, registration)
       // TODO(settings-registration-quiescence): Deactivate every watcher and await
       // its tail on disposal so callbacks cannot outlive the registrant fiber.
-      return () => this.registrations.delete(parsedNs)
-    }, `settings.register(${JSON.stringify(String(parsedNs))})`)
+      return () => {
+        // The kind lives while an instance does: a later registrant of the
+        // namespace finds this kind until its last instance is gone.
+        kind.instances.delete(key)
+        if (kind.instances.size === 0) this.kinds.delete(parsedNs)
+      }
+    }, `settings.register(${JSON.stringify(String(parsedNs))}${scope === undefined ? '' : `, ${JSON.stringify(String(scope))}`})`)
     return {
       get: () => registration.resolved as T,
       watch: (callback) => {
@@ -453,8 +568,9 @@ export abstract class SettingsProvider extends Service {
           registration.watchers.delete(watcher)
         }
       },
-      update: patch => this.update(parsedNs, patch),
-      replace: section => this.replace(parsedNs, section),
+      // Through the async verbs, so a refused input rejects instead of throwing.
+      update: patch => this.update(parsedNs, patch, undefined, scope),
+      replace: section => this.replace(parsedNs, section, undefined, scope),
     }
   }
 
@@ -496,113 +612,160 @@ export abstract class SettingsProvider extends Service {
   }
 
   /**
-   * Describe every registered namespace for configuration surfaces, including
-   * the composition `base` and raw user layers so a form can mark which fields
-   * the user overrode (presence in `user`) and what a reset returns to.
-   * @param options - redaction switch; wire surfaces must redact.
-   * @returns one descriptor per registered namespace, in registration order.
+   * Describe every namespace kind for configuration surfaces, under the
+   * global scope or one named scope: the composition `base` and raw user
+   * layers so a form can mark which fields the user overrode (presence in
+   * `user`) and what a reset returns to, and for a scoped read the
+   * `inherited` value the scope's own section is layered over. A kind with
+   * no instance under the requested scope is described from the kind alone.
+   * @param options - redaction switch (wire surfaces must redact) and scope.
+   * @returns one descriptor per namespace kind, in registration order.
+   * @throws {TypeError} when `scope` is not a well-formed scope id.
    */
   describe(options?: SettingsDescribeOptions): SettingsDescriptor[] {
-    return [...this.registrations.values()].map((registration) => {
-      let user: Record<string, unknown> | undefined
-      try {
-        user = this.section(registration.ns)
-      } catch {
-        // A malformed stored section already warned at publish and kept the
-        // last good resolved value; only that malformed shape can throw here,
-        // and describing it as "no user layer" keeps this read total.
-        user = undefined
-      }
-      const base = registration.base === undefined ? undefined : structuredClone(registration.base)
-      const detachedUser = user === undefined ? undefined : structuredClone(user)
-      const descriptor: SettingsDescriptor = {
-        ns: registration.ns,
-        schema: registration.schema.toJSON(),
-        value: registration.resolved,
-        revision: registration.revision,
-        ...base === undefined ? {} : { base },
-        ...detachedUser === undefined ? {} : { user: detachedUser },
-        applies: registration.applies,
-      }
-      if (options?.redactSecrets !== true) return descriptor
-      const schema = registration.schema as z<never>
-      const redacted = redactSecrets(schema, registration.resolved)
-      return {
-        ...descriptor,
-        value: redacted.value,
-        ...base === undefined ? {} : { base: redactSecrets(schema, base).value },
-        ...detachedUser === undefined ? {} : { user: redactSecrets(schema, detachedUser).value },
-        secrets: redacted.secrets,
+    const scope = options?.scope === undefined ? undefined : parseSettingsScopeId(options.scope)
+    return [...this.kinds.values()].map(kind => this.describeKind(kind, scope, options?.redactSecrets === true))
+  }
+
+  /**
+   * Every named scope some namespace is registered under, in first-seen order.
+   * @returns the scope ids.
+   */
+  scopes(): SettingsScopeId[] {
+    const found = new Set<SettingsScopeId>()
+    for (const kind of this.kinds.values()) {
+      for (const instance of kind.instances.values()) {
+        if (instance.scope !== undefined) found.add(instance.scope)
       }
-    })
+    }
+    return [...found]
+  }
+
+  /** One kind's descriptor under one scope. */
+  private describeKind(kind: SettingsKind, scope: SettingsScopeId | undefined, redact: boolean): SettingsDescriptor {
+    const registration = kind.instances.get(instanceKey(scope))
+    const user = this.sectionOrUndefined(kind.ns, scope)
+    // An unregistered scope resolves from the kind alone; a stored section
+    // the schema refuses, or a malformed one, is described as the layers
+    // below it, so the read stays total the way a registered kind's last
+    // good value keeps it.
+    const value = registration?.resolved ?? this.resolveSafely(kind, undefined, this.userLayerOrUndefined(kind.ns, scope))
+    const inherited = scope === undefined
+      ? undefined
+      : this.resolveSafely(kind, registration?.base, this.userLayerOrUndefined(kind.ns, undefined))
+    const base = registration?.base === undefined ? undefined : structuredClone(registration.base)
+    const detachedUser = user === undefined ? undefined : structuredClone(user)
+    const descriptor: SettingsDescriptor = {
+      ns: kind.ns,
+      ...scope === undefined ? {} : { scope },
+      registered: registration !== undefined,
+      schema: kind.schemaJson,
+      value,
+      revision: this.revisionOf(kind.ns, scope),
+      ...base === undefined ? {} : { base },
+      ...detachedUser === undefined ? {} : { user: detachedUser },
+      ...inherited === undefined ? {} : { inherited },
+      applies: kind.applies,
+    }
+    if (!redact) return descriptor
+    const schema = kind.schema as z<never>
+    const redacted = redactSecrets(schema, value)
+    return {
+      ...descriptor,
+      value: redacted.value,
+      ...base === undefined ? {} : { base: redactSecrets(schema, base).value },
+      ...detachedUser === undefined ? {} : { user: redactSecrets(schema, detachedUser).value },
+      ...inherited === undefined ? {} : { inherited: redactSecrets(schema, inherited).value },
+      secrets: redacted.secrets,
+    }
+  }
+
+  /** Resolve through the kind, falling back to the layers below a section the schema refuses. */
+  private resolveSafely(kind: SettingsKind, base: unknown, layer: unknown): unknown {
+    try {
+      return deepFreeze(this.resolve(kind.schema, base, layer, kind.validate))
+    } catch {
+      // The stored section is malformed for this schema; `publish` already
+      // warned. Describing the layers beneath it keeps the read total.
+      return deepFreeze(this.resolve(kind.schema, base, undefined, undefined))
+    }
   }
 
   /**
    * Read one registered namespace's resolved value.
    * @param ns - the namespace to read.
-   * @returns the resolved value, or `undefined` while unregistered.
+   * @param scope - the named scope of the instance; the global instance when omitted.
+   * @returns the resolved value, or `undefined` while unregistered under that scope.
    * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
    */
-  get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>): unknown {
-    return this.registrations.get(parseSettingsNamespace(ns))?.resolved
+  get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>, scope?: string): unknown {
+    const parsedScope = scope === undefined ? undefined : parseSettingsScopeId(scope)
+    return this.kinds.get(parseSettingsNamespace(ns))?.instances.get(instanceKey(parsedScope))?.resolved
   }
 
   /**
-   * Merge a patch into one registered namespace's user layer, validate the
-   * resolved candidate, persist through the provider, then commit and emit.
-   * A validation failure rejects before anything is persisted. Writes to one
-   * namespace are serialized: concurrent updates apply in call order, each
-   * merging over the previous write's committed section.
+   * Merge a patch into one namespace's user section, validate the resolved
+   * candidates, persist through the provider, then commit and emit. A
+   * validation failure rejects before anything is persisted. Writes to one
+   * section are serialized: concurrent updates apply in call order, each
+   * merging over the previous write's committed section. A global write
+   * re-resolves every instance of the kind; a scoped write only that scope's.
    * @param ns - the registered namespace to update.
    * @param patch - plain-object patch over the user section.
    * @param expectedRevision - the descriptor `revision` the caller read; a
-   *   namespace that moved past it rejects with {@link SettingsConflictError}.
+   *   section that moved past it rejects with {@link SettingsConflictError}.
+   * @param scope - the named scope whose section to write; the global section when omitted.
    * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
    */
   async update<const Namespace extends string>(
     ns: Namespace & SettingsNamespaceInput<Namespace>,
     patch: object,
     expectedRevision?: number,
+    scope?: string,
   ): Promise<void> {
-    return this.write(parseSettingsNamespace(ns), patch, 'merge', expectedRevision)
+    return this.write(parseSettingsNamespace(ns), patch, 'merge', expectedRevision, scope === undefined ? undefined : parseSettingsScopeId(scope))
   }
 
   /**
-   * Replace one registered namespace's user section wholesale, validate,
-   * persist, then commit and emit. Keys absent from `section` fall back to the
-   * composition `base` and schema defaults — this is the removal/reset path a
-   * merge-only patch cannot express (`replace({})` re-inherits everything).
+   * Replace one namespace's user section wholesale, validate, persist, then
+   * commit and emit. Keys absent from `section` fall back to the layers
+   * below — this is the removal/reset path a merge-only patch cannot express
+   * (`replace({})` re-inherits everything).
    * @param ns - the registered namespace to replace.
    * @param section - the complete next user section.
    * @param expectedRevision - the descriptor `revision` the caller read; a
-   *   namespace that moved past it rejects with {@link SettingsConflictError}.
+   *   section that moved past it rejects with {@link SettingsConflictError}.
+   * @param scope - the named scope whose section to write; the global section when omitted.
    * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
    */
   async replace<const Namespace extends string>(
     ns: Namespace & SettingsNamespaceInput<Namespace>,
     section: object,
     expectedRevision?: number,
+    scope?: string,
   ): Promise<void> {
-    return this.write(parseSettingsNamespace(ns), section, 'replace', expectedRevision)
+    return this.write(parseSettingsNamespace(ns), section, 'replace', expectedRevision, scope === undefined ? undefined : parseSettingsScopeId(scope))
   }
 
   /**
-   * Apply path-addressed edits to one registered namespace's user section,
-   * validate, persist, then commit and emit. The ops are applied to the
-   * section as it stands when the write reaches the front of the queue, so a
-   * caller never has to restate fields it did not touch — and, crucially,
-   * cannot delete fields it never saw. This is the write path for any caller
-   * holding a redacted view; `replace` remains the wholesale reset.
+   * Apply path-addressed edits to one namespace's user section, validate,
+   * persist, then commit and emit. The ops are applied to the section as it
+   * stands when the write reaches the front of the queue, so a caller never
+   * has to restate fields it did not touch — and, crucially, cannot delete
+   * fields it never saw. This is the write path for any caller holding a
+   * redacted view; `replace` remains the wholesale reset.
    * @param ns - the registered namespace to edit.
    * @param ops - ordered path edits; later ops observe earlier ones.
    * @param expectedRevision - the descriptor `revision` the caller read; a
-   *   namespace that moved past it rejects with {@link SettingsConflictError}.
+   *   section that moved past it rejects with {@link SettingsConflictError}.
+   * @param scope - the named scope whose section to write; the global section when omitted.
    * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
    */
   async mutate<const Namespace extends string>(
     ns: Namespace & SettingsNamespaceInput<Namespace>,
     ops: readonly SettingsPathOp[],
     expectedRevision?: number,
+    scope?: string,
   ): Promise<void> {
     const parsedNs = parseSettingsNamespace(ns)
     if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${parsedNs}" must be an array of path ops`)
@@ -614,19 +777,20 @@ export abstract class SettingsProvider extends Service {
         throw new TypeError(`settings mutate for "${parsedNs}" op paths must be arrays of strings`)
       }
     }
-    return this.write(parsedNs, ops, 'mutate', expectedRevision)
+    return this.write(parsedNs, ops, 'mutate', expectedRevision, scope === undefined ? undefined : parseSettingsScopeId(scope))
   }
 
-  /** Validate a write, then queue it on the namespace's serialized write chain. */
+  /** Validate a write, then queue it on the section's serialized write chain. */
   private write(
     ns: SettingsNamespace,
     input: object,
     mode: 'merge' | 'replace' | 'mutate',
-    expectedRevision?: number,
+    expectedRevision: number | undefined,
+    scope: SettingsScopeId | undefined,
   ): Promise<void> {
     const verb = mode === 'merge' ? 'update' : mode === 'replace' ? 'replace' : 'mutate'
-    const registration = this.registrations.get(ns)
-    if (registration === undefined) {
+    const kind = this.kinds.get(ns)
+    if (kind === undefined) {
       throw new Error(`settings namespace "${ns}" is not registered`)
     }
     if (this.isStopped()) {
@@ -649,51 +813,93 @@ export abstract class SettingsProvider extends Service {
     // walk rejects values that JSON cannot preserve (see cloneJsonShaped).
     const snapshot = cloneJsonShaped(payload, (label, path) =>
       new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`))
-    const previous = this.writeQueues.get(ns) ?? Promise.resolve()
+    const queueKey = this.sectionKey(ns, scope)
+    const previous = this.writeQueues.get(queueKey) ?? Promise.resolve()
     // Chain past a failed predecessor: one rejected write must not poison the
-    // namespace queue for every later caller.
+    // section queue for every later caller.
     const run = previous.catch(() => undefined).then(async () => {
       if (this.isStopped()) {
         throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`)
       }
-      if (this.registrations.get(ns) !== registration) {
+      if (this.kinds.get(ns) !== kind) {
         throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`)
       }
       // Every mode derives from the section as it stands NOW, at the front of
       // the queue — never from whatever the caller last saw.
-      const current = this.section(ns) ?? {}
+      const current = this.section(ns, scope) ?? {}
       // The revision check belongs HERE, not at call time: the queue orders
       // writes but cannot tell a fresh writer from one holding a snapshot
       // that a predecessor already superseded.
-      if (expectedRevision !== undefined && expectedRevision !== registration.revision) {
-        throw new SettingsConflictError(ns, expectedRevision, registration.revision)
+      const revision = this.revisionOf(ns, scope)
+      if (expectedRevision !== undefined && expectedRevision !== revision) {
+        throw new SettingsConflictError(ns, expectedRevision, revision)
       }
       const section = mode === 'merge'
         ? mergeLayers(current, snapshot) as Record<string, unknown>
         : mode === 'replace'
           ? snapshot
           : (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current)
-      const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate))
-      await this.persist(ns, section)
+      // Validate before persisting: every instance the section feeds resolves
+      // the candidate, and a scope nothing registered yet is judged by the
+      // schema alone so a malformed section never reaches the document.
+      const affected = this.affectedInstances(kind, scope)
+      const candidates = new Map<SettingsRegistration, unknown>()
+      for (const instance of affected) {
+        const layer = this.candidateLayer(ns, instance.scope, scope, section)
+        candidates.set(instance, deepFreeze(this.resolve(kind.schema, instance.base, layer, kind.validate)))
+      }
+      if (affected.length === 0) {
+        this.resolve(kind.schema, undefined, this.candidateLayer(ns, scope, scope, section), kind.validate)
+      }
+      await this.persist(ns, section, scope)
       // The write reached storage either way; the cache must say so. Commit
-      // only when this registration is still the namespace owner — a fiber
-      // disposed (or replaced) mid-persist must not receive the notification.
-      this.document[ns] = section
+      // only when this kind is still the namespace owner — a fiber disposed
+      // (or replaced) mid-persist must not receive the notification.
+      this.setSection(ns, scope, section)
       // TODO(settings-replacement-resync): Re-resolve any replacement registration
       // from this persisted section so an old in-flight write cannot leave it stale.
-      if (this.registrations.get(ns) === registration && !this.isStopped()) {
-        this.bumpRevision(registration, current, section)
-        this.commit(registration, next, 'update')
+      if (this.kinds.get(ns) === kind && !this.isStopped()) {
+        this.bumpRevision(ns, scope, current, section)
+        for (const [instance, next] of candidates) {
+          // An instance disposed while the write persisted must not receive
+          // the notification; the kind itself is still the owner (checked above).
+          /* v8 ignore next */
+          if (kind.instances.get(instanceKey(instance.scope)) === instance) this.commit(instance, next, 'update')
+        }
       }
     })
-    this.writeQueues.set(ns, run)
+    this.writeQueues.set(queueKey, run)
     return run
   }
 
+  /** The instances a write to one section feeds: every instance for the global section, one for a scoped one. */
+  private affectedInstances(kind: SettingsKind, scope: SettingsScopeId | undefined): SettingsRegistration[] {
+    if (scope === undefined) return [...kind.instances.values()]
+    const instance = kind.instances.get(instanceKey(scope))
+    return instance === undefined ? [] : [instance]
+  }
+
+  /**
+   * The user layer one instance would resolve if `candidate` replaced the
+   * section at `writtenScope`: the global section, then the instance's own
+   * scoped section, with the written one substituted.
+   */
+  private candidateLayer(
+    ns: SettingsNamespace,
+    instanceScope: SettingsScopeId | undefined,
+    writtenScope: SettingsScopeId | undefined,
+    candidate: Record<string, unknown>,
+  ): unknown {
+    const global = writtenScope === undefined ? candidate : this.section(ns, undefined)
+    if (instanceScope === undefined) return global
+    const scoped = writtenScope === instanceScope ? candidate : this.section(ns, instanceScope)
+    return mergeLayers(global, scoped)
+  }
+
   /**
    * Provider hook: commit a complete raw document observed in storage. Each
-   * registered namespace re-resolves; an invalid section keeps that
-   * namespace's last good value and warns, other namespaces still commit.
+   * registered instance re-resolves; an invalid section keeps that instance's
+   * last good value and warns, other instances still commit.
    * @param doc - the detached raw document (unregistered sections preserved).
    * @param source - change origin; defaults to `provider`.
    */
@@ -701,51 +907,149 @@ export abstract class SettingsProvider extends Service {
     // Read every raw section BEFORE swapping the document, so the revision
     // bump below compares what was stored with what now is — an external edit
     // moves the revision exactly like an in-process write.
-    const before = new Map<SettingsNamespace, unknown>()
-    for (const registration of this.registrations.values()) {
-      try {
-        before.set(registration.ns, this.section(registration.ns))
-      } catch {
-        // A malformed stored section is not a readable "before"; treating it
-        // as absent still bumps against any well-formed replacement.
-        before.set(registration.ns, undefined)
+    const keys = this.sectionKeys(doc)
+    const before = new Map<string, unknown>()
+    for (const [key, ns, scope] of keys) before.set(key, this.sectionOrUndefined(ns, scope))
+    this.document = doc
+    for (const kind of this.kinds.values()) {
+      for (const instance of kind.instances.values()) {
+        let next: unknown
+        try {
+          next = deepFreeze(this.resolve(kind.schema, instance.base, this.userLayer(kind.ns, instance.scope), kind.validate))
+        } catch (error) {
+          this.ctx.logger.warn(
+            'settings: keeping last good "%s"%s after invalid stored section',
+            kind.ns,
+            instance.scope === undefined ? '' : ` under scope "${instance.scope}"`,
+          )
+          this.ctx.logger.warn(error)
+          continue
+        }
+        this.commit(instance, next, source)
       }
     }
-    this.document = doc
-    for (const registration of this.registrations.values()) {
-      let next: unknown
-      try {
-        next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate))
-      } catch (error) {
-        this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns)
-        this.ctx.logger.warn(error)
-        continue
+    for (const [key, ns, scope] of keys) this.bumpRevision(ns, scope, before.get(key), this.sectionOrUndefined(ns, scope))
+  }
+
+  /**
+   * Every section a publish must version: each kind's global section and
+   * every scoped section an instance registered or either document holds.
+   */
+  private sectionKeys(next: Record<string, unknown>): [string, SettingsNamespace, SettingsScopeId | undefined][] {
+    const scopeIds = new Set<string>()
+    for (const document of [this.document, next]) {
+      const scopes = document['scopes']
+      if (isPlainObject(scopes)) for (const id of Object.keys(scopes)) scopeIds.add(id)
+    }
+    const found: [string, SettingsNamespace, SettingsScopeId | undefined][] = []
+    for (const kind of this.kinds.values()) {
+      const ids = new Set<string>(scopeIds)
+      for (const instance of kind.instances.values()) if (instance.scope !== undefined) ids.add(instance.scope)
+      found.push([this.sectionKey(kind.ns, undefined), kind.ns, undefined])
+      for (const id of ids) {
+        // A malformed scope id in the document names no section this seam
+        // reads; it stays where it is and versions nothing.
+        if (!SCOPE_PATTERN.test(id)) continue
+        const scope = id as SettingsScopeId
+        found.push([this.sectionKey(kind.ns, scope), kind.ns, scope])
       }
-      this.bumpRevision(registration, before.get(registration.ns), this.section(registration.ns))
-      this.commit(registration, next, source)
     }
+    return found
+  }
+
+  /** The stored `scopes` map, rejecting a non-object value. */
+  private scopesContainer(): Record<string, unknown> | undefined {
+    const scopes = this.document['scopes']
+    if (scopes === undefined) return undefined
+    if (!isPlainObject(scopes)) {
+      throw new TypeError('settings section "scopes" must be an object of scope ids')
+    }
+    return scopes
   }
 
-  /** Read one namespace's raw user section, rejecting non-object sections. */
-  private section(ns: SettingsNamespace): Record<string, unknown> | undefined {
-    const section = this.document[ns]
+  /** Read one raw user section, rejecting non-object sections. */
+  private section(ns: SettingsNamespace, scope: SettingsScopeId | undefined): Record<string, unknown> | undefined {
+    let container: Record<string, unknown> | undefined = this.document
+    if (scope !== undefined) {
+      const entry = this.scopesContainer()?.[scope]
+      if (entry === undefined) return undefined
+      if (!isPlainObject(entry)) {
+        throw new TypeError(`settings scope "${scope}" must be an object of namespace sections`)
+      }
+      container = entry
+    }
+    const section = container[ns]
     if (section === undefined) return undefined
     if (!isPlainObject(section)) {
-      throw new TypeError(`settings section "${ns}" must be an object of keys`)
+      throw new TypeError(`settings section "${ns}"${scope === undefined ? '' : ` under scope "${scope}"`} must be an object of keys`)
     }
     return section
   }
 
-  /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
+  /** {@link section} for readers that treat a malformed section as absent. */
+  private sectionOrUndefined(ns: SettingsNamespace, scope: SettingsScopeId | undefined): Record<string, unknown> | undefined {
+    try {
+      return this.section(ns, scope)
+    } catch {
+      // A malformed stored section already warned at publish and kept the
+      // last good resolved value; only that malformed shape can throw here,
+      // and reading it as "no user layer" keeps the caller total.
+      return undefined
+    }
+  }
+
+  /**
+   * The user layer one instance resolves: the global section for the global
+   * instance, the global section with the scope's own section layered over
+   * it for a scoped one.
+   */
+  private userLayer(ns: SettingsNamespace, scope: SettingsScopeId | undefined): unknown {
+    const global = this.section(ns, undefined)
+    if (scope === undefined) return global
+    return mergeLayers(global, this.section(ns, scope))
+  }
+
+  /** {@link userLayer} for readers that treat a malformed section as absent. */
+  private userLayerOrUndefined(ns: SettingsNamespace, scope: SettingsScopeId | undefined): unknown {
+    const global = this.sectionOrUndefined(ns, undefined)
+    if (scope === undefined) return global
+    return mergeLayers(global, this.sectionOrUndefined(ns, scope))
+  }
+
+  /** Store one section in the cached document, creating the scope entry it lives in. */
+  private setSection(ns: SettingsNamespace, scope: SettingsScopeId | undefined, section: Record<string, unknown>): void {
+    if (scope === undefined) {
+      this.document[ns] = section
+      return
+    }
+    const scopes = this.scopesContainer() ?? {}
+    this.document['scopes'] = scopes
+    const entry = scopes[scope]
+    const container = isPlainObject(entry) ? entry : {}
+    scopes[scope] = container
+    container[ns] = section
+  }
+
+  /** The revision map key of one section. */
+  private sectionKey(ns: SettingsNamespace, scope: SettingsScopeId | undefined): string {
+    return scope === undefined ? ns : `${ns}@${scope}`
+  }
+
+  /** The current revision of one section; zero until it first changes. */
+  private revisionOf(ns: SettingsNamespace, scope: SettingsScopeId | undefined): number {
+    return this.revisions.get(this.sectionKey(ns, scope)) ?? 0
+  }
+
+  /** Resolve one instance value: schema defaults, then `base`, then the user layer. */
   private resolve<T>(
     schema: z<T>,
     base: unknown,
-    section: Record<string, unknown> | undefined,
+    layer: unknown,
     validate?: (value: T) => void,
   ): T {
     // The merged candidate is untyped by construction; the schema call is the
     // runtime validation that admits it into T.
-    const value = schema(mergeLayers(base, section) as never)
+    const value = schema(mergeLayers(base, layer) as never)
     // The owner's own check runs on the admitted value, so it sees defaults
     // and the composition base exactly as the owner will.
     validate?.(value)
@@ -753,25 +1057,28 @@ export abstract class SettingsProvider extends Service {
   }
 
   /**
-   * Advance a namespace's revision when its RAW section changed, and announce
+   * Advance a section's revision when its RAW content changed, and announce
    * it. Deliberately independent of {@link commit}'s resolved-value equality:
    * storing an override equal to the composition base leaves the resolved
    * value alone but changes what the document says, which is exactly what a
    * configuration surface must re-read.
    */
-  private bumpRevision(registration: SettingsRegistration, before: unknown, after: unknown): void {
+  private bumpRevision(ns: SettingsNamespace, scope: SettingsScopeId | undefined, before: unknown, after: unknown): void {
     if (deepEqualJson(before, after)) return
-    registration.revision += 1
-    this.emitDocumentUpdated(registration.ns, registration.revision)
+    const key = this.sectionKey(ns, scope)
+    const revision = (this.revisions.get(key) ?? 0) + 1
+    this.revisions.set(key, revision)
+    this.emitDocumentUpdated(ns, revision, scope)
   }
 
   /** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */
-  private emitDocumentUpdated(ns: SettingsNamespace, revision: number): void {
+  private emitDocumentUpdated(ns: SettingsNamespace, revision: number, scope: SettingsScopeId | undefined): void {
     let invariantFailure: unknown
-    const args = ['settings/document-updated', ns, revision]
+    const payload: unknown[] = scope === undefined ? [ns, revision] : [ns, revision, scope]
+    const args = ['settings/document-updated', ...payload]
     for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
       try {
-        const returned = listener(ns, revision)
+        const returned = listener(...payload)
         if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
           void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
             this.warnListenerFailure(ns, error)
@@ -793,6 +1100,7 @@ export abstract class SettingsProvider extends Service {
     const prev = registration.resolved
     if (deepEqualJson(next, prev)) return
     registration.resolved = next
+    const { ns } = registration.kind
     for (const watcher of [...registration.watchers]) {
       // Serialize per watcher: invocations of one callback run one at a time
       // in commit order, so a slow stale invocation can never apply after a
@@ -806,7 +1114,7 @@ export abstract class SettingsProvider extends Service {
           return watcher.callback(next as never, prev as never)
         })
         .then(() => undefined, (error: unknown) => {
-          this.warnWatcherFailure(registration.ns, error)
+          this.warnWatcherFailure(ns, error)
         })
       watcher.tail = segment
       this.pendingTails.add(segment)
@@ -818,16 +1126,19 @@ export abstract class SettingsProvider extends Service {
     // failure is contained so one broken observer cannot wedge the commit
     // path (and, through it, a provider's reload loop).
     let invariantFailure: unknown
-    const args = ['settings/updated', registration.ns, next, prev, source]
+    const payload: unknown[] = registration.scope === undefined
+      ? [ns, next, prev, source]
+      : [ns, next, prev, source, registration.scope]
+    const args = ['settings/updated', ...payload]
     for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
       try {
-        const returned = listener(registration.ns, next, prev, source)
+        const returned = listener(...payload)
         if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
           // An emit listener may still be an async function; its rejection
           // cannot reach the synchronous INVARIANT rethrow below, so it is
           // contained here instead of becoming an unhandled rejection.
           void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
-            this.warnListenerFailure(registration.ns, error)
+            this.warnListenerFailure(ns, error)
           })
         }
       } catch (error) {
@@ -835,7 +1146,7 @@ export abstract class SettingsProvider extends Service {
           invariantFailure ??= error
           continue
         }
-        this.warnListenerFailure(registration.ns, error)
+        this.warnListenerFailure(ns, error)
       }
     }
     if (invariantFailure !== undefined) throw invariantFailure as Error

+ 3 - 3
packages/settings/settings/src/invariant.ts

@@ -21,14 +21,14 @@ export const inject = ['invariants']
  * seam's own equality predicate.
  */
 const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
-  ctx.on('settings/updated', (ns, next, prev) => {
+  ctx.on('settings/updated', (ns, next, prev, _source, scope) => {
     const settings = ctx.get('settings')
     if (settings === undefined) {
       fail(`settings/updated for "${ns}" emitted without a live settings service`)
     }
-    const current = settings.get(ns)
+    const current = settings.get(ns, scope)
     if (current === undefined) {
-      fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`)
+      fail(`settings/updated for "${ns}" (scope ${String(scope)}) emitted while the namespace is unregistered there`)
     }
     if (!deepEqualJson(current, next)) {
       fail(`settings/updated for "${ns}" does not match the authoritative resolved value`)

+ 36 - 4
packages/settings/settings/src/types.ts

@@ -14,6 +14,13 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 /** Nominal id of one registered settings namespace. */
 export type SettingsNamespace = Branded<'SettingsNamespace'>
 
+/**
+ * Nominal id of one named scope a namespace resolves under — the id
+ * `dsh-scope` gave the scope, such as `preset/standard`. The global scope has
+ * no id: it is the absence of one.
+ */
+export type SettingsScopeId = Branded<'SettingsScopeId'>
+
 /** Origin of one committed settings change. */
 export type SettingsUpdateSource = 'update' | 'provider'
 
@@ -33,6 +40,21 @@ export interface SettingsSecretView {
 export interface SettingsNamespaceView {
   /** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */
   ns: string
+  /** The named scope this view resolves under; absent for the global scope. */
+  scope?: string
+  /**
+   * Whether an owner registered the namespace under this scope. A scoped view
+   * of a namespace no owner registered under it — a preset no session has
+   * composed yet — is described from the namespace kind alone: it has no
+   * composition `base`, and a write to it takes effect when an owner mounts.
+   */
+  registered: boolean
+  /**
+   * For a scoped view: the redacted value the scope resolves without its own
+   * user section (schema defaults → composition base → global user section),
+   * so a surface can tell a field the scope overrode from one it inherits.
+   */
+  inherited?: JsonValue
   /** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */
   schema: JsonValue
   /** Redacted resolved value (schema defaults → composition base → user layer). */
@@ -68,8 +90,16 @@ export interface SettingsDescribeValue {
   writable: boolean
   /** Whether a file-backed provider owns a local document, without exposing its Host path. */
   hasDocument: boolean
-  /** One view per registered namespace. */
+  /**
+   * One view per namespace kind under the requested scope: the global scope
+   * when the read named none, else the named scope, with `registered` saying
+   * whether an owner registered the namespace there.
+   */
   namespaces: SettingsNamespaceView[]
+  /** The scope the views resolve under; absent for the global scope. */
+  scope?: string
+  /** Every named scope some namespace is registered under, for a surface offering a scope switch. */
+  scopes: string[]
 }
 
 declare module '@deepseek-ai/cordis' {
@@ -87,9 +117,10 @@ declare module '@deepseek-ai/cordis' {
      * @param next - the new resolved value.
      * @param prev - the previous resolved value.
      * @param source - whether the change entered through `update()` or the provider.
+     * @param scope - the named scope whose registration changed; absent for the global scope.
      * @mode emit
      */
-    'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
+    'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource, scope?: SettingsScopeId): void
 
     /**
      * One registered namespace's RAW user section changed, whether or not the
@@ -99,9 +130,10 @@ declare module '@deepseek-ai/cordis' {
      * resolved value, different meaning) and that their held revision is
      * stale. Listener containment matches `settings/updated`.
      * @param ns - the namespace whose stored section changed.
-     * @param revision - the namespace's new revision.
+     * @param revision - the section's new revision.
+     * @param scope - the named scope whose section changed; absent for the global section.
      * @mode emit
      */
-    'settings/document-updated'(ns: SettingsNamespace, revision: number): void
+    'settings/document-updated'(ns: SettingsNamespace, revision: number, scope?: SettingsScopeId): void
   }
 }

+ 11 - 6
packages/settings/settings/tests/memory.ts

@@ -5,14 +5,14 @@
  * packages.
  */
 
-import { SettingsProvider, type SettingsNamespace } from '../src/index.ts'
+import { SettingsProvider, type SettingsNamespace, type SettingsScopeId } from '../src/index.ts'
 
 /** In-memory provider exposing the protected provider hooks to tests. */
 export class MemorySettings extends SettingsProvider {
   /** Raw document the provider "storage" currently holds. */
   doc: Record<string, unknown>
-  /** Every persist() call observed, in order. */
-  persisted: Array<{ ns: SettingsNamespace; section: Record<string, unknown> }> = []
+  /** Every persist() call observed, in order; `scope` is present only for a scoped section. */
+  persisted: Array<{ ns: SettingsNamespace; section: Record<string, unknown>; scope?: SettingsScopeId }> = []
   /** When false, update() must reject before reaching persist(). */
   writableFlag: boolean
 
@@ -38,12 +38,17 @@ export class MemorySettings extends SettingsProvider {
     return Promise.resolve(structuredClone(this.doc))
   }
 
-  protected async persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
+  protected async persist(ns: SettingsNamespace, section: Record<string, unknown>, scope: SettingsScopeId | undefined): Promise<void> {
     if (this.persistDelayMs > 0) {
       await new Promise(resolve => setTimeout(resolve, this.persistDelayMs))
     }
-    this.persisted.push({ ns, section: structuredClone(section) })
-    this.doc[ns] = structuredClone(section)
+    this.persisted.push({ ns, section: structuredClone(section), ...scope === undefined ? {} : { scope } })
+    if (scope === undefined) {
+      this.doc[ns] = structuredClone(section)
+      return
+    }
+    const scopes = (this.doc['scopes'] ??= {}) as Record<string, Record<string, unknown>>
+    ;(scopes[scope] ??= {})[ns] = structuredClone(section)
   }
 
   /** Simulate an external storage change reaching the provider. */

+ 359 - 0
packages/settings/settings/tests/scopes.spec.ts

@@ -0,0 +1,359 @@
+/**
+ * Namespace kinds and scoped instances: a plugin mounted inside a named scope
+ * registers its namespace under that scope and resolves the document's global
+ * section with the scope's own section layered over it; every registrant of
+ * a namespace shares its schema; the document versions each section on its
+ * own; and a scope nothing registered yet is still described and writable
+ * from the kind alone.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
+import { parseSettingsScopeId, type SettingsScope, type SettingsScopeId, type SettingsUpdateSource } from '../src/index.ts'
+import { MemorySettings } from './memory.ts'
+
+interface ThemeConfig {
+  theme: 'dark' | 'light'
+  fontSize: number
+}
+
+const ThemeSchema: z<ThemeConfig> = z.object({
+  theme: z.union(['dark', 'light']).default('dark'),
+  fontSize: z.number().default(14),
+})
+
+const OtherSchema: z<{ theme: string }> = z.object({
+  theme: z.string().default('x'),
+})
+
+interface Booted {
+  ctx: Context
+  provider: MemorySettings
+  updates: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource; scope: SettingsScopeId | undefined }>
+  documents: Array<{ ns: string; revision: number; scope: SettingsScopeId | undefined }>
+}
+
+async function boot(doc: Record<string, unknown> = {}): Promise<Booted> {
+  const ctx = new Context()
+  await ctx.plugin(MemorySettings, { doc })
+  const updates: Booted['updates'] = []
+  const documents: Booted['documents'] = []
+  ctx.on('settings/updated', (ns, next, prev, source, scope) => { updates.push({ ns, next, prev, source, scope }) })
+  ctx.on('settings/document-updated', (ns, revision, scope) => { documents.push({ ns, revision, scope }) })
+  return { ctx, provider: ctx.get('settings') as MemorySettings, updates, documents }
+}
+
+/** Mount a plugin inside a named scope and register the namespace from it. */
+async function registerIn(
+  ctx: Context,
+  id: string | undefined,
+  ns: string,
+  schema: z<ThemeConfig> = ThemeSchema,
+  base?: Partial<ThemeConfig>,
+): Promise<{ scope: Scope; handle: SettingsScope<ThemeConfig> }> {
+  let scope!: Scope
+  let handle!: SettingsScope<ThemeConfig>
+  await ctx.plugin((host: Context) => {
+    scope = createScope(host, { id }, id === undefined ? {} : { id })
+  })
+  await scope.ctx.plugin({
+    inject: ['settings'],
+    apply(inner: Context) {
+      handle = inner.settings.register(ns as 'ui-theme', schema, base === undefined ? {} : { base })
+    },
+  })
+  return { scope, handle }
+}
+
+describe('scoped registration', () => {
+  it('registers an instance under the nearest named scope and resolves its section over the global one', async () => {
+    const { ctx, provider } = await boot({
+      'ui-theme': { fontSize: 18 },
+      scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } } },
+    })
+    const { handle } = await registerIn(ctx, 'preset/standard', 'ui-theme', ThemeSchema, { theme: 'dark', fontSize: 12 })
+
+    expect(handle.get()).toEqual({ theme: 'light', fontSize: 18 })
+    expect(provider.get('ui-theme', 'preset/standard')).toEqual({ theme: 'light', fontSize: 18 })
+    expect(provider.get('ui-theme')).toBeUndefined()
+    expect(provider.scopes()).toEqual(['preset/standard'])
+  })
+
+  it('uses the nearest named ancestor when the registrant\'s own scope is unnamed', async () => {
+    const { ctx, provider } = await boot({ scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } } } })
+    let handle!: SettingsScope<ThemeConfig>
+    const namedKey = { id: 'named' }
+    let anonymous!: Scope
+    await ctx.plugin((host: Context) => {
+      const named = createScope(host, namedKey, { id: 'preset/standard' })
+      anonymous = createScope(named.ctx, { id: 'agent' }, { parent: namedKey })
+    })
+    await anonymous.ctx.plugin({
+      inject: ['settings'],
+      apply(inner: Context) { handle = inner.settings.register('ui-theme', ThemeSchema) },
+    })
+
+    expect(handle.get()).toEqual({ theme: 'light', fontSize: 14 })
+    expect(provider.scopes()).toEqual(['preset/standard'])
+  })
+
+  it('lets two scopes register one kind, and refuses a second schema or a duplicate scope', async () => {
+    const { ctx } = await boot()
+    const standard = await registerIn(ctx, 'preset/standard', 'ui-theme')
+    const research = await registerIn(ctx, 'preset/research', 'ui-theme')
+    ctx.settings.register('ui-theme', ThemeSchema)
+
+    expect(standard.handle.get()).toEqual({ theme: 'dark', fontSize: 14 })
+    expect(research.handle.get()).toEqual({ theme: 'dark', fontSize: 14 })
+    expect(ctx.settings.scopes()).toEqual(['preset/standard', 'preset/research'])
+    expect(() => ctx.settings.register('ui-theme', ThemeSchema))
+      .toThrow('settings namespace "ui-theme" is already registered')
+    await expect(registerIn(ctx, 'preset/standard', 'ui-theme'))
+      .rejects.toThrow('already registered under scope "preset/standard"')
+    await expect(registerIn(ctx, 'preset/other', 'ui-theme', OtherSchema as unknown as z<ThemeConfig>))
+      .rejects.toThrow('already registered with a different schema')
+  })
+
+  it('drops the kind with its last instance, not before', async () => {
+    const { ctx, provider } = await boot()
+    const { scope } = await registerIn(ctx, 'preset/standard', 'ui-theme')
+    const research = await registerIn(ctx, 'preset/research', 'ui-theme')
+    expect(provider.describe().map(descriptor => descriptor.ns)).toEqual(['ui-theme'])
+
+    await scope.dispose()
+    expect(provider.scopes()).toEqual(['preset/research'])
+    expect(provider.describe().map(descriptor => descriptor.ns)).toEqual(['ui-theme'])
+
+    await research.scope.dispose()
+    expect(provider.describe()).toEqual([])
+    expect(provider.scopes()).toEqual([])
+  })
+
+  it('rejects a registration whose scoped section is malformed, naming the scope', async () => {
+    const { ctx } = await boot({ scopes: { 'preset/standard': { 'ui-theme': 'nope' } } })
+    await expect(registerIn(ctx, 'preset/standard', 'ui-theme')).rejects.toThrow('settings section "ui-theme" under scope "preset/standard" must be an object of keys')
+  })
+
+  it('installs a section under a scope with the consumer\'s own validation', async () => {
+    const { ctx, provider } = await boot()
+    let scope!: Scope
+    await ctx.plugin((host: Context) => { scope = createScope(host, { id: 'standard' }, { id: 'preset/standard' }) })
+    const seen: ThemeConfig[] = []
+    await scope.ctx.plugin({
+      inject: ['settings'],
+      apply(inner: Context) {
+        let source: () => ThemeConfig = () => ({ theme: 'dark', fontSize: 1 })
+        inner.settings.installSection(inner, 'ui-theme', ThemeSchema, { theme: 'dark', fontSize: 1 }, {
+          setSource: (current) => { source = current },
+          onChange: () => { seen.push(source()) },
+          validate: (value) => { if (value.fontSize > 40) throw new Error('too large') },
+        })
+      },
+    })
+
+    expect(seen).toEqual([{ theme: 'dark', fontSize: 1 }])
+    await provider.update('ui-theme', { fontSize: 30 }, undefined, 'preset/standard')
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(seen.at(-1)).toEqual({ theme: 'dark', fontSize: 30 })
+    await expect(provider.update('ui-theme', { fontSize: 50 }, undefined, 'preset/standard')).rejects.toThrow('too large')
+  })
+
+  it('refuses the reserved scopes namespace and a malformed scope id', async () => {
+    const { ctx, provider } = await boot()
+    expect(() => ctx.settings.register('scopes', ThemeSchema)).toThrow('reserved for the document\'s per-scope sections')
+    expect(() => provider.describe({ scope: 'Preset/Standard' })).toThrow('must match')
+    expect(() => parseSettingsScopeId('preset//x')).toThrow(TypeError)
+    expect(parseSettingsScopeId('preset/standard')).toBe('preset/standard')
+    await expect(registerIn(ctx, 'Not Valid', 'ui-theme')).rejects.toThrow('settings scope "Not Valid" must match')
+  })
+})
+
+describe('writes and change propagation', () => {
+  it('re-resolves every instance on a global write, gated on the resolved value', async () => {
+    const { ctx, provider, updates, documents } = await boot({
+      scopes: { 'preset/research': { 'ui-theme': { fontSize: 20 } } },
+    })
+    const standard = await registerIn(ctx, 'preset/standard', 'ui-theme')
+    const research = await registerIn(ctx, 'preset/research', 'ui-theme')
+    ctx.settings.register('ui-theme', ThemeSchema)
+
+    await provider.update('ui-theme', { fontSize: 16 })
+
+    expect(standard.handle.get()).toEqual({ theme: 'dark', fontSize: 16 })
+    // The research scope overrides the field the global write changed.
+    expect(research.handle.get()).toEqual({ theme: 'dark', fontSize: 20 })
+    expect(provider.get('ui-theme')).toEqual({ theme: 'dark', fontSize: 16 })
+    expect(updates.map(update => [update.scope, (update.next as ThemeConfig).fontSize])).toEqual([
+      ['preset/standard', 16], [undefined, 16],
+    ])
+    expect(documents).toEqual([{ ns: 'ui-theme', revision: 1, scope: undefined }])
+    expect(provider.persisted).toEqual([{ ns: 'ui-theme', section: { fontSize: 16 } }])
+  })
+
+  it('writes one scope\'s section through the owner handle and the provider, committing only that scope', async () => {
+    const { ctx, provider, updates, documents } = await boot()
+    const standard = await registerIn(ctx, 'preset/standard', 'ui-theme')
+    const research = await registerIn(ctx, 'preset/research', 'ui-theme')
+
+    await standard.handle.update({ theme: 'light' })
+    await provider.mutate('ui-theme', [{ op: 'set', path: ['fontSize'], value: 9 }], undefined, 'preset/research')
+    await provider.replace('ui-theme', { fontSize: 10 }, undefined, 'preset/research')
+
+    expect(standard.handle.get()).toEqual({ theme: 'light', fontSize: 14 })
+    expect(research.handle.get()).toEqual({ theme: 'dark', fontSize: 10 })
+    expect(provider.persisted).toEqual([
+      { ns: 'ui-theme', section: { theme: 'light' }, scope: 'preset/standard' },
+      { ns: 'ui-theme', section: { fontSize: 9 }, scope: 'preset/research' },
+      { ns: 'ui-theme', section: { fontSize: 10 }, scope: 'preset/research' },
+    ])
+    expect(provider.doc).toEqual({ scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } }, 'preset/research': { 'ui-theme': { fontSize: 10 } } } })
+    expect(updates.map(update => update.scope)).toEqual(['preset/standard', 'preset/research', 'preset/research'])
+    expect(documents.map(entry => [entry.scope, entry.revision])).toEqual([['preset/standard', 1], ['preset/research', 1], ['preset/research', 2]])
+    await standard.handle.replace({})
+    expect(standard.handle.get()).toEqual({ theme: 'dark', fontSize: 14 })
+  })
+
+  it('versions each section on its own and refuses a stale writer per section', async () => {
+    const { ctx, provider } = await boot()
+    await registerIn(ctx, 'preset/standard', 'ui-theme')
+    ctx.settings.register('ui-theme', ThemeSchema)
+    await provider.update('ui-theme', { fontSize: 16 })
+    await provider.update('ui-theme', { fontSize: 17 })
+
+    const [global, scoped] = [provider.describe(), provider.describe({ scope: 'preset/standard' })]
+    expect(global[0]?.revision).toBe(2)
+    expect(scoped[0]?.revision).toBe(0)
+    await expect(provider.update('ui-theme', { fontSize: 1 }, 1)).rejects.toMatchObject({ code: 'SETTINGS_CONFLICT', expected: 1, actual: 2 })
+    await expect(provider.update('ui-theme', { fontSize: 1 }, 0, 'preset/standard')).resolves.toBeUndefined()
+    await expect(provider.update('ui-theme', { fontSize: 2 }, 0, 'preset/standard')).rejects.toMatchObject({ code: 'SETTINGS_CONFLICT', expected: 0, actual: 1 })
+  })
+
+  it('accepts a write for a scope nothing registered yet, judged by the schema, and takes effect when an owner mounts', async () => {
+    const { ctx, provider, updates } = await boot()
+    ctx.settings.register('ui-theme', ThemeSchema)
+
+    await provider.update('ui-theme', { theme: 'light' }, undefined, 'preset/later')
+    await expect(provider.update('ui-theme', { theme: 'sepia' }, undefined, 'preset/later')).rejects.toThrow()
+
+    expect(provider.doc).toEqual({ scopes: { 'preset/later': { 'ui-theme': { theme: 'light' } } } })
+    expect(updates).toEqual([])
+    const later = await registerIn(ctx, 'preset/later', 'ui-theme')
+    expect(later.handle.get()).toEqual({ theme: 'light', fontSize: 14 })
+    await expect(provider.update('absent', { theme: 'light' }, undefined, 'preset/later')).rejects.toThrow('is not registered')
+  })
+
+  it('rejects a scoped write whose resolved value the owner refuses, before persisting', async () => {
+    const { ctx, provider } = await boot()
+    ctx.settings.register('ui-theme', ThemeSchema, { validate: (value) => { if (value.fontSize > 40) throw new Error('too large') } })
+    await registerIn(ctx, 'preset/standard', 'ui-theme')
+
+    await expect(provider.update('ui-theme', { fontSize: 99 }, undefined, 'preset/standard')).rejects.toThrow('too large')
+    expect(provider.persisted).toEqual([])
+  })
+
+  it('publishes external scope edits, keeps the last good value for a malformed scoped section, and versions unregistered scopes', async () => {
+    const { ctx, provider, updates, documents } = await boot()
+    const standard = await registerIn(ctx, 'preset/standard', 'ui-theme')
+
+    provider.pushExternal({ scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } }, 'preset/other': { 'ui-theme': { fontSize: 1 } } } })
+    expect(standard.handle.get()).toEqual({ theme: 'light', fontSize: 14 })
+    expect(documents.map(entry => [entry.scope, entry.revision])).toEqual([['preset/standard', 1], ['preset/other', 1]])
+    expect(updates.map(update => update.scope)).toEqual(['preset/standard'])
+
+    provider.pushExternal({ scopes: { 'preset/standard': { 'ui-theme': { theme: 'sepia' } } } })
+    expect(standard.handle.get()).toEqual({ theme: 'light', fontSize: 14 })
+    // A malformed scope entry and a malformed scopes map are read as absent by the describers.
+    provider.pushExternal({ scopes: { 'preset/standard': 'nope' } })
+    expect(provider.describe({ scope: 'preset/standard' })[0]).toMatchObject({ value: { theme: 'light', fontSize: 14 } })
+    provider.pushExternal({ scopes: 'nope' })
+    expect(provider.describe({ scope: 'preset/standard' })[0]?.user).toBeUndefined()
+    // A scope id the document spells outside the grammar names no section.
+    provider.pushExternal({ scopes: { 'Not Valid': { 'ui-theme': { theme: 'light' } } } })
+    expect(documents.at(-1)?.scope).not.toBe('Not Valid')
+  })
+
+  it('serializes the global and one scoped section on separate queues', async () => {
+    const { ctx, provider } = await boot()
+    ctx.settings.register('ui-theme', ThemeSchema)
+    provider.persistDelayMs = 5
+
+    await Promise.all([
+      provider.update('ui-theme', { fontSize: 1 }),
+      provider.update('ui-theme', { fontSize: 2 }, undefined, 'preset/standard'),
+      provider.update('ui-theme', { theme: 'light' }),
+    ])
+
+    expect(provider.doc).toEqual({ 'ui-theme': { fontSize: 1, theme: 'light' }, scopes: { 'preset/standard': { 'ui-theme': { fontSize: 2 } } } })
+  })
+})
+
+describe('describe', () => {
+  it('describes the global scope from the kind when only scoped instances exist', async () => {
+    const { ctx, provider } = await boot({ 'ui-theme': { fontSize: 18 } })
+    await registerIn(ctx, 'preset/standard', 'ui-theme', ThemeSchema, { theme: 'light' })
+
+    expect(provider.describe()).toEqual([{
+      ns: 'ui-theme',
+      registered: false,
+      schema: ThemeSchema.toJSON(),
+      value: { theme: 'dark', fontSize: 18 },
+      revision: 0,
+      user: { fontSize: 18 },
+      applies: 'live',
+    }])
+  })
+
+  it('describes one scope with its base, own section, and inherited value', async () => {
+    const { ctx, provider } = await boot({
+      'ui-theme': { fontSize: 18 },
+      scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } } },
+    })
+    await registerIn(ctx, 'preset/standard', 'ui-theme', ThemeSchema, { theme: 'dark', fontSize: 12 })
+
+    const [standard] = provider.describe({ scope: 'preset/standard' })
+    expect(standard).toEqual({
+      ns: 'ui-theme',
+      scope: 'preset/standard',
+      registered: true,
+      schema: ThemeSchema.toJSON(),
+      value: { theme: 'light', fontSize: 18 },
+      revision: 0,
+      base: { theme: 'dark', fontSize: 12 },
+      user: { theme: 'light' },
+      inherited: { theme: 'dark', fontSize: 18 },
+      applies: 'live',
+    })
+    // A scope no owner registered: no base, inherits the global section.
+    const [other] = provider.describe({ scope: 'preset/other' })
+    expect(other).toEqual({
+      ns: 'ui-theme',
+      scope: 'preset/other',
+      registered: false,
+      schema: ThemeSchema.toJSON(),
+      value: { theme: 'dark', fontSize: 18 },
+      revision: 0,
+      inherited: { theme: 'dark', fontSize: 18 },
+      applies: 'live',
+    })
+  })
+
+  it('redacts secrets in the inherited value too', async () => {
+    const Secret = z.object({ token: z.string().role('secret'), size: z.number().default(1) })
+    const { ctx, provider } = await boot({ secretive: { token: 'global-token' } })
+    ctx.settings.register('secretive', Secret)
+
+    const [view] = provider.describe({ scope: 'preset/standard', redactSecrets: true })
+    expect(view?.inherited).toEqual({ size: 1 })
+    expect(view?.secrets).toEqual([{ path: ['token'], set: true }])
+  })
+
+  it('describes a scope whose stored section the schema refuses from the layers below it', async () => {
+    const { ctx, provider } = await boot({ scopes: { 'preset/standard': { 'ui-theme': { fontSize: 'huge' } } } })
+    ctx.settings.register('ui-theme', ThemeSchema)
+
+    const [standard] = provider.describe({ scope: 'preset/standard' })
+    expect(standard).toMatchObject({ registered: false, value: { theme: 'dark', fontSize: 14 }, user: { fontSize: 'huge' } })
+  })
+})

+ 3 - 0
packages/settings/settings/tsconfig.json

@@ -20,6 +20,9 @@
     {
       "path": "../../util/brand"
     },
+    {
+      "path": "../../core/scope"
+    },
     {
       "path": "../../util/values"
     },

+ 2 - 2
packages/skill/skill-filesystem/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/skill/skill-filesystem/README.md
-README.md: 808df61c0fa248ba02b4e2993e6cbe60ede35f7c
-README.zh.md: 6e542dee5bb275488cea9dadba10decd6ecb6733
+README.md: dbd36591ed645bedf821c129615c7e61e623a046
+README.zh.md: 5b06cf3f136d7aecf017a716bc8ebc73beb8fc90

+ 3 - 1
packages/skill/skill-filesystem/README.md

@@ -68,12 +68,14 @@ Load the plugin alongside the skill registry; it requires `ctx.skills`.
 | `includeDefaultRoots` | `true` | Include project and user roots around `customSkillDirs` |
 | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness config root; its `skills` subdirectory is scanned |
 | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills |
-| `customSkillDirs` | `[]` | Additional local skill roots, after project roots and before user roots |
+| `customSkillDirs` | `[]` | Additional local skill roots, after project roots and before user roots; also the base of the `skill-filesystem` settings section |
 | `watch` | `true` | Watch local roots and invalidate the provider when the catalog may have changed |
 | `bundledSkillDir` | — | Bundled skill root scanned at rank 600 when configured |
 
 The remaining `watch*` fields tune Chokidar behavior — polling, stability window, interval, project cap, and symlink following. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-skill-filesystem) is the exhaustive source for every field.
 
+While a settings provider is composed, `customSkillDirs` also resolves through the `skill-filesystem` settings namespace with the composition value as its base: a person adds a root to their own catalog in the settings document, and the provider re-lists with the new roots without a restart. A row mounted inside an agent preset registers under that preset's scope, so `scopes.preset/<id>.skill-filesystem` adds roots for that preset alone over the global section. Without a settings provider the composition value stands alone.
+
 ### Change detection
 
 Existing roots are watched, so adding, renaming, or deleting a skill (or editing its frontmatter) triggers a catalog refresh for the next model step; edits below `references`, `scripts`, `assets`, and other bundle resources do not. The first-party `write` and `edit` tools invalidate the provider directly when their target could affect a watched skill, so the model observes its own filesystem mutation without waiting for the host watcher. External IDE, Git, and shell changes are picked up by the host watcher, and a root that does not exist yet is probed until it appears.

+ 3 - 1
packages/skill/skill-filesystem/README.zh.md

@@ -68,12 +68,14 @@ skill 可以是被扫描根目录顶层的目录 bundle `<name>/SKILL.md`,也
 | `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根与用户根 |
 | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | Harness 配置根目录;扫描其 `skills` 子目录 |
 | `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent 配置根目录 |
-| `customSkillDirs` | `[]` | 其他本地 skill 根目录,位于项目根之后、用户根之前 |
+| `customSkillDirs` | `[]` | 其他本地 skill 根目录,位于项目根之后、用户根之前;也是 `skill-filesystem` settings 分节的 base |
 | `watch` | `true` | 监视本地根,并在目录可能变化时使提供方失效 |
 | `bundledSkillDir` | — | 配置后按 rank 600 扫描的内置 skill 根目录 |
 
 其余 `watch*` 字段用于调节 Chokidar 行为——轮询、稳定窗口、间隔、项目上限与符号链接跟随。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-skill-filesystem)是每个字段的穷尽式真源。
 
+组合了 settings provider 时,`customSkillDirs` 还会以组合值为 base 经 `skill-filesystem` settings namespace 解析:一个人在 settings 文档里给自己的目录添加一个根目录,provider 就用新的根目录重新列出,无需重启。挂在 agent preset 内的行注册在该 preset 的 scope 下,因此 `scopes.preset/<id>.skill-filesystem` 只为该 preset 在全局分节之上添加根目录。没有 settings provider 时只有组合值。
+
 ### 变更检测
 
 现有根目录会被监视,因此新增、改名或删除 skill(或编辑其 frontmatter)会在下一个模型步骤触发目录刷新;`references`、`scripts`、`assets` 等 bundle 资源下的编辑不会触发。当第一方 `write` 与 `edit` 工具的目标可能影响受监视的 skill 时,它们会直接使提供方失效,因此模型无需等待宿主 watcher 即可观察到自身的文件系统变更。外部 IDE、Git 与 shell 变更由宿主 watcher 捕获;尚不存在的根目录会被探测,直至其出现。

+ 7 - 0
packages/skill/skill-filesystem/package.json

@@ -29,9 +29,15 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-fs": "workspace:^",
     "@deepseek-ai/dsh-home-paths": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
+  "peerDependenciesMeta": {
+    "@deepseek-ai/dsh-settings": {
+      "optional": true
+    }
+  },
   "dependencies": {
     "chokidar": "^5.0.0",
     "@deepseek-ai/schemastery": "workspace:^",
@@ -40,6 +46,7 @@
   "devDependencies": {
     "@deepseek-ai/dsh-fs": "workspace:^",
     "@deepseek-ai/dsh-home-paths": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   }

+ 56 - 2
packages/skill/skill-filesystem/src/index.ts

@@ -20,6 +20,8 @@ import type Schema from '@deepseek-ai/schemastery'
 import { parse as parseYaml } from 'yaml'
 import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
 import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
+// Type-only: resolves `ctx.settings` for the optional settings injection.
+import type {} from '@deepseek-ai/dsh-settings'
 import {
   BUNDLED_SKILL_RANK,
   isSkillName,
@@ -45,6 +47,23 @@ const DEFAULT_WATCH_MAX_PROJECTS = 128
 export const name = 'skill-filesystem'
 export const inject = ['skills']
 
+/** Settings namespace carrying the user-writable subset of this provider's config. */
+export const SETTINGS_NAMESPACE = 'skill-filesystem'
+
+/**
+ * The user-writable subset: the roots a person adds to their own catalog.
+ * Every other field is a deployment choice a composition sets.
+ */
+export interface SkillFilesystemSettings {
+  /** Additional skill roots scanned after project roots and before user roots. */
+  customSkillDirs: string[]
+}
+
+/** Runtime schema for the user-writable subset. */
+export const SkillFilesystemSettingsSchema: z<SkillFilesystemSettings> = z.object({
+  customSkillDirs: z.array(z.string()).default([]),
+})
+
 /** Local filesystem skill provider configuration. */
 export interface Config {
   /** Unique provider name. Defaults to `filesystem`. */
@@ -126,7 +145,17 @@ interface ResolvedWatchConfig {
   followSymlinks: boolean
 }
 
-/** Register the local filesystem skill provider on `ctx.skills`. */
+/**
+ * Register the local filesystem skill provider on `ctx.skills`. While a
+ * settings provider is composed, the user-writable subset of the config —
+ * `customSkillDirs` — resolves through the `skill-filesystem` settings
+ * namespace with the composition value as its base, under the scope this
+ * row is mounted in: a row inside an agent preset reads that preset's
+ * section over the global one. Without a settings provider the composition
+ * value stands alone.
+ * @param ctx - the mounting context.
+ * @param config - the provider config; `customSkillDirs` is the settings base.
+ */
 export function apply(ctx: Context, config: Config = {}): void {
   let provider!: FileSystemSkillProvider
   ctx.skills.registerProvider((control) => {
@@ -136,6 +165,15 @@ export function apply(ctx: Context, config: Config = {}): void {
   ctx.effect(function* () {
     yield async () => { await provider.dispose() }
   }, 'skill-filesystem watcher')
+  const entry: SkillFilesystemSettings = { customSkillDirs: config.customSkillDirs ?? [] }
+  ctx.inject(['settings'], (settingsCtx) => {
+    // Assigned by `setSource` before the first `onChange`, per the hooks contract.
+    let source!: () => SkillFilesystemSettings
+    settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, SkillFilesystemSettingsSchema, entry, {
+      setSource: (current) => { source = current },
+      onChange: () => { provider.setCustomSkillDirs(source().customSkillDirs) },
+    })
+  })
   ctx.on('fs/observed', (target, _observation, actor) => {
     if (mutationToolName(actor) === undefined) return
     provider.observeHostMutation(target.displayPath)
@@ -148,7 +186,8 @@ export class FileSystemSkillProvider implements SkillProvider {
   private readonly includeDefaultRoots: boolean
   private readonly dshHome: string
   private readonly agentsHome: string
-  private readonly customSkillDirs: string[]
+  private customSkillDirs: string[]
+  private readonly control: SkillProviderControl
   private readonly watchManager: SkillWatchManager
   private readonly bundledSkillDir: string | undefined
   private disposal: Promise<void> | undefined
@@ -163,6 +202,7 @@ export class FileSystemSkillProvider implements SkillProvider {
     this.dshHome = resolveDshHome(config.dshHome)
     this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
     this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
+    this.control = control
     this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config))
     control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true })
     // The environment bundled root is a default root: an isolated provider
@@ -221,6 +261,20 @@ export class FileSystemSkillProvider implements SkillProvider {
     }
   }
 
+  /**
+   * Replace the custom roots and invalidate the catalog when they changed —
+   * the settings path for a person adding a root to their own catalog while
+   * the process runs. Watching follows on the next listing, which observes
+   * the roots it scans.
+   * @param dirs - the roots, relative paths resolved against the process cwd.
+   */
+  setCustomSkillDirs(dirs: readonly string[]): void {
+    const next = dirs.map(root => resolve(root))
+    if (next.length === this.customSkillDirs.length && next.every((root, index) => root === this.customSkillDirs[index])) return
+    this.customSkillDirs = next
+    this.control.invalidate()
+  }
+
   /**
    * Invalidate this provider synchronously after a first-party filesystem mutation.
    * @param path - host display path observed after a model-facing write or edit.

+ 29 - 0
packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts

@@ -880,3 +880,32 @@ describe('FileSystemSkillProvider', () => {
     }
   })
 })
+
+describe('the skill-filesystem settings section', () => {
+  it('exposes customSkillDirs through settings with the composition value as base, and re-lists on change', async () => {
+    const { MemorySettings } = await import('../../../settings/settings/tests/memory.ts')
+    const home = await tempDir('skill-settings')
+    const composed = join(home, 'composed')
+    const added = join(home, 'added')
+    await writeSkill(composed, 'composed-skill', 'From the composition.')
+    await writeSkill(added, 'added-skill', 'From the user document.')
+    const ctx = new Context()
+    await ctx.plugin(MemorySettings)
+    await ctx.plugin(SkillRegistry)
+    await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false, includeDefaultRoots: false, customSkillDirs: [composed] })
+
+    const [descriptor] = ctx.settings.describe()
+    expect(descriptor).toMatchObject({ ns: 'skill-filesystem', base: { customSkillDirs: [composed] }, value: { customSkillDirs: [composed] } })
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['composed-skill'])
+
+    await ctx.settings.update('skill-filesystem', { customSkillDirs: [added] })
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['added-skill'])
+
+    // The same roots again change nothing; a reset restores the composition's.
+    await ctx.settings.update('skill-filesystem', { customSkillDirs: [added] })
+    await ctx.settings.replace('skill-filesystem', {})
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['composed-skill'])
+  })
+})

+ 2 - 1
packages/skill/skill-filesystem/tsconfig.json

@@ -11,6 +11,7 @@
     { "path": "../../../vendor/schemastery" },
     { "path": "../../fs/fs" },
     { "path": "../../util/home-paths" },
-    { "path": "../skill" }
+    { "path": "../skill" },
+    { "path": "../../settings/settings" }
   ]
 }

+ 17 - 8
pnpm-lock.yaml

@@ -184,6 +184,9 @@ importers:
       '@deepseek-ai/dsh-fs-local':
         specifier: workspace:^
         version: link:../../packages/fs/fs-local
+      '@deepseek-ai/dsh-global-tool-mask':
+        specifier: workspace:^
+        version: link:../../packages/preset/global-tool-mask
       '@deepseek-ai/dsh-goal':
         specifier: workspace:^
         version: link:../../packages/goal/goal
@@ -295,9 +298,6 @@ importers:
       '@deepseek-ai/dsh-tool-ralph':
         specifier: workspace:^
         version: link:../../packages/workflow/tool-ralph
-      '@deepseek-ai/dsh-global-tool-mask':
-        specifier: workspace:^
-        version: link:../../packages/preset/global-tool-mask
       '@deepseek-ai/dsh-tool-skill':
         specifier: workspace:^
         version: link:../../packages/skill/tool-skill
@@ -887,6 +887,9 @@ importers:
       '@deepseek-ai/dsh-native-command':
         specifier: workspace:^
         version: link:../../util/native-command
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../../core/scope
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
@@ -6660,7 +6663,7 @@ importers:
         specifier: workspace:^
         version: link:../../typert/protocol
 
-  packages/preset/persona:
+  packages/preset/global-tool-mask:
     dependencies:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
@@ -6675,8 +6678,11 @@ importers:
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
 
-  packages/preset/global-tool-mask:
+  packages/preset/persona:
     dependencies:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
@@ -6691,9 +6697,6 @@ importers:
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt
-      '@deepseek-ai/dsh-tools':
-        specifier: workspace:^
-        version: link:../../core/tools
 
   packages/runtime-diagnostics/invariants:
     dependencies:
@@ -7625,6 +7628,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../../core/scope
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
@@ -8091,6 +8097,9 @@ importers:
       '@deepseek-ai/dsh-home-paths':
         specifier: workspace:^
         version: link:../../util/home-paths
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@deepseek-ai/dsh-skill':
         specifier: workspace:^
         version: link:../skill

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -545,6 +545,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   ToolRestriction: 'tools.md',
   ToolSchema: 'tools.md',
   SettingsNamespace: 'settings.md',
+  SettingsScopeId: 'settings.md',
   SettingsNamespaceInput: 'settings.md',
   SettingsRegisterOptions: 'settings.md',
   SettingsSectionHooks: 'settings.md',