@morlay/dsh-session-mode 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/modes.ts CHANGED
@@ -1,105 +1,86 @@
1
- /**
2
- * 模式的**定义形状**与它的装配期校验:一个模式就是"一段提示词 + 一组能力开关"。
3
- *
4
- * 与旧形态(每个模式一行 `@deepseek-ai/dsh-agent-preset`,`config.plugins` 里塞 persona / scope 行)的区别:
5
- * 模式不再是 Cordis 子树,而是这份纯数据——本包的插件行只在 `config.modes` 里声明它们,运行期按会话读取
6
- * 并应用(persona 注册到该 agent 的 scope,工具收口交给 `@morlay/dsh-context-assembler/scope`)。
7
- *
8
- * 行清单(工具 / 命令 / 压缩 / 委派…)来自 `preset` 指的那份 agent preset:本部署自己注册了一份
9
- * (`packages/bundles/session-mode-profile` 的 `preset-mode-switch`),两个模式共享它,差异全在会话级收口。
10
- *
11
- * "支持自定义"就是指这份 config:装配层(`cordis.patch.yml` / profile 的用户层)能整体改写 `modes`,
12
- * 也可以只给某几个模式换提示词或白名单——不需要任何插件行。
13
- *
14
- * "某个模式默认用哪个模型"**不在**模式里,而是 config 的顶层 `models`(模式 id → 模型):它是 settings
15
- * 的设置面要编辑的东西,而设置面只认 volatile 字段、且只认**固定路径**(dict 内部的字段一律 blocked,见
16
- * `@deepseek-ai/schemastery` 的 `validateVolatileSchema`)。取舍与理由见
17
- * [ADR 模式默认模型搬到顶层 volatile](../.agents/adrs/20260925-模式默认模型搬到顶层volatile.md)。
18
- */
1
+ // 模式的定义形状与它的装配期校验:一个模式就是"一段提示词 + 一组能力开关",本行的 `config.modes` 声明它们。
2
+ // 行清单来自 `preset` 指的那份 agent preset;装配层能整体改写 `modes`,也可以只给某几个模式换提示词或白名单。
3
+ // 取舍见 `.agents/adrs/20260925-默认模型住在模式定义里.md`、`.agents/designs/20260924-会话模式.md`。
19
4
 
20
5
  import type { Volatile } from "@deepseek-ai/cordis";
21
6
  import z from "@deepseek-ai/schemastery";
7
+ import { POLICY_NAMES } from "./shared.ts";
22
8
 
23
- /** 一个模式的提示词:两段文本,注册成 agent 作用域的 `deployment:persona-prefix` / `-suffix` section。 */
9
+ // 一个模式的提示词:两段文本,注册成 agent 作用域的 `deployment:persona-prefix` / `-suffix` section。
24
10
  export interface SessionModePersona {
25
- /** 系统提示词最前的一段;空串表示不遮蔽部署级那层。 */
11
+ // 系统提示词最前的一段;空串表示不遮蔽部署级那层。
26
12
  readonly prefix: string;
27
- /** 系统提示词最后的一段;空串表示不写。 */
13
+ // 系统提示词最后的一段;空串表示不写。
28
14
  readonly suffix: string;
29
15
  }
30
16
 
31
- /**
32
- * 一个模式对谁可见:`main` 进用户选择器(会话级选择),`subagent` 表示它**可以**作为子代理的 mode。
33
- * 两个角色可以同时声明;不写默认 `["main"]`——没写角色的模式不该悄悄变成子代理候选。
34
- *
35
- * `subagent` 目前只是候选集的声明:子代理默认继承父 mode(不看角色),"按角色指派 mode" 还没做。
36
- */
17
+ // 一个模式对谁可见:`main` 进用户选择器(会话级选择),`subagent` 表示它**可以**作为子代理的 mode。
18
+ // 两个角色可以同时声明;不写默认 `["main"]`。`subagent` 现在只是候选集的声明——子代理默认继承父 mode。
37
19
  export type SessionModeRole = "main" | "subagent";
38
20
 
39
- /** 一个模式的默认模型;省略的字段跟着 provider 默认走(与全局 `agent-default-model` 同形状)。 */
21
+ // 一个模式的默认模型;省略的字段跟着 provider 默认走(与全局 `agent-default-model` 同形状)。
40
22
  export interface SessionModeModel {
41
23
  readonly provider: string;
42
24
  readonly model: string;
43
25
  readonly reasoningEffort?: string;
44
26
  }
45
27
 
46
- /** 退役的顶层形状:模式 id → 模型(默认模型现在住在各自的模式里)。 */
28
+ // 退役的顶层形状:模式 id → 模型(默认模型现在住在各自的模式里)。
47
29
  export type SessionModeModels = Readonly<Record<string, SessionModeModel>>;
48
30
 
49
- /**
50
- * 一个模式:提示词 + 能力开关。
51
- *
52
- * 这份形状是 **schema 归一化之后**的:每个字段都有值(写配置时可以不写,schema 用默认补上——`description`
53
- * 补空串、`persona` 补两段空文本、两个布尔开关补 `true`)。配置里能省略哪些字段看 `modeSchema` 的 default,
54
- * 不看这里。
55
- */
31
+ // 一个模式:提示词 + 能力开关。这份形状是 **schema 归一化之后**的(每个字段都有值,schema 用默认补上),
32
+ // 配置里能省略哪些字段看 `modeSchema` 的 default。
56
33
  export interface SessionMode {
57
- /**
58
- * 这个模式挂在哪个 **agent preset** 上(官方 `standard` / `ptc` / `minimal` / `cordis`,或本部署自己
59
- * 注册的那一份的 `id`,见 `mode-sources.ts` 的 `MODE_PRESET_ID`)。
60
- *
61
- * preset 决定这个 agent 有哪些行(工具 / 命令 / 压缩 / 委派…),模式只决定"这些行怎么被用":
62
- * `allowTools` 里 preset 没有的工具自动跳过,其余扩展(persona / 注入开关 / 默认模型)照常应用。
63
- * **可以共享**(本部署就是两个模式挂同一份 preset,差异全在会话级收口);共享时 preset → 模式的反查无从下手,
64
- * 见 `SessionModes.modeForPreset`。空串表示不挂(只能由 applyTo / 继承使用)。
65
- */
34
+ // 这个模式挂在哪个 **agent preset** 上(官方四个,或本部署自己注册的那一份)。**可选**:不写(schema 里是空串)
35
+ // 就是不绑——选这个模式不换 preset,会话保持它当前挂着的那份,行清单由那份 preset 提供;模式自己的那几项扩展
36
+ // (persona / 工具名单 / policy 名单 / 三个开关 / `defaultModel`)在任意 preset 上都照常生效。
37
+ // 写了才在切模式时切过去;**可以共享**(共享时反查无从下手,见 `SessionModes.modeForPreset`)。
66
38
  readonly preset: string;
67
- /** 模式的展示名(选择面归官方 roster;这里留着做事实文案)。 */
39
+ // 模式的展示名(选择面归官方 roster;这里留着做事实文案)。
68
40
  readonly name: string;
69
- /** 一句话说明这个模式干什么;空串表示没写。 */
41
+ // 一句话说明这个模式干什么;空串表示没写。
70
42
  readonly description: string;
71
- /** 这个模式归谁用:`main`(用户选择器)/ `subagent`(可作子代理 mode)。至少一个。 */
43
+ // 这个模式归谁用:`main`(用户选择器)/ `subagent`(可作子代理 mode)。至少一个。
72
44
  readonly role: SessionModeRole[];
73
- /** 该模式的提示词。 */
45
+ // 该模式的提示词。
74
46
  readonly persona: SessionModePersona;
75
- /**
76
- * 这个模式能用哪些工具;其余工具既不进模型目录、调用也被执行层拒绝,它们自己的说明 section
77
- * (`tool:<工具名>`)也不留在提示词里。**至少给一个**——想要"全都要"就列出全部,别留空。
78
- */
47
+ // 这个模式能用哪些工具;其余工具既不进模型目录、调用也被执行层拒绝,它们自己的说明 section
48
+ // (`tool:<工具名>`)也不留在提示词里。**留空表示不设收窄**:这个会话用它挂着的 preset 装着的全部工具
49
+ // (想收窄就列名单——"全都要"不需要抄一份清单)。
79
50
  readonly allowTools: string[];
80
- /**
81
- * 是否要 instruction 类注入(工作区指令、技能目录、用法正文)。缺省要;`false` 表示这个模式一条都不要
82
- * ——对话模式就是它。开关由 `@morlay/dsh-context-assembler/scope` 落到通道上。
83
- */
51
+ // 这个模式**不用**哪些工具(黑名单):从 `allowTools` 定下的那份里减掉(`allowTools` 留空时就是从全部里减)。
52
+ // 留空表示不禁任何工具。同时命中两份名单时以这里为准(deny 优先)。
53
+ readonly denyTools: string[];
54
+ // 上游 policy 规则的**生效白名单**:留空(或不写)表示全部规则照旧生效;有值表示只有列出的那些生效,
55
+ // 其余的按 `denyPolicies` 那一套被绕过。名单见 `POLICY_NAMES`。空数组与不写同义。
56
+ readonly allowPolicies: string[];
57
+ // 上游 policy 规则的黑名单:列出的规则**禁用**——上游在那条 waterfall 上的裁决(连它抛出的拒绝)被丢掉,
58
+ // 调用按"没有这条规则"继续。留空表示一条都不禁;同时命中两份名单时以这里为准(deny 优先)。
59
+ readonly denyPolicies: string[];
60
+ // 是否要 instruction 类注入。缺省要;`false` 表示这个模式一条都不要——对话模式就是它。落到两处:丢掉官方
61
+ // `agent-instructions` 的注入(`agent/pre-step` 上过滤),以及关掉通道自己的降级注入。
84
62
  readonly instructions: boolean;
85
- /**
86
- * 是否要 runtime context(文件沙箱策略、审批策略那两条动态快照)。缺省要;`false` 表示这个模式不要它们
87
- * ——对话模式没有文件与 shell 工具,"能改工作区哪些文件、要不要走审批"对它全是噪音。
88
- */
63
+ // 是否要技能目录。**可选**:不写就由这个模式自己的工具名单推导——`(allowTools 留空 ? 全部 : allowTools) −
64
+ // denyTools` 里含 `skill` 就要(`allowTools` 留空即"全部",所以只有 `denyTools` 能把它推成 `false`)。
65
+ // `false` 表示这个模式不要技能目录:丢掉官方 `skill-catalog` 的注入(`skill` 工具的收窄仍归 `allowTools`)。
66
+ readonly skills?: boolean;
67
+ // 是否要 runtime context(文件沙箱策略、审批策略那两条动态快照)。缺省要;`false` 表示这个模式不要它们
68
+ // ——对话模式没有文件与 shell 工具,"能改工作区哪些文件、要不要走审批"对它全是噪音。
89
69
  readonly runtimeContext: boolean;
90
- /**
91
- * 这个模式的默认模型;省略就跟全局 `agent-default-model`。**可选**:没配的模式在页面上不出现在这一行
92
- * (`defaultModel` 是它所在模式的一个可加字段)。
93
- */
70
+ // 这个模式的默认模型;省略就跟全局 `agent-default-model`。**可选**:没配的模式在页面上不出现在这一行
71
+ // (`defaultModel` 是它所在模式的一个可加字段)。
94
72
  readonly defaultModel?: SessionModeModel;
95
73
  }
96
74
 
75
+ // `skills` 不写时的推导:起点是白名单(留空 = 起点是全部工具,含 `skill`),减去黑名单,还留着 `skill` 就要技能
76
+ // 目录。名单留空等于"全部工具",所以只有 `denyTools` 能把它推成 `false`。
77
+ export function derivedSkills(mode: Pick<SessionMode, "allowTools" | "denyTools">): boolean {
78
+ const allowed = mode.allowTools.length === 0 || mode.allowTools.includes("skill");
79
+ return allowed && !mode.denyTools.includes("skill");
80
+ }
81
+
97
82
  // 每个字段都带 default:schema 的产物因此没有 `undefined` 键(`exactOptionalPropertyTypes` 下"缺省的键"
98
- // 会与可选属性对不上),而默认值就是"不遮蔽"的那个语义——persona 的默认是两段空文本。
99
- /**
100
- * 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
101
- * (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
102
- */
83
+ // 会与可选属性对不上)。`description()` 的类型签名只声明 `string`,这里做一次类型放行。
103
84
  const localized = (text: { zh: string; en: string }): string => text as unknown as string;
104
85
 
105
86
  const personaSchema = z.object({
@@ -107,10 +88,10 @@ const personaSchema = z.object({
107
88
  suffix: z.string().default(""),
108
89
  });
109
90
 
110
- /** 角色是个封闭集合:写错的 role 在装配期就拒绝,而不是静默变成"谁都不用"。 */
91
+ // 角色是个封闭集合:写错的 role 在装配期就拒绝,而不是静默变成"谁都不用"。
111
92
  const roleSchema = z.union([z.const("main"), z.const("subagent")]);
112
93
 
113
- /** 默认模型的形状与全局 `agent-default-model` 一致;省略 effort 就跟 provider 默认。 */
94
+ // 默认模型的形状与全局 `agent-default-model` 一致;省略 effort 就跟 provider 默认。
114
95
  const modelSchema = z.object({
115
96
  provider: z
116
97
  .string()
@@ -140,28 +121,24 @@ const modelSchema = z.object({
140
121
  ),
141
122
  });
142
123
 
143
- /** 本包 config 的**源码形状**:装配层与设置页写的那个形状。 */
124
+ // 本包 config 的**源码形状**:装配层与设置页写的那个形状。
144
125
  export interface Config {
145
- /** 新会话(还没选过模式的会话)用哪个模式。必须是 `modes` 里的一个 id。 */
126
+ // 新会话(还没选过模式的会话)用哪个模式。必须是 `modes` 里的一个 id。
146
127
  readonly default: string;
147
- /** 模式清单:id → 定义(含各自的 `defaultModel`)。顺序即选择器里的顺序(`Object.entries` 的插入序)。 */
128
+ // 模式清单:id → 定义(含各自的 `defaultModel`)。顺序即选择器里的顺序(`Object.entries` 的插入序)。
148
129
  readonly modes: Record<string, SessionMode>;
149
- /**
150
- * 各模式默认模型曾经住在这里(模式 id → 模型)。现在住在**每个模式自己的 `defaultModel`** 里,这个字段
151
- * 只剩一件事:装配期看见它还配着值就报错,提醒把它挪进对应的模式——否则它会静静地失效。
152
- */
130
+ // 退役字段:各模式的默认模型住在每个模式自己的 `defaultModel` 里;这里留着只为装配期报错
131
+ // (见 `configProblem`)。
153
132
  readonly models?: SessionModeModels;
154
133
  }
155
134
 
156
- /**
157
- * schema 解析之后的形状:volatile 字段被换成**稳定引用**,读它要过 `.get()`(设置页改的就是同一份)。
158
- *
159
- * `default` 与 `modes` 都是 volatile:默认模式与整份模式清单(含各自的 `defaultModel`)都在行配置页上。
160
- */
135
+ // schema 解析之后的形状:volatile 字段被换成**稳定引用**,读它要过 `.get()`(设置页改的就是同一份)。
136
+ //
137
+ // `default` 与 `modes` 都是 volatile:默认模式与整份模式清单(含各自的 `defaultModel`)都在行配置页上。
161
138
  export interface ResolvedConfig {
162
139
  readonly default: Volatile<string>;
163
140
  readonly modes: Volatile<Record<string, SessionMode>>;
164
- /** 退役的顶层字段:解析后仍在这儿(普通值,不 volatile),装配期据此发现"还配着值"并报错。 */
141
+ // 退役的顶层字段:解析后仍在这儿(普通值,不 volatile),装配期据此发现"还配着值"并报错。
165
142
  readonly models: SessionModeModels;
166
143
  }
167
144
 
@@ -171,8 +148,8 @@ const modeSchema: z<SessionMode> = z.object({
171
148
  .default("")
172
149
  .description(
173
150
  localized({
174
- zh: "这个模式挂哪个 agent preset(它的 id,官方或本部署自建的):行清单由那个 preset 提供,几个模式可以共享同一个;空串表示不挂(只能由 applyTo / 继承使用)。",
175
- en: "Which agent preset this mode rides on (its id, shipped or deployment-owned): that preset supplies the row list and several modes may share it; empty means none (usable only through applyTo / inheritance).",
151
+ zh: "这个模式挂哪个 agent preset(它的 id,官方或本部署自建的):行清单由那份 preset 提供,几个模式可以共享同一个。留空就是不绑——选这个模式不换 preset,会话保持当前挂着的那份,模式自己的提示词、工具收口与开关照常生效。",
152
+ en: "Which agent preset this mode rides on (its id, shipped or deployment-owned): that preset supplies the row list, and several modes may share it. Leave it empty to bind none — selecting the mode then keeps whatever preset the session already has, while the mode's persona, tool narrowing, and switches still apply.",
176
153
  }),
177
154
  ),
178
155
  name: z
@@ -208,8 +185,35 @@ const modeSchema: z<SessionMode> = z.object({
208
185
  .default([])
209
186
  .description(
210
187
  localized({
211
- zh: "这个会话能用的工具;其余既不进目录,调用也被拒。",
212
- en: "Tools this session may use; everything else leaves the catalog and calls are refused.",
188
+ zh: "这个会话能用的工具;其余既不进目录,调用也被拒。**留空就是不设收窄**:用这个会话挂着的 preset 提供的全部工具。",
189
+ en: "Tools this session may use; everything else leaves the catalog and calls are refused. Leave it empty to narrow nothing: the session then uses every tool its preset provides.",
190
+ }),
191
+ ),
192
+ denyTools: z
193
+ .array(z.string())
194
+ .default([])
195
+ .description(
196
+ localized({
197
+ zh: "这个模式不用的工具(黑名单):从 `allowTools` 定下的那份里减掉(`allowTools` 留空就是从全部里减)。留空 = 一条都不禁;同时命中两份名单时以这里为准。",
198
+ en: "Tools this mode must not use (deny list): subtracted from whatever `allowTools` settled on (with an empty `allowTools`, from everything). Empty denies nothing; a name in both lists is denied.",
199
+ }),
200
+ ),
201
+ allowPolicies: z
202
+ .array(z.string())
203
+ .default([])
204
+ .description(
205
+ localized({
206
+ zh: "上游 policy 规则的生效白名单(`fs/write-intent` / `fs/edit-intent`):留空 = 全部规则照旧生效;有值 = 只有列出的生效,其余的被绕过。",
207
+ en: "Allow list of upstream policy rules that stay in force (`fs/write-intent` / `fs/edit-intent`): empty keeps every rule; when set, only the listed ones stay, and the others are bypassed.",
208
+ }),
209
+ ),
210
+ denyPolicies: z
211
+ .array(z.string())
212
+ .default([])
213
+ .description(
214
+ localized({
215
+ zh: "上游 policy 规则的黑名单:列出的规则禁用(它的裁决连拒绝一起丢掉,调用按没有这条规则继续)。留空 = 一条都不禁;同时命中两份名单时以这里为准。",
216
+ en: "Deny list of upstream policy rules: the listed ones are disabled — their verdict (rejection included) is dropped and the call proceeds as if the rule were absent. Empty denies nothing; a name in both lists is denied.",
213
217
  }),
214
218
  ),
215
219
  instructions: z
@@ -217,10 +221,16 @@ const modeSchema: z<SessionMode> = z.object({
217
221
  .default(true)
218
222
  .description(
219
223
  localized({
220
- zh: "是否要 instruction 类注入(工作区指令、技能目录、用法正文)。",
221
- en: "Whether instruction-class injections apply (workspace instructions, skill catalog, guidance).",
224
+ zh: "是否要 instruction 类注入(工作区指令、用法正文):`false` 时丢掉官方 `agent-instructions` 的注入,并关掉通道自己的降级注入。",
225
+ en: "Whether instruction-class injections apply (workspace instructions, guidance): `false` drops the official `agent-instructions` injection and turns off the channel's own demoted delivery.",
222
226
  }),
223
227
  ),
228
+ skills: z.boolean().description(
229
+ localized({
230
+ zh: "是否要技能目录(官方 `skill-catalog` 的注入)。**不写就按这个模式自己的工具名单推导**:`(allowTools 留空 ? 全部 : allowTools) − denyTools` 里含 `skill` 就要;写 `false` 就丢掉官方 `skill-catalog` 的注入(`skill` 工具的可见性仍归 `allowTools`)。",
231
+ en: "Whether the skill catalog applies (the official `skill-catalog` injection). Unset derives it from this mode's own tool lists: it applies when `(empty allowTools ? every tool : allowTools) − denyTools` contains `skill`; `false` drops the official `skill-catalog` injection (`skill` tool visibility still belongs to `allowTools`).",
232
+ }),
233
+ ),
224
234
  runtimeContext: z
225
235
  .boolean()
226
236
  .default(true)
@@ -230,10 +240,8 @@ const modeSchema: z<SessionMode> = z.object({
230
240
  en: "Whether the runtime snapshot applies (sandbox and approval policy).",
231
241
  }),
232
242
  ),
233
- /**
234
- * 这个模式的默认模型。不标 `volatile`:`modes` 本身就是 volatile,整棵子树都在页面上——再标一层会被
235
- * schemastery 拒(`validateVolatileSchema` 不许 volatile 套 volatile)。
236
- */
243
+ // 这个模式的默认模型。不标 `volatile`:`modes` 本身就是 volatile,整棵子树都在页面上——再标一层会被
244
+ // schemastery 拒(`validateVolatileSchema` 不许 volatile 套 volatile)。
237
245
  defaultModel: modelSchema
238
246
  // `default(null)` 是"没配就没有这个键":schemastery 对缺省的对象字段会造一个空对象,那样每个模式都会
239
247
  // 凭空多出一行;给了 null 反而让它保持缺失(页面按非必填处理,从候选加成)。
@@ -264,43 +272,51 @@ export const Config: z<Config, ResolvedConfig> = z.object({
264
272
  .description(
265
273
  localized({
266
274
  zh:
267
- "模式清单:id → 定义(persona / 允许的工具 / 角色)。改它对**已运行会话**不自动生效——重挂后新建的会话、" +
268
- "或重新应用模式的会话才用新定义。",
275
+ "模式清单:id → 定义(persona / 工具名单 / 生效的 policy 规则 / 角色)。改它对**已运行会话**不自动生效" +
276
+ "——重挂后新建的会话、或重新应用模式的会话才用新定义。",
269
277
  en:
270
- "Mode roster: id to definition (persona, allowed tools, role). Edits do not follow into already-running " +
271
- "sessions; sessions created after the row is remounted use the new definition.",
278
+ "Mode roster: id to definition (persona, tool lists, effective policy rules, role). Edits do not follow " +
279
+ "into already-running sessions; sessions created after the row is remounted use the new definition.",
272
280
  }),
273
281
  )
274
282
  .volatile(),
275
- /**
276
- * 退役字段:默认模型住在每个模式自己的 `defaultModel` 里。这里留着是为了**报错**(见 `configProblem`),
277
- * 页面上不出现(`hidden()`)。
278
- */
283
+ // 退役字段:默认模型住在每个模式自己的 `defaultModel` 里。这里留着是为了**报错**(见 `configProblem`),
284
+ // 页面上不出现(`hidden()`)。
279
285
  models: z.dict(modelSchema).default({}).hidden(),
280
286
  });
281
287
 
282
- /** 校验只需要看的那几件事:默认模式、每个模式的工具名单与角色、每个模式自己的默认模型。 */
288
+ // 校验只需要看的那几件事:默认模式、每个模式的角色、工具名单、policy 名单与默认模型。
283
289
  interface Validated {
284
290
  readonly default: string;
285
291
  readonly modes: Readonly<
286
292
  Record<
287
293
  string,
288
294
  {
289
- /** 空串合法的"不挂";共享合法(差异由会话级收口表达),所以这里不做任何映射唯一性校验。 */
295
+ // 空串合法的"不挂";共享合法(差异由会话级收口表达),所以这里不做任何映射唯一性校验。
290
296
  readonly preset?: string;
297
+ // 留空合法:不设收窄(用 preset 的全部工具)。
291
298
  readonly allowTools?: readonly string[];
299
+ // 留空合法:不禁任何工具;与 `allowTools` 同时命中合法(deny 优先)。
300
+ readonly denyTools?: readonly string[];
301
+ // 留空合法:全部规则生效。
302
+ readonly allowPolicies?: readonly string[];
303
+ // 留空合法:一条都不禁;与 `allowPolicies` 同时命中合法(deny 优先)。
304
+ readonly denyPolicies?: readonly string[];
292
305
  readonly role?: readonly string[];
293
306
  readonly defaultModel?: { readonly provider?: string; readonly model?: string };
294
307
  }
295
308
  >
296
309
  >;
297
- /** 退役的顶层字段:还配着值就报错(它已经不再生效)。 */
310
+ // 退役的顶层字段:还配着值就报错(它已经不再生效)。
298
311
  readonly models?: Readonly<Record<string, unknown>>;
299
312
  }
300
313
 
301
- /** 模式定义里不合法的地方(装配期 fail loud,而不是等到某个会话装配提示词时才发现)。 */
314
+ // 已知的 policy 名(上游 waterfall 名):写错的名字静默变成"没配"是这份配置最坏的失效方式,所以装配期拒绝。
315
+ const POLICY_NAME_SET: ReadonlySet<string> = new Set(POLICY_NAMES);
316
+
317
+ // 模式定义里不合法的地方(装配期 fail loud,而不是等到某个会话装配提示词时才发现)。
302
318
  export function configProblem(config: Validated): string | undefined {
303
- /** 没写 `role` 等于默认 `["main"]`(与 schema 的默认一致)——字面量与归一化后的形状都能校验。 */
319
+ // 没写 `role` 等于默认 `["main"]`(与 schema 的默认一致)——字面量与归一化后的形状都能校验。
304
320
  const roles = (id: string): readonly string[] => config.modes[id]?.role ?? ["main"];
305
321
  const ids = Object.keys(config.modes);
306
322
  if (ids.length === 0) return "session-mode: `modes` must declare at least one mode";
@@ -314,14 +330,19 @@ export function configProblem(config: Validated): string | undefined {
314
330
  if (!roles(config.default).includes("main")) {
315
331
  return `session-mode: \`default\` names ${JSON.stringify(config.default)}, which does not declare role "main"; a session that can never be re-selected is a contradiction`;
316
332
  }
317
- const empty = ids.filter((id) => (config.modes[id]?.allowTools ?? []).length === 0);
318
- if (empty.length > 0) {
319
- return `session-mode: mode(s) ${empty.join(", ")} declare no \`allowTools\`; list the tools instead of leaving it empty`;
333
+ // 每个模式列出的 policy 名都必须在已知名单里(名单见 `shared.ts` 的 `POLICY_NAMES`)。`denyTools` 与
334
+ // `allowTools` 同时命中是**合法**的(deny 优先):这里不报错,只拒绝认不出的名字。
335
+ const unknownPolicies = Object.entries(config.modes).flatMap(([id, mode]) =>
336
+ [...(mode.allowPolicies ?? []), ...(mode.denyPolicies ?? [])]
337
+ .filter((policy) => !POLICY_NAME_SET.has(policy))
338
+ .map((policy) => `${id}: ${policy}`),
339
+ );
340
+ if (unknownPolicies.length > 0) {
341
+ return `session-mode: mode(s) ${unknownPolicies.join(", ")} name unknown policies; the known ones are ${POLICY_NAMES.join(", ")}`;
320
342
  }
321
- // `preset` **允许共享**(本部署两个模式挂同一份 preset,差异由会话级收口表达):共享时 preset → 模式
322
- // 的反查无从下手,那件事交给 `SessionModes.modeForPreset` 处理(共享时它返回 `undefined`,不反查)。
323
- // 空串是"不挂",合法;除此之外没有可校验的东西——preset 是否存在由 registry 自己回答。
324
- // 退役的顶层字段还配着值:它已经不再生效,别让一份"看着像配过"的配置静静地失效。
343
+ // `allowTools` **留空是合法的**:不设收窄,用这个会话挂着的 preset 的全部工具。
344
+ // `preset` **允许共享**(共享时 preset → 模式的反查交给 `SessionModes.modeForPreset`),空串是"不挂";
345
+ // 退役的顶层 `models` 还配着值就报错——别让一份"看着像配过"的配置静静地失效。
325
346
  if (Object.keys(config.models ?? {}).length > 0) {
326
347
  return "session-mode: `models` has moved into each mode's `defaultModel`; move the entries there and drop the top-level `models`";
327
348
  }
package/src/persona.ts CHANGED
@@ -1,13 +1,5 @@
1
- /**
2
- * 按会话给提示词:把模式的 persona 注册到**该 agent 自己的 scope** 上。
3
- *
4
- * 为什么不是一行 `@deepseek-ai/dsh-persona`:那一行只能按 scope 遮蔽,得先有一棵 preset 子树。这里走的是
5
- * 上游自己给子 agent 用的那条路——在 `agent.ctx` 上注册同名 section(`deployment:persona-prefix` /
6
- * `-suffix`),跨层遮蔽部署级那层,不需要任何子树。
7
- *
8
- * 两个 section 都注册(哪怕只给了一段文本):suffix 缺省就是空串,语义与上游 persona 行一致。注册与
9
- * 注销都发 `system-prompt/change`,所以模式切换后下一次装配自然读到新文本。
10
- */
1
+ // 按会话给提示词:把模式的 persona 注册到该 agent 自己的 scope 上(`deployment:persona-prefix` / `-suffix`),
2
+ // 跨层遮蔽部署级那层。两个 section 都注册(缺省是空串);注册与注销都发 `system-prompt/change`。
11
3
 
12
4
  import type { Agent } from "@deepseek-ai/dsh-agent";
13
5
  import type { PromptSectionOrderName } from "@deepseek-ai/dsh-system-prompt";
@@ -18,12 +10,7 @@ import type { SessionModePersona } from "./modes.ts";
18
10
  const PREFIX_ORDER: PromptSectionOrderName = "DEPLOYMENT_PERSONA_PREFIX";
19
11
  const SUFFIX_ORDER: PromptSectionOrderName = "DEPLOYMENT_PERSONA_SUFFIX";
20
12
 
21
- /**
22
- * 把一段 persona 装到该 agent 的 scope 上。
23
- * @param agent - 目标 agent(用它的 `ctx` 定作用域)。
24
- * @param persona - 模式的提示词;缺省时两段都注册成空串,等于遮蔽掉部署级那层。
25
- * @returns 注销这两个 section 的 disposer(模式切换时先调它)。
26
- */
13
+ // 把一段 persona 装到该 agent 的 scope 上;返回注销这两个 section 的 disposer(模式切换时先调它)。
27
14
  export function installPersona(agent: Agent, persona: SessionModePersona | undefined): () => void {
28
15
  const prompt = agent.ctx.systemPrompt;
29
16
  const disposers = [
package/src/rows.ts CHANGED
@@ -1,42 +1,54 @@
1
- /**
2
- * 本包作为能力包发布的**装配数据**:`session-mode` 行的 config(各模式的会话级扩展)由这里渲染,
3
- * 装配入口在 `packages/bundles/session-mode-profile`(它同时装注入通道、工具说明与 subagent 那几行)。
4
- */
1
+ // 本包作为能力包发布的**装配数据**:`session-mode` 行的 config(各模式的会话级扩展)由这里渲染,
2
+ // 装配入口在 `packages/bundles/session-mode-profile`(它同时装注入通道、工具说明与 subagent 那几行)。
5
3
 
6
4
  import { DEFAULT_MODE, MODE_SOURCES, type ModeSource } from "./mode-sources.ts";
7
5
 
8
- // 装配入口要拿这个 id 声明 preset 行(`preset-<id>` 的行 id 与 `config.id` 都用它):与模式里的 `preset`
9
- // 是同一个事实,所以只有一份。
10
- export { MODE_PRESET_ID } from "./mode-sources.ts";
11
-
12
- /** 一行装配条目:与 `cordis.patch.yml` 的顶层结构同形。 */
6
+ // 一行装配条目:与 `cordis.patch.yml` 的顶层结构同形。
13
7
  export interface PatchRow {
14
8
  readonly insert?: readonly RowEntry[];
15
9
  }
16
10
 
17
- /** `insert` 里的一个条目。 */
11
+ // `insert` 里的一个条目。
18
12
  export interface RowEntry {
19
13
  readonly id: string;
20
14
  readonly name: string;
21
15
  readonly config?: Readonly<Record<string, unknown>>;
22
16
  }
23
17
 
24
- /** 一个模式的源定义 → 行 config(schema 的输入形状:可省的字段就省)。 */
18
+ // 一份名单:留空(或没写)就不进 config——空数组与不写同义,行 config 里不留空键。
19
+ function list<T>(values: readonly T[] | undefined): readonly T[] | undefined {
20
+ return values === undefined || values.length === 0 ? undefined : [...values];
21
+ }
22
+
23
+ // 一个模式的源定义 → 行 config(schema 的输入形状:可省的字段就省)。
25
24
  function modeConfig(source: ModeSource): Record<string, unknown> {
25
+ const allowTools = list(source.allowTools);
26
+ const denyTools = list(source.denyTools);
27
+ const allowPolicies = list(source.allowPolicies);
28
+ const denyPolicies = list(source.denyPolicies);
26
29
  return {
27
- preset: source.preset,
30
+ // `preset` 是可选的:不写就是不绑(行 config 里也不出现这个键,schema 默认空串)。
31
+ ...(source.preset === undefined ? {} : { preset: source.preset }),
28
32
  name: source.name,
29
33
  description: source.description,
30
34
  ...(source.role === undefined ? {} : { role: [...source.role] }),
31
35
  ...(source.persona === undefined ? {} : { persona: { ...source.persona } }),
32
- allowTools: [...source.allowTools],
36
+ // 四份名单都是可选的:不写(或写成空数组)就是这个键不进 config,schema 默认空数组。
37
+ // `allowTools` 不写 = 不设收窄;`denyTools` 不写 = 一件都不禁(两份同配时 deny 优先);
38
+ // `allowPolicies` 不写 = 全部上游 policy 规则生效;`denyPolicies` 不写 = 一条都不禁。
39
+ ...(allowTools === undefined ? {} : { allowTools }),
40
+ ...(denyTools === undefined ? {} : { denyTools }),
41
+ ...(allowPolicies === undefined ? {} : { allowPolicies }),
42
+ ...(denyPolicies === undefined ? {} : { denyPolicies }),
33
43
  ...(source.instructions === undefined ? {} : { instructions: source.instructions }),
44
+ // `skills` 不写就是不进 config:缺省由这份定义自己的工具名单推导(写成 `false` 才落进 config)。
45
+ ...(source.skills === undefined ? {} : { skills: source.skills }),
34
46
  ...(source.runtimeContext === undefined ? {} : { runtimeContext: source.runtimeContext }),
35
47
  ...(source.defaultModel === undefined ? {} : { defaultModel: { ...source.defaultModel } }),
36
48
  };
37
49
  }
38
50
 
39
- /** `session-mode` 行的装配:默认模式与各模式的扩展定义。 */
51
+ // `session-mode` 行的装配:默认模式与各模式的扩展定义。
40
52
  export function sessionModeRows(): readonly PatchRow[] {
41
53
  return [
42
54
  {