Sfoglia il codice sorgente

feat(settings): add user-settings seam (ctx.settings) + file provider

Two-package capability family mirroring session-persistence/:

- dsh-settings: abstract Settings service — namespace registry with
  caller-fiber effect registrations, layered resolution (schema defaults
  < composition base < user document), schemastery validation,
  per-namespace deep-equal commit detection, and the settings/updated
  event. Boot/registration validation fails loud; provider publishes
  keep last-good per namespace.
- dsh-settings-local: settings.yaml/.json provider — resolveSpec
  defaulting to $DSH_HOME/settings.yaml, chokidar hot reload,
  content-equality self-write suppression, atomic 0600 tmp+rename
  writes, comment-preserving YAML namespace patching.

Consumers register inside ctx.inject(['settings'], …), so every
composition works unchanged without a mounted provider. Real Loader +
Include composition test proves cordis.yml boot and external-edit hot
propagation; HMR disposal test proves registry cleanup. Both packages
hold per-file 100% coverage.

Doc budgets rise 1705→1710 (AGENTS.md) and 835→845 (packages/README.md):
one structural line per file for the new package group.

Agent Note: .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md
Yichen Jiang 2 mesi fa
parent
commit
ec0786e099
43 ha cambiato i file con 2098 aggiunte e 4 eliminazioni
  1. 6 0
      .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml
  2. 35 0
      .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md
  3. 35 0
      .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md
  4. 1 0
      AGENTS.md
  5. 6 0
      docs/capability-seams.md
  6. 19 0
      docs/config-catalog.md
  7. 22 0
      docs/cordis-catalog/events.md
  8. 42 0
      docs/cordis-catalog/services.md
  9. 1 0
      docs/event-producer-consumer.md
  10. 2 2
      packages/README.i18n.yaml
  11. 1 0
      packages/README.md
  12. 1 0
      packages/README.zh.md
  13. 49 0
      packages/cordis/tool-cordis/src/api-catalog.ts
  14. 6 0
      packages/settings/README.i18n.yaml
  15. 12 0
      packages/settings/README.md
  16. 12 0
      packages/settings/README.zh.md
  17. 6 0
      packages/settings/settings-local/README.i18n.yaml
  18. 36 0
      packages/settings/settings-local/README.md
  19. 36 0
      packages/settings/settings-local/README.zh.md
  20. 46 0
      packages/settings/settings-local/package.json
  21. 226 0
      packages/settings/settings-local/src/index.ts
  22. 31 0
      packages/settings/settings-local/src/invariant.ts
  23. 112 0
      packages/settings/settings-local/tests/loader-composition.spec.ts
  24. 254 0
      packages/settings/settings-local/tests/local.spec.ts
  25. 118 0
      packages/settings/settings-local/tests/watcher.spec.ts
  26. 30 0
      packages/settings/settings-local/tsconfig.json
  27. 6 0
      packages/settings/settings/README.i18n.yaml
  28. 35 0
      packages/settings/settings/README.md
  29. 35 0
      packages/settings/settings/README.zh.md
  30. 41 0
      packages/settings/settings/package.json
  31. 304 0
      packages/settings/settings/src/index.ts
  32. 41 0
      packages/settings/settings/src/invariant.ts
  33. 41 0
      packages/settings/settings/tests/invariant.spec.ts
  34. 53 0
      packages/settings/settings/tests/memory.ts
  35. 307 0
      packages/settings/settings/tests/settings.spec.ts
  36. 27 0
      packages/settings/settings/tsconfig.json
  37. 40 0
      pnpm-lock.yaml
  38. 2 2
      scripts/doc-budgets.manifest.json
  39. 6 0
      scripts/gen-cordis-catalog.ts
  40. 9 0
      scripts/gen-doc-graphs.ts
  41. 2 0
      scripts/verify-package-readme-model-experience.ts
  42. 2 0
      tsconfig.base.json
  43. 2 0
      tsconfig.host.json

+ 6 - 0
.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.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-07-28-user-settings-seam.md
+2026-07-28-user-settings-seam.md: f0f45d77c8f98fc15625b1a1bf116ec10b965676
+2026-07-28-user-settings-seam.zh.md: eb562099b1ff0b5b0a019e9956d6e36e71857236

+ 35 - 0
.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md

@@ -0,0 +1,35 @@
+# Agent Note: user-settings seam (`ctx.settings`) and the file provider
+
+Status: implemented
+
+English | [中文](2026-07-28-user-settings-seam.zh.md)
+
+> Scope: the `packages/settings/` capability family — the abstract seam, the file-backed provider, and the composition boundary between user settings and `cordis.yml`. The [web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) recorded "the profile write path" as a deferral; this seam is that write path's owner. Consumer migrations (theme, locale, default model route) and the web `settings.*` RPC surface are follow-ups, not part of this note's shipped scope.
+
+## Problem
+
+User-editable configuration had no owner: `dsh web` read a cwd-anchored profile json through a static whitelist with no write path, the TUI read `$DSH_HOME/config.yaml` raw loader patches, and both froze at boot. A personal-settings page (web GUI) needs one cross-surface user layer with schema validation, a write path, and hot propagation — and peer products (Codex, Claude Code, Kimi, OpenCode, Pi) all converged on separating user preferences from extension composition. Loader-reactive config updates cannot carry this: `fiber.update` swaps entry config in place, so a plugin that read config at construction observes nothing and no callback tells it otherwise.
+
+## Decision
+
+**Two planes with a litmus test.** `cordis.yml` (+ Include patches) stays the composition plane: which plugins exist, wiring, deployment config, owned by the orchestrator and upgraded with the product. A settings namespace carries only the user-editable subset; the test is "should the personal config page edit it?" Values live in both planes without ambiguity because layering is the contract: schema defaults, then the registrant's composition `base` (its entry-config subset), then the user document section.
+
+**Three-package seam mirroring `session-persistence/`.** `dsh-settings` owns the abstract `Settings` service: namespace registry, layered resolution, schema validation, per-namespace deep-equal change detection, and the `settings/updated` commit event. Providers implement only `writable`/`load()`/`persist(ns, section)` and push externally observed documents through the protected `publish(doc)` — so hot-update semantics are identical across providers, and a network configuration-center backend (nacos-style, possibly read-only) is a sibling package away. `dsh-settings-local` is the file provider: YAML/JSON under `resolveSpec` (explicit defaulting to `<DSH_HOME>/settings.yaml`), chokidar watch, atomic `0600` tmp+rename writes, comment-preserving YAML patching of exactly one namespace key, and content-equality self-write suppression.
+
+**Registrations are caller-fiber effects.** `register()` runs through the service proxy, so `this.ctx` is the registrant's context and the registration rides `ctx.effect`: disposing the registrant removes the namespace and its watchers (proven by the HMR disposal test), while the user's section keeps living in storage for the next owner.
+
+**Fail loud at rest, last-good in motion.** Boot-time and registration-time validation throw (invalid stored section fails the registering plugin; an existing-but-unparsable document fails provider load). Once live, a bad external edit warns and keeps the last good state per namespace — a hot reload must never take the process down. This asymmetry mirrors `Include.refresh()` and Kimi's safe runtime reload.
+
+**Consumers stay optional-by-construction.** A consumer registers inside `ctx.inject(['settings'], …)`; without a mounted provider it keeps resolving entry config alone, so every existing composition, demo, and snapshot works unchanged and migration is per-plugin.
+
+## Alternatives considered
+
+- **Include write-back as the user layer** (per-plugin config pages writing loader entry files, cordis-webui style): write-back would target per-composition files, binding user preferences to one `cordis.yml`; a per-user layer must survive template upgrades and serve TUI and web from one document.
+- **Loader-reactive `fiber.update` as the propagation channel**: constructor-time reads observe nothing; the seam's explicit `watch()` makes hot-update a consumer contract instead of framework magic.
+- **A domain-aware settings service** (getters per product area): the coupling objection from design review stands; the service stores, validates, and publishes — domain meaning stays with the registrant that owns the schema.
+- **Multi-layer precedence now** (system/managed/project tiers à la Codex/Claude Code): deferred until a real second layer exists; the resolve step is the single place layering would extend.
+- **A cross-process lockfile now** (Pi's proper-lockfile): atomic replace plus watcher convergence (last write wins) is documented behavior until real contention shows up.
+
+## Consequences
+
+Deferred, in dependency order: the web `settings.raw`/`settings.describe`/`settings.update` RPC surface (which must redact `role('secret')` fields before exposure); first consumer migrations (`ui-theme`, locale, api-gateway default route) retiring `PROFILE_MAPPINGS` and the profile json; `${env:VAR}` value indirection for secrets; provider-side layering. The keyless snapshot obligation lands with the first model- or product-user-visible consumer, not with this infrastructure step.

+ 35 - 0
.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md

@@ -0,0 +1,35 @@
+# Agent Note:用户设置 seam(`ctx.settings`)与文件 provider
+
+Status: implemented
+
+[English](2026-07-28-user-settings-seam.md) | 中文
+
+> 范围:`packages/settings/` 能力族——抽象 seam、文件 provider,以及用户设置与 `cordis.yml` 的组合边界。[web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) 曾把"profile 写路径"记为延后项;本 seam 就是该写路径的归属。消费者迁移(主题、语言、默认模型路由)与 web `settings.*` RPC 面是后续工作,不在本 note 已交付范围内。
+
+## 问题
+
+用户可编辑配置没有归属:`dsh web` 经静态白名单读 cwd 锚定的 profile json 且无写路径,TUI 读 `$DSH_HOME/config.yaml` 裸 loader patch,两者都在启动时冻结。个人设置页(web GUI)需要一个跨 surface 的用户层,带 schema 校验、写路径与热传导——同类产品(Codex、Claude Code、Kimi、OpenCode、Pi)也全部收敛于"用户偏好与扩展组合分离"。Loader 的 reactive 配置更新承载不了这件事:`fiber.update` 原地替换 entry config,构造期读过配置的插件毫无感知,也没有任何回调通知它。
+
+## 决策
+
+**两个面,一条判定。**`cordis.yml`(+ Include patches)仍是组合面:有哪些插件、接线、部署配置,归 orchestrator 所有并随产品升级。settings namespace 只承载用户可编辑子集;判定是"个人配置页应该能改它吗?"值可同时存在于两个面而不歧义,因为分层就是契约:schema 默认值,然后注册方的组合 `base`(其 entry 配置子集),最后用户文档分节。
+
+**镜像 `session-persistence/` 的三包 seam。**`dsh-settings` 拥有抽象 `Settings` 服务:namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测,以及 `settings/updated` 提交事件。provider 只实现 `writable`/`load()`/`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档——因此热更新语义对所有 provider 一致,网络配置中心后端(nacos 类,可能只读)只是一个平级包的距离。`dsh-settings-local` 是文件 provider:`resolveSpec` 显式默认到 `<DSH_HOME>/settings.yaml` 的 YAML/JSON、chokidar 监听、`0600` tmp+rename 原子写、只修补目标 namespace 键的保注释 YAML 写回、按内容相等抑制自写。
+
+**注册是调用方 fiber 上的 effect。**`register()` 经服务代理调用,`this.ctx` 即注册方 context,注册挂在 `ctx.effect` 上:dispose 注册方即移除 namespace 及其观察者(HMR disposal 测试证明),而用户的分节继续留在存储中等待下一任 owner。
+
+**静止时响亮报错,运行中保留最后可用值。**启动期与注册期校验直接抛错(非法存量分节使注册插件加载失败;存在但不可解析的文档使 provider 加载失败)。运行中坏的外部编辑只告警并按 namespace 保留最后可用状态——热重载绝不拖垮进程。该不对称镜像 `Include.refresh()` 与 Kimi 的安全运行时重载。
+
+**消费者天然可选。**消费者在 `ctx.inject(['settings'], …)` 内注册;不挂 provider 时仍只按 entry 配置解析,因此所有既有组合、demo、snapshot 原样工作,迁移按插件渐进。
+
+## Alternatives considered
+
+- **以 Include 写回为用户层**(cordis-webui 式的按插件配置页写 loader entry 文件):写回目标是按组合的文件,会把用户偏好绑死在某个 `cordis.yml` 上;用户层必须在模板升级中存活,并以同一文档服务 TUI 与 web。
+- **以 Loader reactive `fiber.update` 为传导通道**:构造期读取毫无感知;seam 的显式 `watch()` 把热更新变成消费者契约而非框架魔法。
+- **领域化的 settings 服务**(按产品域的 getter):设计评审中的耦合反对成立;服务只做存储、校验、发布——领域含义留给拥有 schema 的注册方。
+- **现在就做多层优先级**(Codex/Claude Code 式 system/managed/project 层级):延后到真实第二层出现;resolve 步骤是分层未来唯一的扩展点。
+- **现在就上跨进程锁**(Pi 的 proper-lockfile):原子替换加 watcher 收敛(后写胜出)是已记录的行为,真实冲突出现再说。
+
+## 后果
+
+按依赖顺序延后:web `settings.raw`/`settings.describe`/`settings.update` RPC 面(暴露前必须对 `role('secret')` 字段脱敏);首批消费者迁移(`ui-theme`、语言、api-gateway 默认路由)并退役 `PROFILE_MAPPINGS` 与 profile json;面向密钥的 `${env:VAR}` 值间接引用;provider 侧分层。keyless snapshot 义务随第一个模型或产品用户可见的消费者落地,而非本基础设施步骤。

+ 1 - 0
AGENTS.md

@@ -30,6 +30,7 @@ packages/    @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
   cordis/      self-referential toolset: the agent inspects/mounts plugins in its own runtime
   hooks/       Claude Code/Codex hook bridges + shared wire-protocol library
   session-persistence/  persistence seam + JSONL/SQLite backends
+  settings/    user-settings seam + file-backed provider
   acp/         automation-only Agent Client Protocol server
   ui/          TUI/JSON-RPC bridges; boot, approval, interaction plugins
   examples/    demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load

+ 6 - 0
docs/capability-seams.md

@@ -35,6 +35,9 @@ flowchart LR
   pkg_tool_bash["tool-bash"]
   pkg_hooks_claude["hooks-claude"]
   pkg_hooks_codex["hooks-codex"]
+  pkg_settings["settings"]
+  svc_settings["ctx.settings<br/>User-settings seam"]
+  pkg_settings_local["settings-local"]
   pkg_session_telemetry["session-telemetry"]
   svc_telemetry["ctx.telemetry<br/>Session telemetry seam"]
   pkg_session_telemetry_otel["session-telemetry-otel"]
@@ -188,6 +191,8 @@ flowchart LR
   pkg_session_title --> svc_sessionTitle
   pkg_session_title_all_messages_llm --> svc_sessionTitle
   pkg_session_title_first_message_llm --> svc_sessionTitle
+  pkg_settings --> svc_settings
+  pkg_settings_local --> svc_settings
   pkg_skill --> svc_skills
   pkg_skill_local --> svc_skills
   pkg_spill --> svc_spillStore
@@ -316,6 +321,7 @@ flowchart LR
 | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
 | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
 | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
+| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. |
 | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |

+ 19 - 0
docs/config-catalog.md

@@ -1177,6 +1177,24 @@ Depends on: [`SessionTitleLlmConfig`](../packages/session-title/session-title-ll
 
 Source: [`packages/session-title/session-title-first-message-llm/src/index.ts:15`](../packages/session-title/session-title-first-message-llm/src/index.ts)
 
+## `@deepseek-ai/dsh-settings-local`
+
+```ts config-catalog
+/** Plugin config: file location and hot-reload behavior. */
+export interface Config {
+  /** Settings document path; defaults to `settings.yaml` under the harness home. */
+  path?: string
+  /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
+  dshHome?: string
+  /** Watch the document and hot-publish external edits; defaults to true. */
+  watch?: boolean
+  /** Watcher write-settle window in milliseconds; defaults to 100. */
+  debounceMs?: number
+}
+```
+
+Source: [`packages/settings/settings-local/src/index.ts:18`](../packages/settings/settings-local/src/index.ts)
+
 ## `@deepseek-ai/dsh-skill`
 
 ```ts config-catalog
@@ -2192,6 +2210,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
 - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
 - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
 - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
+- `@deepseek-ai/dsh-settings` — abstract `Settings` ([`packages/settings/settings/src/index.ts`](../packages/settings/settings/src/index.ts))
 - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
 - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts))
 - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))

+ 22 - 0
docs/cordis-catalog/events.md

@@ -640,6 +640,28 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru
 
 Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts)
 
+## `settings/*`
+
+### `settings/updated` — emit
+
+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.
+
+```ts cordis-catalog
+/**
+ * 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.
+ * @param ns - the namespace whose resolved value changed.
+ * @param next - the new resolved value.
+ * @param prev - the previous resolved value.
+ * @param source - whether the change entered through `update()` or the provider.
+ * @mode emit
+ */
+'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
+```
+
+Source: [`packages/settings/settings/src/index.ts:90`](../../packages/settings/settings/src/index.ts)
+
 ## `slash/*`
 
 ### `slash/input-begin-command` — bail

+ 42 - 0
docs/cordis-catalog/services.md

@@ -1388,6 +1388,48 @@ Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](.
 
 Source: [`packages/session-title/session-title/src/index.ts:232`](../../packages/session-title/session-title/src/index.ts)
 
+## `ctx.settings` — `Settings` (abstract seam)
+
+Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event.
+
+```ts cordis-catalog
+/**
+ * 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.
+ * @param ns - unique namespace; duplicate registration 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.
+ */
+register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>
+
+/**
+ * Describe every registered namespace for configuration surfaces.
+ * @returns one descriptor per registered namespace, in registration order.
+ */
+describe(): SettingsDescriptor[]
+
+/**
+ * Read one registered namespace's resolved value.
+ * @param ns - the namespace to read.
+ * @returns the resolved value, or `undefined` while unregistered.
+ */
+get(ns: SettingsNamespace): 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.
+ * @param ns - the registered namespace to update.
+ * @param patch - plain-object patch over the user section.
+ */
+async update(ns: SettingsNamespace, patch: object): Promise<void>
+```
+
+Source: [`packages/settings/settings/src/index.ts:140`](../../packages/settings/settings/src/index.ts)
+
 ## `ctx.skills` — `SkillService`
 
 Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand.

+ 1 - 0
docs/event-producer-consumer.md

@@ -35,6 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
+| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:90`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`emit`) | [`settings`](../packages/settings/settings) |
 | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
 | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
 | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |

+ 2 - 2
packages/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/README.md
-README.md: d16e395a42e491461c0862227205931894c27e39
-README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f
+README.md: 7b415bdbbd3e442c952da49b6f9f8823a636f643
+README.zh.md: d3347ecd6db68d99bc00e340c632da51cf825c63

+ 1 - 0
packages/README.md

@@ -37,6 +37,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
 | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
 | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
 | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
+| [`settings/`](settings/README.md) | User-settings capability family: the seam + file-backed provider | Product — stable surface |
 | [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
 | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
 | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |

+ 1 - 0
packages/README.zh.md

@@ -37,6 +37,7 @@
 | [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 |
 | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
 | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 |
+| [`settings/`](settings/README.md) | 用户设置能力族:seam + 文件 provider | 产品:稳定表面 |
 | [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
 | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
 | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |

+ 49 - 0
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -666,6 +666,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'settings',
+    summary: 'Abstract settings service.',
+    methods: [
+      {
+        signature: 'register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>',
+        jsDoc: '/**\n * Register a namespace schema and receive its owner scope. The registration\n * is an effect on the calling plugin\'s fiber: disposing that fiber removes\n * the namespace and its observers. An invalid stored section fails the\n * registration itself — the earliest point where the schema can judge it.\n * @param ns - unique namespace; duplicate registration fails loud.\n * @param schema - schemastery schema resolving this namespace\'s value.\n * @param options - composition `base` layer and effect timing.\n * @returns the owner scope for reads, observation, and updates.\n */',
+      },
+      {
+        signature: 'describe(): SettingsDescriptor[]',
+        jsDoc: '/**\n * Describe every registered namespace for configuration surfaces.\n * @returns one descriptor per registered namespace, in registration order.\n */',
+      },
+      {
+        signature: 'get(ns: SettingsNamespace): unknown',
+        jsDoc: '/**\n * Read one registered namespace\'s resolved value.\n * @param ns - the namespace to read.\n * @returns the resolved value, or `undefined` while unregistered.\n */',
+      },
+      {
+        signature: 'async update(ns: SettingsNamespace, patch: object): Promise<void>',
+        jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */',
+      },
+    ],
+  },
   {
     key: 'skills',
     summary: 'Registry of skill providers.',
@@ -1188,6 +1210,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
     jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
     summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
   },
+  {
+    name: 'settings/updated',
+    mode: 'emit',
+    signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void',
+    jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */',
+    summary: 'Committed change to one registered namespace\'s resolved value.',
+  },
   {
     name: 'slash/input-begin-command',
     mode: 'bail',
@@ -2181,6 +2210,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionTitleUserMessage',
     declaration: 'export interface SessionTitleUserMessage {\n    readonly seq: number;\n    readonly text: string;\n}',
   },
+  {
+    name: 'SettingsApplies',
+    declaration: 'export type SettingsApplies = \'live\' | \'restart\';',
+  },
+  {
+    name: 'SettingsDescriptor',
+    declaration: 'export interface SettingsDescriptor {\n    ns: SettingsNamespace;\n    schema: unknown;\n    value: unknown;\n    applies: SettingsApplies;\n}',
+  },
+  {
+    name: 'SettingsNamespace',
+    declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;',
+  },
+  {
+    name: 'SettingsRegisterOptions',
+    declaration: 'export interface SettingsRegisterOptions<T> {\n    base?: Partial<T>;\n    applies?: SettingsApplies;\n}',
+  },
+  {
+    name: 'SettingsScope',
+    declaration: 'export interface SettingsScope<T> {\n    get(): T;\n    watch(callback: (next: T, prev: T) => void): () => void;\n    update(patch: object): Promise<void>;\n}',
+  },
   {
     name: 'SkillCandidate',
     declaration: 'export interface SkillCandidate extends SkillSummary {\n    readonly rank: number;\n    readonly locator: unknown;\n    readonly path?: string;\n    readonly metadata?: Readonly<Record<string, unknown>>;\n}',

+ 6 - 0
packages/settings/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/settings/README.md
+README.md: 7a91355dd01805938944f0abce77765021288e6d
+README.zh.md: 2df40b67eb8ce6cfc693ed6bf3574815c0219ec0

+ 12 - 0
packages/settings/README.md

@@ -0,0 +1,12 @@
+# settings/ — user-settings capability family
+
+English | [中文](README.zh.md)
+
+The user-settings seam and its providers. The interface package owns the abstract `Settings` service — namespace registration, layered resolution, and change commits; providers implement raw-document storage and push external edits through the seam. All **product** packages.
+
+| Package | Role | ctx key |
+|---|---|---|
+| `settings/` | Settings seam: namespace registry, layered resolution, commit events | `ctx.settings` |
+| `settings-local/` | File-backed provider (`settings.yaml`/`.json`) with hot reload and comment-preserving write-back | (registers `ctx.settings`) |
+
+The interface lives at `settings/settings/`; providers are flat siblings. A network configuration-center provider (for example a nacos-style backend) joins here and registers on `ctx.settings`. Composition config stays in `cordis.yml`: a settings namespace carries only the user-editable subset, resolved as schema defaults, then the registrant's composition `base`, then the user document.

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

@@ -0,0 +1,12 @@
+# settings/ — 用户设置能力族
+
+[English](README.md) | 中文
+
+用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交;provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。
+
+| 包 | 角色 | ctx key |
+|---|---|---|
+| `settings/` | 设置 seam:namespace 注册表、分层解析、提交事件 | `ctx.settings` |
+| `settings-local/` | 文件 provider(`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings`) |
+
+接口位于 `settings/settings/`;provider 平级并列。网络配置中心 provider(例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`:settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。

+ 6 - 0
packages/settings/settings-local/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
+README.md: 90428f98055d8e49fa1ec54e571453db2e0a5054
+README.zh.md: 3532d6cee99cb46f54e23889ef1bb54b2548ecfa

+ 36 - 0
packages/settings/settings-local/README.md

@@ -0,0 +1,36 @@
+# @deepseek-ai/dsh-settings-local
+
+English | [中文](README.zh.md)
+
+File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` writes back atomically while preserving the user's YAML comments and any section owned by a plugin that is not currently loaded.
+
+## Config
+
+| Field | Meaning | Default |
+|---|---|---|
+| `path` | Settings document path; extension picks the format (`.yaml`/`.yml`/`.json`) | `settings.yaml` under the harness home |
+| `dshHome` | Harness home used when `path` is omitted | `$DSH_HOME` or `~/.dsh` |
+| `watch` | Watch the document and hot-publish external edits | `true` |
+| `debounceMs` | Watcher write-settle window in milliseconds | `100` |
+
+Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension fails at load.
+
+## Behavior
+
+- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
+- **Write-back is atomic and owner-only.** `persist` writes `<path>.tmp` with mode `0600` and renames over the target. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes.
+- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
+
+## Model Experience
+
+Indirectly, through consumers of `ctx.settings`: this provider only stores and publishes namespace sections, and each consumer's own surface documents any model effect.
+
+#### KV Cache effect
+
+No direct invalidation; the consuming plugin owns any request-prefix changes.
+
+## Known Limitations and Deferred Work
+
+- **No cross-process write lock** — concurrent writers (for example TUI and web on one home) converge by atomic replace plus watcher reload, last write wins; a lockfile is deferred until real contention shows up.
+- **Comment preservation is YAML-only** — JSON documents re-serialize without comments (JSON has none) and lose hand formatting.
+- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature.

+ 36 - 0
packages/settings/settings-local/README.zh.md

@@ -0,0 +1,36 @@
+# @deepseek-ai/dsh-settings-local
+
+[English](README.md) | 中文
+
+文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 原子写回,并保留用户的 YAML 注释以及当前未加载插件所拥有的分节。
+
+## 配置
+
+| 字段 | 含义 | 默认 |
+|---|---|---|
+| `path` | 设置文档路径;扩展名决定格式(`.yaml`/`.yml`/`.json`) | harness home 下的 `settings.yaml` |
+| `dshHome` | `path` 省略时使用的 harness home | `$DSH_HOME` 或 `~/.dsh` |
+| `watch` | 监听文档并热发布外部编辑 | `true` |
+| `debounceMs` | watcher 写入稳定窗口(毫秒) | `100` |
+
+默认值解析是一步显式的 `resolveSpec(config)`;不支持的扩展名在加载时报错。
+
+## 行为
+
+- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
+- **写回原子且仅属主可读。** `persist` 以 `0600` 权限写 `<path>.tmp` 后 rename 覆盖目标。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。
+- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
+
+## Model Experience
+
+间接生效:本 provider 只存储并发布 namespace 分节,模型效果经由 `ctx.settings` 的消费插件产生,由各消费者自己的文档描述。
+
+#### KV Cache effect
+
+无直接失效;请求前缀的变更由消费插件拥有。
+
+## Known Limitations and Deferred Work
+
+- **无跨进程写锁** — 并发写入者(例如同一 home 上的 TUI 与 web)靠原子替换加 watcher 重载收敛,后写胜出;lockfile 等真实冲突出现再做。
+- **注释保留仅限 YAML** — JSON 文档重新序列化,无注释(JSON 本身没有)且丢失手工排版。
+- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。

+ 46 - 0
packages/settings/settings-local/package.json

@@ -0,0 +1,46 @@
+{
+  "name": "@deepseek-ai/dsh-settings-local",
+  "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-paths": "^0.0.1",
+    "@deepseek-ai/dsh-settings": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "dependencies": {
+    "chokidar": "^4.0.3",
+    "schemastery": "^3.18.0",
+    "yaml": "^2.9.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-paths": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 226 - 0
packages/settings/settings-local/src/index.ts

@@ -0,0 +1,226 @@
+/**
+ * File-backed settings provider. One YAML or JSON document under the user's
+ * harness home carries every namespace section; external edits hot-publish
+ * through the seam and `update()` writes back preserving the user's comments.
+ * @module @deepseek-ai/dsh-settings-local
+ */
+
+import { Context, Service } from 'cordis'
+import z from 'schemastery'
+import { watch as chokidarWatch } from 'chokidar'
+import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
+import { dirname, extname, join, resolve } from 'node:path'
+import { Document, parseDocument } from 'yaml'
+import { resolveDshHome } from '@deepseek-ai/dsh-paths'
+import { Settings, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
+
+/** Plugin config: file location and hot-reload behavior. */
+export interface Config {
+  /** Settings document path; defaults to `settings.yaml` under the harness home. */
+  path?: string
+  /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
+  dshHome?: string
+  /** Watch the document and hot-publish external edits; defaults to true. */
+  watch?: boolean
+  /** Watcher write-settle window in milliseconds; defaults to 100. */
+  debounceMs?: number
+}
+
+/** Document format derived from the configured file extension. */
+type SettingsFormat = 'yaml' | 'json'
+
+const FORMATS: Record<string, SettingsFormat> = {
+  '.yaml': 'yaml',
+  '.yml': 'yaml',
+  '.json': 'json',
+}
+
+/** Fully resolved provider parameters; defaulting happens here, never inline. */
+interface ResolvedSpec {
+  filename: string
+  format: SettingsFormat
+  watch: boolean
+  debounceMs: number
+}
+
+/**
+ * Resolve the runtime spec from plugin config: an explicit `path` wins,
+ * otherwise the document lives at `<harness home>/settings.yaml`.
+ * @param config - raw plugin config.
+ * @returns the resolved file location, format, and watch behavior.
+ */
+export function resolveSpec(config: Config): ResolvedSpec {
+  const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml'))
+  const format = FORMATS[extname(filename)]
+  if (format === undefined) {
+    throw new Error(`settings-local: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`)
+  }
+  return {
+    filename,
+    format,
+    watch: config.watch ?? true,
+    debounceMs: config.debounceMs ?? 100,
+  }
+}
+
+/** 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'
+}
+
+/** File-backed settings provider (`settings.yaml`/`.json`). */
+export class SettingsLocal extends Settings {
+  static Config: z<Config> = z.object({
+    path: z.string(),
+    dshHome: z.string(),
+    watch: z.boolean().default(true),
+    debounceMs: z.number().min(0).default(100),
+  })
+
+  private readonly spec: ResolvedSpec
+  /**
+   * Raw text of the last successfully parsed or persisted document;
+   * `undefined` while the file is absent. Watcher events whose content equals
+   * this cache are no-ops, which is also the self-write suppression.
+   */
+  private text: string | undefined
+  /** Serializes watcher-triggered reloads so reads never interleave. */
+  private refreshTask: Promise<void> = Promise.resolve()
+
+  constructor(ctx: Context, public config: Config) {
+    super(ctx)
+    // Programmatic construction may bypass Schemastery normalization; resolve
+    // the same defaults in one explicit step either way.
+    this.spec = resolveSpec(config)
+  }
+
+  /** The local document is always writable through {@link Settings.update}. */
+  get writable(): boolean {
+    return true
+  }
+
+  protected async load(): Promise<Record<string, unknown>> {
+    let text: string
+    try {
+      text = await readFile(this.spec.filename, 'utf8')
+    } catch (error) {
+      if (!isENOENT(error)) throw error
+      this.text = undefined
+      return {}
+    }
+    const doc = this.parse(text)
+    this.text = text
+    return doc
+  }
+
+  protected async persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
+    const output = this.spec.format === 'yaml'
+      ? this.renderYaml(ns, section)
+      : this.renderJson(ns, section)
+    await mkdir(dirname(this.spec.filename), { recursive: true })
+    const temp = `${this.spec.filename}.tmp`
+    // Owner-only permissions apply to the temp file and survive the rename, so
+    // a document that may carry personal values is never world-readable.
+    await writeFile(temp, output, { mode: 0o600 })
+    await rename(temp, this.spec.filename)
+    this.text = output
+  }
+
+  async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
+    // A parse failure here is a boot failure: an existing-but-invalid document
+    // must fail loud, never be silently ignored or overwritten.
+    this.publish(await this.load())
+    if (!this.spec.watch) return
+    const watcher = chokidarWatch(this.spec.filename, {
+      ignoreInitial: true,
+      awaitWriteFinish: {
+        stabilityThreshold: this.spec.debounceMs,
+        pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
+      },
+    })
+    watcher.on('all', () => {
+      this.refreshTask = this.refreshTask.then(() => this.refresh())
+    })
+    watcher.on('error', (error) => {
+      this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
+      this.ctx.logger.warn(error)
+    })
+    yield () => watcher.close()
+  }
+
+  /** Parse one document text into raw sections, failing on a non-map root. */
+  private parse(text: string): Record<string, unknown> {
+    let root: unknown
+    if (this.spec.format === 'yaml') {
+      const document = parseDocument(text, { prettyErrors: true })
+      if (document.errors.length > 0) {
+        throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${
+          document.errors.map(error => error.message).join('; ')}`)
+      }
+      root = document.toJS() ?? {}
+    } else {
+      root = text.trim().length === 0 ? {} : JSON.parse(text)
+    }
+    if (typeof root !== 'object' || root === null || Array.isArray(root)) {
+      throw new TypeError(`settings-local: ${this.spec.filename} must be a map of namespace sections`)
+    }
+    return root as Record<string, unknown>
+  }
+
+  /**
+   * Re-read the document after a watcher event. Unchanged content (including
+   * this provider's own writes) is a no-op; an unreadable or unparsable
+   * document keeps the last good sections and warns — a live hot-reload must
+   * never take the process down.
+   */
+  private async refresh(): Promise<void> {
+    let text: string
+    try {
+      text = await readFile(this.spec.filename, 'utf8')
+    } catch (error) {
+      if (!isENOENT(error)) {
+        this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename)
+        this.ctx.logger.warn(error)
+        return
+      }
+      if (this.text === undefined) return
+      this.text = undefined
+      this.publish({})
+      return
+    }
+    if (text === this.text) return
+    let doc: Record<string, unknown>
+    try {
+      doc = this.parse(text)
+    } catch (error) {
+      this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename)
+      this.ctx.logger.warn(error)
+      return
+    }
+    this.text = text
+    this.publish(doc)
+  }
+
+  /** Render the next YAML text by patching one namespace in the comment-preserving document. */
+  private renderYaml(ns: SettingsNamespace, section: Record<string, unknown>): string {
+    if (this.text === undefined) {
+      return new Document({ [ns]: section }).toString()
+    }
+    // this.text only ever caches content that parsed successfully, so this
+    // re-parse (for the mutable comment-preserving tree) cannot fail.
+    const document = parseDocument(this.text)
+    document.set(ns, section)
+    return document.toString()
+  }
+
+  /** Render the next JSON text by replacing one namespace key. */
+  private renderJson(ns: SettingsNamespace, section: Record<string, unknown>): string {
+    const root = this.text === undefined
+      ? {}
+      : this.parse(this.text)
+    root[ns] = section
+    return `${JSON.stringify(root, null, 2)}\n`
+  }
+}
+
+export default SettingsLocal

+ 31 - 0
packages/settings/settings-local/src/invariant.ts

@@ -0,0 +1,31 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-settings-local`.
+ * @module @deepseek-ai/dsh-settings-local/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local'
+
+/** Cordis companion plugin name. */
+export const name = 'settings-local-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: this provider's contracts are file round-trip,
+ * watcher timing, and atomic-write behavior — IO effects proven by package
+ * tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 112 - 0
packages/settings/settings-local/tests/loader-composition.spec.ts

@@ -0,0 +1,112 @@
+/**
+ * Real-composition guard: the provider and a consumer plugin boot from a
+ * test-only cordis.yml through the actual Loader + Include path, and an
+ * external edit of settings.yaml hot-publishes into the consumer's scope.
+ */
+
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import Include from '@cordisjs/plugin-include'
+import z from 'schemastery'
+import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
+import SettingsLocal from '../src/index.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),
+})
+
+let root: string | undefined
+let context: Context | undefined
+
+afterEach(async () => {
+  await context?.fiber.dispose()
+  context = undefined
+  if (root !== undefined) await rm(root, { recursive: true, force: true })
+  root = undefined
+})
+
+interface ConsumerState {
+  scope: SettingsScope<ThemeConfig> | undefined
+  seen: ThemeConfig[]
+}
+
+async function loadComposition(): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
+  root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
+  const settingsPath = join(root, 'settings.yaml')
+  await writeFile(settingsPath, 'ui-theme:\n  theme: light\n')
+
+  const state: ConsumerState = { scope: undefined, seen: [] }
+  const consumer = {
+    name: 'settings-consumer',
+    inject: ['settings'],
+    apply: (ctx: Context) => {
+      const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
+        base: { fontSize: 16 },
+      })
+      state.scope = scope
+      scope.watch(next => state.seen.push(next))
+    },
+  }
+
+  const configPath = join(root, 'cordis.yml')
+  await writeFile(configPath, [
+    '- id: settings',
+    "  name: '@deepseek-ai/dsh-settings-local'",
+    '  config:',
+    `    path: ${JSON.stringify(settingsPath)}`,
+    '    debounceMs: 10',
+    '- id: consumer',
+    '  name: test-settings-consumer',
+    '',
+  ].join('\n'))
+
+  const ctx = new Context()
+  context = ctx
+  ctx.baseUrl = pathToFileURL(root).href + '/'
+  await ctx.plugin(Loader)
+  ctx.loader.builtins.include = Include
+  const modules = new Map<string, unknown>([
+    ['@deepseek-ai/dsh-settings-local', SettingsLocal],
+    ['test-settings-consumer', consumer],
+  ])
+  ctx.loader.internal = {
+    version: 'v2',
+    async import(specifier: string) {
+      if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
+      return modules.get(specifier)
+    },
+  } as unknown as NonNullable<typeof ctx.loader.internal>
+  await ctx.loader.create({
+    name: 'cordis:include',
+    config: { path: pathToFileURL(configPath).href },
+  })
+  await ctx.loader.await()
+  return { ctx, state, settingsPath }
+}
+
+describe('settings-local real composition', () => {
+  it('boots from cordis.yml and hot-publishes an external settings edit', async () => {
+    const { ctx, state, settingsPath } = await loadComposition()
+
+    // Composition resolution: user layer over the consumer's composition base.
+    expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
+    expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
+
+    await writeFile(settingsPath, 'ui-theme:\n  theme: dark\n  fontSize: 20\n')
+    await vi.waitFor(() => {
+      expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 })
+    }, { timeout: 5000 })
+    expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
+  })
+})

+ 254 - 0
packages/settings/settings-local/tests/local.spec.ts

@@ -0,0 +1,254 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import z from 'schemastery'
+import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
+import { SettingsLocal, resolveSpec } from '../src/index.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 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-local-'))
+  cleanups.push(() => rm(dir, { recursive: true, force: true }))
+  return dir
+}
+
+async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
+  const ctx = new Context()
+  const fiber = ctx.plugin(SettingsLocal, config)
+  cleanups.push(async () => { await fiber.dispose() })
+  await fiber
+  return ctx
+}
+
+describe('resolveSpec', () => {
+  it('defaults watch and debounce when construction bypasses schema normalization', () => {
+    const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' })
+    expect(spec.watch).toBe(true)
+    expect(spec.debounceMs).toBe(100)
+  })
+})
+
+describe('boot and reads', () => {
+  it('resolves defaults over an absent file and reports writable', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
+      base: { fontSize: 16 },
+    })
+    expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
+    expect(ctx.settings.writable).toBe(true)
+  })
+
+  it('reads sections from an existing yaml document', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, 'ui-theme:\n  theme: light\n')
+    const ctx = await boot({ path, watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
+  })
+
+  it('reads sections from 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, watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
+  })
+
+  it('defaults the file location under the configured harness home', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ dshHome: dir, watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await scope.update({ theme: 'light' })
+    const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
+    expect(written).toContain('theme: light')
+  })
+
+  it('reads an empty yaml document as no sections', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, '')
+    const ctx = await boot({ path, watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
+  })
+
+  it('reads an empty json document as no sections', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.json')
+    await writeFile(path, '')
+    const ctx = await boot({ path, watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
+  })
+
+  it('fails loud at boot when the document exists but is unreadable', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, 'ui-theme:\n  theme: light\n')
+    await chmod(path, 0o000)
+    cleanups.push(() => chmod(path, 0o600))
+    await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i)
+  })
+
+  it('fails loud on an unsupported extension', async () => {
+    const dir = await tempDir()
+    await expect(boot({ path: join(dir, 'settings.toml'), watch: false }))
+      .rejects.toThrow(/not supported/)
+  })
+
+  it('fails loud at boot on unparsable yaml', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, 'ui-theme: [unclosed\n')
+    await expect(boot({ path, watch: false })).rejects.toThrow()
+  })
+
+  it('fails loud at boot when the root is not a map of sections', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, '- just\n- a list\n')
+    await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/)
+  })
+})
+
+describe('persist', () => {
+  it('writes the merged section, creating the file with owner-only permissions', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    const ctx = await boot({ path, watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await scope.update({ theme: 'light' })
+
+    const written = await readFile(path, 'utf8')
+    expect(written).toContain('theme: light')
+    expect((await stat(path)).mode & 0o777).toBe(0o600)
+    // Atomic replace leaves no temp artifact behind.
+    expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
+  })
+
+  it('preserves comments and unregistered sections across updates', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, [
+      '# personal settings',
+      'ui-theme:',
+      '  theme: light',
+      '# owned by a plugin that is not loaded right now',
+      'future-plugin:',
+      '  keep: me',
+      '',
+    ].join('\n'))
+    const ctx = await boot({ path, watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await scope.update({ fontSize: 18 })
+
+    const written = await readFile(path, 'utf8')
+    expect(written).toContain('# personal settings')
+    expect(written).toContain('# owned by a plugin that is not loaded right now')
+    expect(written).toContain('keep: me')
+    expect(written).toContain('fontSize: 18')
+    expect(written).toContain('theme: light')
+  })
+
+  it('creates a json document from scratch', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.json')
+    const ctx = await boot({ path, watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await scope.update({ theme: 'light' })
+    const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
+    expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
+  })
+
+  it('round-trips a json document', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.json')
+    await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2))
+    const ctx = await boot({ path, watch: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await scope.update({ theme: 'light' })
+    const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
+    expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } })
+  })
+})
+
+describe('watch', () => {
+  it('publishes an external edit to registered scopes', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, 'ui-theme:\n  theme: light\n')
+    const ctx = await boot({ path, debounceMs: 10 })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    expect(scope.get().theme).toBe('light')
+
+    await writeFile(path, 'ui-theme:\n  theme: dark\n  fontSize: 20\n')
+    await vi.waitFor(() => {
+      expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 })
+    }, { timeout: 5000 })
+  })
+
+  it('keeps the last good document over an invalid edit, then recovers', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, 'ui-theme:\n  theme: light\n')
+    const ctx = await boot({ path, debounceMs: 10 })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+
+    await writeFile(path, 'ui-theme: [unclosed\n')
+    // The bad edit must never take the live tree down or reset the value.
+    await new Promise(resolve => setTimeout(resolve, 300))
+    expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
+
+    await writeFile(path, 'ui-theme:\n  theme: dark\n')
+    await vi.waitFor(() => {
+      expect(scope.get().theme).toBe('dark')
+    }, { timeout: 5000 })
+  })
+
+  it('treats file removal as an empty document', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, 'ui-theme:\n  theme: light\n')
+    const ctx = await boot({ path, debounceMs: 10 })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+
+    await rm(path)
+    await vi.waitFor(() => {
+      expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
+    }, { timeout: 5000 })
+  })
+
+  it('does not republish its own persisted write', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    const ctx = await boot({ path, debounceMs: 10 })
+    const events: unknown[] = []
+    ctx.on('settings/updated', (ns, _next, _prev, source) => {
+      events.push({ ns, source })
+    })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await scope.update({ theme: 'light' })
+    await new Promise(resolve => setTimeout(resolve, 300))
+    expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }])
+  })
+})

+ 118 - 0
packages/settings/settings-local/tests/watcher.spec.ts

@@ -0,0 +1,118 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import z from 'schemastery'
+import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
+import { SettingsLocal } from '../src/index.ts'
+
+// chokidar is the nondeterministic OS boundary: faking it lets these tests
+// drive the event pipeline (error events, races with unreadable files)
+// deterministically. Real end-to-end watching stays covered by local.spec.ts.
+vi.mock('chokidar', async () => {
+  const { EventEmitter } = await import('node:events')
+  class FakeWatcher extends EventEmitter {
+    close = vi.fn(() => Promise.resolve())
+  }
+  const instances: Array<{ path: string; options: unknown; watcher: InstanceType<typeof FakeWatcher> }> = []
+  return {
+    watch: vi.fn((path: string, options: unknown) => {
+      const watcher = new FakeWatcher()
+      instances.push({ path, options, watcher })
+      return watcher
+    }),
+    __instances: instances,
+  }
+})
+
+interface FakeChokidar {
+  __instances: Array<{
+    path: string
+    options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } }
+    watcher: import('node:events').EventEmitter
+  }>
+}
+
+async function fakeInstances(): Promise<FakeChokidar['__instances']> {
+  const chokidar = await import('chokidar') as unknown as FakeChokidar
+  return chokidar.__instances
+}
+
+const ThemeSchema: z<{ theme: string }> = z.object({
+  theme: z.string().default('dark'),
+})
+
+const cleanups: Array<() => Promise<void>> = []
+
+afterEach(async () => {
+  while (cleanups.length > 0) await cleanups.pop()!()
+  ;(await fakeInstances()).length = 0
+})
+
+async function tempDir(): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-'))
+  cleanups.push(() => rm(dir, { recursive: true, force: true }))
+  return dir
+}
+
+async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
+  const ctx = new Context()
+  const fiber = ctx.plugin(SettingsLocal, config)
+  cleanups.push(async () => { await fiber.dispose() })
+  await fiber
+  return ctx
+}
+
+describe('watcher pipeline', () => {
+  it('clamps the write-settle poll interval for a zero debounce', async () => {
+    const dir = await tempDir()
+    await boot({ path: join(dir, 'settings.yaml'), debounceMs: 0 })
+    const [instance] = await fakeInstances()
+    expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
+  })
+
+  it('survives a watcher error and keeps publishing later edits', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    const ctx = await boot({ path, debounceMs: 5 })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const [instance] = await fakeInstances()
+
+    instance!.watcher.emit('error', new Error('watch backend failure'))
+    expect(scope.get()).toEqual({ theme: 'dark' })
+
+    await writeFile(path, 'ui-theme:\n  theme: light\n')
+    instance!.watcher.emit('all', 'change', path)
+    await vi.waitFor(() => {
+      expect(scope.get()).toEqual({ theme: 'light' })
+    })
+  })
+
+  it('keeps the last good document when the file turns unreadable at runtime', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    await writeFile(path, 'ui-theme:\n  theme: light\n')
+    const ctx = await boot({ path, debounceMs: 5 })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+
+    await chmod(path, 0o000)
+    cleanups.push(() => chmod(path, 0o600))
+    const [instance] = await fakeInstances()
+    instance!.watcher.emit('all', 'change', path)
+    // The warn-and-keep path is asynchronous; give the serialized refresh a turn.
+    await new Promise(resolve => setTimeout(resolve, 50))
+    expect(scope.get()).toEqual({ theme: 'light' })
+  })
+
+  it('treats an event for a still-absent file as a no-op', async () => {
+    const dir = await tempDir()
+    const path = join(dir, 'settings.yaml')
+    const ctx = await boot({ path, debounceMs: 5 })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const [instance] = await fakeInstances()
+    instance!.watcher.emit('all', 'add', path)
+    await new Promise(resolve => setTimeout(resolve, 50))
+    expect(scope.get()).toEqual({ theme: 'dark' })
+  })
+})

+ 30 - 0
packages/settings/settings-local/tsconfig.json

@@ -0,0 +1,30 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../util/paths"
+    },
+    {
+      "path": "../settings"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 6 - 0
packages/settings/settings/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/settings/settings/README.md
+README.md: e57db00c095a87f9f0b51397e030dec364f48e62
+README.zh.md: 8733823106f0ef3880cbe2c567de2edcdeff2c86

+ 35 - 0
packages/settings/settings/README.md

@@ -0,0 +1,35 @@
+# @deepseek-ai/dsh-settings
+
+English | [中文](README.zh.md)
+
+Abstract user-settings seam (`ctx.settings`). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition `base` (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings.
+
+## Service API
+
+- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
+- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces.
+- `get(ns)` — resolved value, `undefined` while unregistered.
+- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every update.
+- Resolved values are deep-frozen snapshots; watchers receive `(next, prev)` after each commit, and watcher failures are contained.
+
+## Provider contract
+
+Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
+
+## Events
+
+`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value.
+
+## Model Experience
+
+Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect.
+
+#### KV Cache effect
+
+No direct invalidation; a consumer that folds a settings value into the request prefix owns that change.
+
+## Known Limitations and Deferred Work
+
+- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet.
+- **Cross-process concurrency is provider-defined** — the seam serializes nothing across processes; concurrent writers converge by provider behavior (the local file provider is last-write-wins).
+- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure.

+ 35 - 0
packages/settings/settings/README.zh.md

@@ -0,0 +1,35 @@
+# @deepseek-ai/dsh-settings
+
+[English](README.md) | 中文
+
+抽象用户设置 seam(`ctx.settings`)。一个 provider 持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值:schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载 provider 时消费者行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。
+
+## 服务 API
+
+- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
+- `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。
+- `get(ns)` — 解析值;未注册时为 `undefined`。
+- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切更新。
+- 解析值是深冻结快照;每次提交后观察者收到 `(next, prev)`,观察者异常被隔离。
+
+## Provider 契约
+
+子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
+
+## 事件
+
+`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。
+
+## Model Experience
+
+间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。
+
+#### KV Cache effect
+
+无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。
+
+## Known Limitations and Deferred Work
+
+- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。
+- **跨进程并发由 provider 定义** — seam 不做跨进程串行化;并发写入者按 provider 行为收敛(本地文件 provider 为后写胜出)。
+- **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。

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

@@ -0,0 +1,41 @@
+{
+  "name": "@deepseek-ai/dsh-settings",
+  "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-brand": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7",
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "cordis": "^4.0.0-rc.7",
+    "schemastery": "^3.18.0"
+  }
+}

+ 304 - 0
packages/settings/settings/src/index.ts

@@ -0,0 +1,304 @@
+/**
+ * User-settings 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.
+ * @module @deepseek-ai/dsh-settings
+ */
+
+import { Context, Service } from 'cordis'
+import { deepEqual } from 'cosmokit'
+import type z from 'schemastery'
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+/** Nominal id of one registered settings namespace. */
+export type SettingsNamespace = Branded<'SettingsNamespace'>
+
+const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
+
+/**
+ * Brand a raw string as a {@link SettingsNamespace}.
+ * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
+ * @returns the branded namespace.
+ */
+export function settingsNamespace(value: string): SettingsNamespace {
+  if (!NAMESPACE_PATTERN.test(value)) {
+    throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
+  }
+  return value as SettingsNamespace
+}
+
+/** When a namespace's changes take effect for its owner. */
+export type SettingsApplies = 'live' | 'restart'
+
+/** Origin of one committed settings change. */
+export type SettingsUpdateSource = 'update' | 'provider'
+
+/** Registration options beyond the namespace schema. */
+export interface SettingsRegisterOptions<T> {
+  /** Composition-layer values resolved below the user layer (entry-config subset). */
+  base?: Partial<T>
+  /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
+  applies?: SettingsApplies
+}
+
+/** One registered namespace as surfaced to configuration UIs. */
+export interface SettingsDescriptor {
+  /** The registered namespace. */
+  ns: SettingsNamespace
+  /** Serialized schemastery schema (`schema.toJSON()`). */
+  schema: unknown
+  /** Current resolved value. */
+  value: unknown
+  /** Owner's declared effect timing. */
+  applies: SettingsApplies
+}
+
+/** Owner-facing handle for one registered namespace. */
+export interface SettingsScope<T> {
+  /** Current resolved value: schema defaults, then `base`, then the user layer. */
+  get(): T
+  /**
+   * Observe committed changes to this namespace's resolved value.
+   * @param callback - invoked after each commit with the next and previous values.
+   * @returns the disposer removing this observer.
+   */
+  watch(callback: (next: T, prev: T) => void): () => void
+  /**
+   * Merge a partial patch into this namespace's user layer and persist it.
+   * @param patch - plain-object patch over the user section.
+   */
+  update(patch: object): Promise<void>
+}
+
+declare module 'cordis' {
+  interface Context {
+    settings: Settings
+  }
+
+  interface Events {
+    /**
+     * 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.
+     * @param ns - the namespace whose resolved value changed.
+     * @param next - the new resolved value.
+     * @param prev - the previous resolved value.
+     * @param source - whether the change entered through `update()` or the provider.
+     * @mode emit
+     */
+    'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
+  }
+}
+
+/** Whether a value is a plain data object (not an array, null, or class instance). */
+function isPlainObject(value: unknown): value is Record<string, unknown> {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+  const proto: unknown = Object.getPrototypeOf(value)
+  return proto === Object.prototype || proto === null
+}
+
+/**
+ * Layer `over` onto `under`: plain objects merge recursively, every other
+ * value (arrays included) replaces the lower layer wholesale, and `undefined`
+ * entries in `over` are ignored so a sparse patch cannot erase lower keys.
+ */
+function mergeLayers(under: unknown, over: unknown): unknown {
+  if (over === undefined) return under
+  if (!isPlainObject(under) || !isPlainObject(over)) return over
+  const merged: Record<string, unknown> = { ...under }
+  for (const [key, value] of Object.entries(over)) {
+    if (value === undefined) continue
+    merged[key] = key in merged ? mergeLayers(merged[key], value) : value
+  }
+  return merged
+}
+
+/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
+function deepFreeze<T>(value: T): T {
+  if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value
+  for (const entry of Object.values(value)) deepFreeze(entry)
+  return Object.freeze(value)
+}
+
+/** One live namespace registration owned by a registrant fiber. */
+interface SettingsRegistration {
+  ns: SettingsNamespace
+  schema: z<unknown>
+  base: unknown
+  applies: SettingsApplies
+  resolved: unknown
+  watchers: Set<(next: never, prev: never) => void>
+}
+
+/**
+ * Abstract settings service. Providers implement raw-document storage
+ * (`load`/`persist`) and push external changes through {@link Settings.publish};
+ * the base class owns namespace registration, resolution, validation, change
+ * detection, and the `settings/updated` commit event.
+ */
+export abstract class Settings extends Service {
+  private readonly registrations = new Map<SettingsNamespace, SettingsRegistration>()
+  /** Latest published raw document; empty until the provider's first publish. */
+  private document: Record<string, unknown> = {}
+
+  constructor(ctx: Context) {
+    super(ctx, 'settings')
+  }
+
+  /** Whether {@link update} may persist through this provider. */
+  abstract readonly writable: boolean
+
+  /**
+   * Read the provider's current raw document (namespace to raw section).
+   * @returns the detached raw document.
+   */
+  protected abstract load(): Promise<Record<string, unknown>>
+
+  /**
+   * Durably store one namespace's merged user section.
+   * @param ns - the namespace being written.
+   * @param section - the complete merged user section to store.
+   */
+  protected abstract persist(ns: SettingsNamespace, section: Record<string, unknown>): 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
+   * registration itself — the earliest point where the schema can judge it.
+   * @param ns - unique namespace; duplicate registration 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.
+   */
+  register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T> {
+    if (this.registrations.has(ns)) {
+      throw new Error(`settings namespace "${ns}" is already registered`)
+    }
+    const registration: SettingsRegistration = {
+      ns,
+      schema: schema as z<unknown>,
+      base: options?.base,
+      applies: options?.applies ?? 'live',
+      resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))),
+      watchers: new Set(),
+    }
+    this.ctx.effect(() => {
+      this.registrations.set(ns, registration)
+      return () => this.registrations.delete(ns)
+    }, `settings.register(${JSON.stringify(String(ns))})`)
+    return {
+      get: () => registration.resolved as T,
+      watch: (callback) => {
+        registration.watchers.add(callback)
+        return () => registration.watchers.delete(callback)
+      },
+      update: patch => this.update(ns, patch),
+    }
+  }
+
+  /**
+   * Describe every registered namespace for configuration surfaces.
+   * @returns one descriptor per registered namespace, in registration order.
+   */
+  describe(): SettingsDescriptor[] {
+    return [...this.registrations.values()].map(registration => ({
+      ns: registration.ns,
+      schema: registration.schema.toJSON(),
+      value: registration.resolved,
+      applies: registration.applies,
+    }))
+  }
+
+  /**
+   * Read one registered namespace's resolved value.
+   * @param ns - the namespace to read.
+   * @returns the resolved value, or `undefined` while unregistered.
+   */
+  get(ns: SettingsNamespace): unknown {
+    return this.registrations.get(ns)?.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.
+   * @param ns - the registered namespace to update.
+   * @param patch - plain-object patch over the user section.
+   */
+  async update(ns: SettingsNamespace, patch: object): Promise<void> {
+    const registration = this.registrations.get(ns)
+    if (registration === undefined) {
+      throw new Error(`settings namespace "${ns}" is not registered`)
+    }
+    if (!this.writable) {
+      throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`)
+    }
+    if (!isPlainObject(patch)) {
+      throw new TypeError(`settings update for "${ns}" must be a plain object patch`)
+    }
+    const section = mergeLayers(this.section(ns) ?? {}, patch) as Record<string, unknown>
+    const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
+    await this.persist(ns, section)
+    this.document[ns] = section
+    this.commit(registration, next, 'update')
+  }
+
+  /**
+   * 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.
+   * @param doc - the detached raw document (unregistered sections preserved).
+   * @param source - change origin; defaults to `provider`.
+   */
+  protected publish(doc: Record<string, unknown>, source: SettingsUpdateSource = 'provider'): void {
+    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)))
+      } catch (error) {
+        this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns)
+        this.ctx.logger.warn(error)
+        continue
+      }
+      this.commit(registration, next, source)
+    }
+  }
+
+  /** Read one namespace's raw user section, rejecting non-object sections. */
+  private section(ns: SettingsNamespace): Record<string, unknown> | undefined {
+    const section = this.document[ns]
+    if (section === undefined) return undefined
+    if (!isPlainObject(section)) {
+      throw new TypeError(`settings section "${ns}" must be an object of keys`)
+    }
+    return section
+  }
+
+  /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
+  private resolve<T>(schema: z<T>, base: unknown, section: Record<string, unknown> | undefined): T {
+    // The merged candidate is untyped by construction; the schema call is the
+    // runtime validation that admits it into T.
+    return schema(mergeLayers(base, section) as never)
+  }
+
+  /** Commit a resolved value when changed: swap, notify watchers, emit the event. */
+  private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void {
+    const prev = registration.resolved
+    if (deepEqual(next, prev)) return
+    registration.resolved = next
+    for (const watcher of [...registration.watchers]) {
+      try {
+        watcher(next as never, prev as never)
+      } catch (error) {
+        this.ctx.logger.warn('settings: watcher for "%s" failed', registration.ns)
+        this.ctx.logger.warn(error)
+      }
+    }
+    this.ctx.emit('settings/updated', registration.ns, next, prev, source)
+  }
+}
+
+export default Settings

+ 41 - 0
packages/settings/settings/src/invariant.ts

@@ -0,0 +1,41 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-settings`.
+ * @module @deepseek-ai/dsh-settings/invariant
+ */
+
+import type { Context } from 'cordis'
+import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-settings'
+
+/** Cordis companion plugin name. */
+export const name = 'settings-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * Install the commit-event contract: `settings/updated` fires only for a
+ * currently registered namespace and only when the resolved value changed.
+ */
+const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
+  ctx.on('settings/updated', (ns, next, prev) => {
+    const settings = ctx.get('settings')
+    if (settings === undefined) {
+      fail(`settings/updated for "${ns}" emitted without a live settings service`)
+    }
+    if (settings.get(ns) === undefined) {
+      fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`)
+    }
+    if (JSON.stringify(next) === JSON.stringify(prev)) {
+      fail(`settings/updated for "${ns}" emitted without a resolved-value change`)
+    }
+  })
+}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

+ 41 - 0
packages/settings/settings/tests/invariant.spec.ts

@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import z from 'schemastery'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as SettingsInvariant from '../src/invariant.ts'
+import { settingsNamespace } from '../src/index.ts'
+import { MemorySettings } from './memory.ts'
+
+async function setup(withProvider: boolean): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(InvariantService)
+  await ctx.plugin(SettingsInvariant)
+  if (withProvider) await ctx.plugin(MemorySettings)
+  return ctx
+}
+
+describe('settings invariants', () => {
+  it('fails a settings/updated emission without a live settings service', async () => {
+    const ctx = await setup(false)
+    expect(() => {
+      ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
+    }).toThrow(/without a live settings service/)
+  })
+
+  it('fails a settings/updated emission for an unregistered namespace', async () => {
+    const ctx = await setup(true)
+    expect(() => {
+      ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
+    }).toThrow(/unregistered/)
+  })
+
+  it('fails a settings/updated emission without a resolved-value change', async () => {
+    const ctx = await setup(true)
+    ctx.settings.register(settingsNamespace('ui-theme'), z.object({
+      theme: z.string().default('dark'),
+    }))
+    expect(() => {
+      ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update')
+    }).toThrow(/without a resolved-value change/)
+  })
+})

+ 53 - 0
packages/settings/settings/tests/memory.ts

@@ -0,0 +1,53 @@
+/**
+ * In-memory settings provider fixture: the smallest real subclass of the seam,
+ * used by the base-class behavior suite in place of a file- or network-backed
+ * provider. Kept in `tests/` because production providers live in their own
+ * packages.
+ */
+
+import { Service } from 'cordis'
+import { Settings, type SettingsNamespace } from '../src/index.ts'
+
+/** In-memory provider exposing the protected seam hooks to tests. */
+export class MemorySettings extends Settings {
+  /** 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> }> = []
+  /** When false, update() must reject before reaching persist(). */
+  writableFlag: boolean
+
+  constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
+    doc?: Record<string, unknown>
+    writable?: boolean
+  }) {
+    super(ctx)
+    this.doc = structuredClone(options?.doc ?? {})
+    this.writableFlag = options?.writable ?? true
+  }
+
+  get writable(): boolean {
+    return this.writableFlag
+  }
+
+  protected load(): Promise<Record<string, unknown>> {
+    return Promise.resolve(structuredClone(this.doc))
+  }
+
+  protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
+    this.persisted.push({ ns, section: structuredClone(section) })
+    this.doc[ns] = structuredClone(section)
+    return Promise.resolve()
+  }
+
+  /** Simulate an external storage change reaching the provider. */
+  pushExternal(doc: Record<string, unknown>): void {
+    this.doc = structuredClone(doc)
+    this.publish(structuredClone(doc))
+  }
+
+  async* [Service.init](): AsyncGenerator<() => void, void, void> {
+    this.publish(await this.load())
+    yield () => { this.persisted.length = 0 }
+  }
+}

+ 307 - 0
packages/settings/settings/tests/settings.spec.ts

@@ -0,0 +1,307 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import z from 'schemastery'
+import { settingsNamespace, type SettingsScope, 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),
+})
+
+interface NestedConfig {
+  retry: { attempts: number; delayMs: number }
+  tags: string[]
+}
+
+const NestedSchema: z<NestedConfig> = z.object({
+  retry: z.object({
+    attempts: z.number().default(2),
+    delayMs: z.number().default(100),
+  }),
+  tags: z.array(z.string()).default(['default']),
+})
+
+async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
+  const ctx = new Context()
+  await ctx.plugin(MemorySettings, options)
+  const provider = ctx.get('settings') as MemorySettings
+  return { ctx, provider }
+}
+
+/** Record every settings/updated emission. */
+function recordUpdates(ctx: Context) {
+  const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = []
+  ctx.on('settings/updated', (ns, next, prev, source) => {
+    events.push({ ns, next, prev, source })
+  })
+  return events
+}
+
+describe('settingsNamespace', () => {
+  it('brands lowercase kebab-case names', () => {
+    expect(settingsNamespace('ui-theme')).toBe('ui-theme')
+  })
+
+  it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => {
+    expect(() => settingsNamespace(value)).toThrow(TypeError)
+  })
+})
+
+describe('registration', () => {
+  it('resolves schema defaults, then composition base, then the user layer', async () => {
+    const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
+      base: { fontSize: 16 },
+    })
+    // theme: user layer wins; fontSize: base wins over the schema default.
+    expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
+  })
+
+  it('rejects a duplicate namespace loud', async () => {
+    const { ctx } = await boot()
+    ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
+      .toThrow(/already registered/)
+  })
+
+  it('fails registration when the stored section is invalid for the schema', async () => {
+    const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
+    expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()
+  })
+
+  it('fails registration when the stored section is not an object', async () => {
+    const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } })
+    expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
+      .toThrow(/must be an object/)
+  })
+
+  it('describes registered namespaces with schema JSON, value, and applies', async () => {
+    const { ctx } = await boot()
+    ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' })
+    const descriptors = ctx.settings.describe()
+    expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([
+      ['ui-theme', 'live'],
+      ['workspace', 'restart'],
+    ])
+    expect(descriptors[0]!.value).toEqual({ theme: 'dark', fontSize: 14 })
+    // schemastery's canonical wire form: a { uid, refs } envelope whose root ref
+    // is the object schema — the shape schema-driven form UIs reconstruct from.
+    const serialized = descriptors[0]!.schema as { uid: number; refs: Record<string, { type: string }> }
+    expect(serialized.refs[String(serialized.uid)]?.type).toBe('object')
+  })
+
+  it('reads undefined for an unregistered namespace', async () => {
+    const { ctx } = await boot()
+    expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined()
+  })
+
+  it('hands out frozen resolved values', async () => {
+    const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } })
+    const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
+    const value = scope.get()
+    expect(Object.isFrozen(value)).toBe(true)
+    expect(Object.isFrozen(value.retry)).toBe(true)
+    expect(() => { (value.retry as { attempts: number }).attempts = 0 }).toThrow(TypeError)
+  })
+
+  it('removes the namespace and its observers when the registrant fiber disposes', async () => {
+    const { ctx, provider } = await boot()
+    const seen: unknown[] = []
+    let scope: SettingsScope<ThemeConfig> | undefined
+    const fiber = ctx.plugin({
+      inject: ['settings'],
+      apply: (child: Context) => {
+        scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+        scope.watch(next => seen.push(next))
+      },
+    })
+    await fiber
+    expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 })
+
+    await fiber.dispose()
+    expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined()
+    expect(ctx.settings.describe()).toEqual([])
+    provider.pushExternal({ 'ui-theme': { theme: 'light' } })
+    expect(seen).toEqual([])
+
+    // The namespace is free again, and re-registration resolves the user layer
+    // that kept living in storage while nobody owned the namespace.
+    const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    expect(again.get()).toEqual({ theme: 'light', fontSize: 14 })
+  })
+})
+
+describe('update', () => {
+  it('persists the merged user section without baking in the base layer', async () => {
+    const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
+      base: { fontSize: 16 },
+    })
+    await scope.update({ theme: 'dark' })
+    expect(provider.persisted).toEqual([
+      { ns: 'ui-theme', section: { theme: 'dark' } },
+    ])
+    expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
+  })
+
+  it('deep-merges nested objects and replaces arrays wholesale', async () => {
+    const { ctx, provider } = await boot({
+      doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } },
+    })
+    const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
+    await scope.update({ retry: { attempts: 7 }, tags: ['c'] })
+    expect(provider.persisted[0]!.section).toEqual({
+      retry: { attempts: 7, delayMs: 300 },
+      tags: ['c'],
+    })
+    expect(scope.get()).toEqual({ retry: { attempts: 7, delayMs: 300 }, tags: ['c'] })
+  })
+
+  it('commits, notifies watchers, and emits with source update', async () => {
+    const { ctx } = await boot()
+    const events = recordUpdates(ctx)
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const watcher = vi.fn()
+    scope.watch(watcher)
+    await scope.update({ theme: 'light' })
+    expect(watcher).toHaveBeenCalledWith(
+      { theme: 'light', fontSize: 14 },
+      { theme: 'dark', fontSize: 14 },
+    )
+    expect(events).toEqual([{
+      ns: 'ui-theme',
+      next: { theme: 'light', fontSize: 14 },
+      prev: { theme: 'dark', fontSize: 14 },
+      source: 'update',
+    }])
+  })
+
+  it('rejects an invalid patch before persisting anything', async () => {
+    const { ctx, provider } = await boot()
+    const events = recordUpdates(ctx)
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await expect(scope.update({ fontSize: 'big' })).rejects.toThrow()
+    expect(provider.persisted).toEqual([])
+    expect(events).toEqual([])
+    expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
+  })
+
+  it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => {
+    const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await scope.update({ theme: undefined, fontSize: 18 })
+    expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 })
+    expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 })
+  })
+
+  it('rejects a non-object patch', async () => {
+    const { ctx } = await boot()
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await expect(scope.update([1])).rejects.toThrow(TypeError)
+    await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError)
+  })
+
+  it('accepts a null-prototype patch object', async () => {
+    const { ctx } = await boot()
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number }
+    patch.fontSize = 18
+    await scope.update(patch)
+    expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
+  })
+
+  it('rejects an unregistered namespace', async () => {
+    const { ctx } = await boot()
+    await expect(ctx.settings.update(settingsNamespace('missing'), {}))
+      .rejects.toThrow(/not registered/)
+  })
+
+  it('rejects on a read-only provider before reaching persist', async () => {
+    const { ctx, provider } = await boot({ writable: false })
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/)
+    expect(provider.persisted).toEqual([])
+  })
+})
+
+describe('publish', () => {
+  it('notifies watchers of an external change with source provider', async () => {
+    const { ctx, provider } = await boot()
+    const events = recordUpdates(ctx)
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const watcher = vi.fn()
+    scope.watch(watcher)
+    provider.pushExternal({ 'ui-theme': { theme: 'light' } })
+    expect(watcher).toHaveBeenCalledWith(
+      { theme: 'light', fontSize: 14 },
+      { theme: 'dark', fontSize: 14 },
+    )
+    expect(events[0]!.source).toBe('provider')
+  })
+
+  it('stays silent when the resolved value is deep-equal', async () => {
+    const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
+    const events = recordUpdates(ctx)
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const watcher = vi.fn()
+    scope.watch(watcher)
+    provider.pushExternal({ 'ui-theme': { theme: 'light' } })
+    expect(watcher).not.toHaveBeenCalled()
+    expect(events).toEqual([])
+  })
+
+  it('keeps the last good value for an invalid section while other namespaces commit', async () => {
+    const { ctx, provider } = await boot()
+    const events = recordUpdates(ctx)
+    const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
+    provider.pushExternal({
+      'ui-theme': { fontSize: 'broken' },
+      workspace: { retry: { attempts: 9 } },
+    })
+    expect(theme.get()).toEqual({ theme: 'dark', fontSize: 14 })
+    expect(workspace.get()).toEqual({ retry: { attempts: 9, delayMs: 100 }, tags: ['default'] })
+    expect(events.map(event => event.ns)).toEqual(['workspace'])
+  })
+
+  it('recovers from a bad section once storage turns valid again', async () => {
+    const { ctx, provider } = await boot()
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } })
+    expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
+    provider.pushExternal({ 'ui-theme': { fontSize: 18 } })
+    expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
+  })
+})
+
+describe('watch', () => {
+  it('stops after its disposer runs', async () => {
+    const { ctx, provider } = await boot()
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const watcher = vi.fn()
+    const dispose = scope.watch(watcher)
+    dispose()
+    provider.pushExternal({ 'ui-theme': { theme: 'light' } })
+    expect(watcher).not.toHaveBeenCalled()
+  })
+
+  it('contains a throwing watcher without blocking the commit or other watchers', async () => {
+    const { ctx, provider } = await boot()
+    const events = recordUpdates(ctx)
+    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    scope.watch(() => { throw new Error('watcher boom') })
+    const second = vi.fn()
+    scope.watch(second)
+    provider.pushExternal({ 'ui-theme': { theme: 'light' } })
+    expect(second).toHaveBeenCalledTimes(1)
+    expect(events).toHaveLength(1)
+    expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
+  })
+})

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

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 40 - 0
pnpm-lock.yaml

@@ -3636,6 +3636,46 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/settings/settings:
+    devDependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+
+  packages/settings/settings-local:
+    dependencies:
+      chokidar:
+        specifier: ^4.0.3
+        version: 4.0.3
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+      yaml:
+        specifier: ^2.9.0
+        version: 2.9.0
+    devDependencies:
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-paths':
+        specifier: workspace:^
+        version: link:../../util/paths
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../settings
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/skill/skill:
     dependencies:
       schemastery:

+ 2 - 2
scripts/doc-budgets.manifest.json

@@ -1,5 +1,5 @@
 {
-  "AGENTS.md": 1705,
+  "AGENTS.md": 1710,
   "docs/AGENTS.md": 1150,
   "docs/architecture.md": 1800,
   "docs/cordis-primer.md": 600,
@@ -7,5 +7,5 @@
   "docs/testing.md": 1100,
   "examples/AGENTS.md": 310,
   "packages/AGENTS.md": 675,
-  "packages/README.md": 835
+  "packages/README.md": 845
 }

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

@@ -217,6 +217,12 @@ const FOUNDATION_TYPE_NAMES = new Set([
 /** Project types deliberately documented outside the core-data catalog. */
 const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
+  SettingsNamespace: 'settings seam vocabulary is owned by packages/settings/settings/README.md',
+  SettingsUpdateSource: 'settings seam vocabulary is owned by packages/settings/settings/README.md',
+  SettingsRegisterOptions: 'settings seam vocabulary is owned by packages/settings/settings/README.md',
+  SettingsScope: 'settings seam vocabulary is owned by packages/settings/settings/README.md',
+  SettingsDescriptor: 'settings seam vocabulary is owned by packages/settings/settings/README.md',
+  z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)',
   BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
   InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
   ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',

+ 9 - 0
scripts/gen-doc-graphs.ts

@@ -137,6 +137,15 @@ const SERVICE_ROLES: ServiceRole[] = [
     consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'],
     note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
   },
+  {
+    key: 'settings',
+    pkg: 'settings',
+    title: 'User-settings seam',
+    mode: 'seam',
+    implementations: ['settings-local'],
+    consumers: [],
+    note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet.',
+  },
   {
     key: 'telemetry',
     pkg: 'session-telemetry',

+ 2 - 0
scripts/verify-package-readme-model-experience.ts

@@ -90,6 +90,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
   'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
   'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
+  'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' },
+  'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' },
   'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
   'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
   'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },

+ 2 - 0
tsconfig.base.json

@@ -75,6 +75,7 @@
         "./packages/hooks/*/src/invariant.ts",
         "./packages/session-persistence/*/src/invariant.ts",
         "./packages/session-query/*/src/invariant.ts",
+        "./packages/settings/*/src/invariant.ts",
         "./packages/telemetry/*/src/invariant.ts",
         "./packages/acp/*/src/invariant.ts",
         "./packages/storage/*/src/invariant.ts",
@@ -156,6 +157,7 @@
         "./packages/session-persistence/*/src",
         "./packages/session-query/*/src",
         "./packages/session-title/*/src",
+        "./packages/settings/*/src",
         "./packages/telemetry/*/src",
         "./packages/acp/*/src",
         "./packages/storage/*/src",

+ 2 - 0
tsconfig.host.json

@@ -58,6 +58,8 @@
     { "path": "./packages/session-persistence/session-persistence-sqlite" },
     { "path": "./packages/session-query/session-query" },
     { "path": "./packages/session-query/session-query-sqlite" },
+    { "path": "./packages/settings/settings" },
+    { "path": "./packages/settings/settings-local" },
     { "path": "./packages/session-query/tool-session-query" },
     { "path": "./packages/storage/storage" },
     { "path": "./packages/storage/storage-json" },