dsh-plugin-t-expert 0.3.92 → 0.4.9

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/lib/index.js CHANGED
@@ -38,6 +38,9 @@ import {
38
38
  truncate,
39
39
  writeFileAtomic,
40
40
  } from "./catalog.js";
41
+ // 技能开关(2026-09-28 从 dsh-plugin-skill-gate 整体搬来):包装 ctx.skills,把名单里的技能
42
+ // 从模型目录里摘掉。装配入口是动态 inject 调的 —— 宿主没有 skills 服务时只少这一块。
43
+ import { installSkillGate } from "./skill-gate/install.js";
41
44
  import { localized, NS, readLocale, renderList, renderSummonResults, t, toRenderableGroups, toRenderableSummonResults } from "./i18n.js";
42
45
  import { seedData } from "./bootstrap.js";
43
46
  import { installBundledSkills } from "./skill.js";
@@ -130,14 +133,19 @@ export const Config = schema.object({
130
133
  enabledSeeded: volatileField(schema.boolean().default(false)),
131
134
  /** 「默认全部启用」已按哪一版策略播种过;+1 即对所有用户重新播种一次。 */
132
135
  enabledSeedVersion: volatileField(schema.number().default(0)),
133
- /** 是否显示侧栏「定时事项」按钮(关掉 = 定时功能整体停用)。 */
134
- /** 是否显示窗口右上角的「活跃指示」。 */
135
- showActivePulse: volatileField(schema.boolean().default(true)),
136
+ /**
137
+ * 技能开关的名单文件(技能开关那部分的配置,2026-09-28 随它一起搬进来)。
138
+ *
139
+ * 默认仍是原来的 `~/.dsh/skill-gate.json` —— **刻意不改路径**:用户的名单就在那里,
140
+ * 换路径等于把已有的开关状态清零。字段类型与其它 Config 一致(普通 string,
141
+ * 不需要 volatile:它不属于「用户偏好」那套 profile patch 机制)。
142
+ */
143
+ skillGateStore: schema.string().default(join(homedir(), ".dsh", "skill-gate.json")),
136
144
  });
137
145
 
138
146
  /** 供 remote 服务读取名册的服务名。 */
139
147
  export const CATALOG_SERVICE = "tTeamCatalog";
140
- /**
148
+
141
149
  /** 设置命名空间(= 插件名)。 */
142
150
  export const SETTINGS_NAMESPACE = NS;
143
151
 
@@ -166,20 +174,6 @@ const settingsSchema = schema.object({
166
174
  * 而且置了 true 之后连改逻辑都救不回来。换成版本号后,+1 就能对所有人重新播种一次。
167
175
  */
168
176
  enabledSeedVersion: schema.number().default(0),
169
- /**
170
- * 是否显示侧栏「定时事项」按钮(界面偏好)。
171
- *
172
- * 语义(用户口径):**关掉 = 定时事项功能整体停用** —— 不只是藏按钮,还要停掉全部定时触发
173
- * 任务数据完整保留,重新打开即恢复调度。
174
- */
175
- /**
176
- * 是否显示窗口右上角的「活跃指示」(心电图图标,界面偏好)。
177
- *
178
- * 彩色 = 有会话正在活动(自己在跑、或子代理在跑)**或**有待查看的完成提醒;
179
- * 灰色 = 全部空闲且没有待看。它是**全局**的(挂会话标题栏的 utilities 槽),
180
- * 不受"当前打开哪个会话"影响。
181
- */
182
- showActivePulse: schema.boolean().default(true),
183
177
  });
184
178
 
185
179
  /**
@@ -223,10 +217,10 @@ function logSeedReport(logger, seeded, dataHome) {
223
217
  /**
224
218
  * 包内名册的规模(专家数 / 分区数),**从名册目录现算**并按目录缓存。
225
219
  *
226
- * 为什么不能写死:提示段是**模型可见的事实**。「a 316-expert, 22-division roster」这类字面量
227
- * 在名册增删改分区之后就对模型说谎,而且没有任何东西会红(2026-09-13 定性的恒存缺陷之一:
228
- * 数据更新了,模型看到的事实还是旧的)。现算 + 按目录缓存:一次 readdir 的开销,换来提示
229
- * 永远等于该名册的真实规模。
220
+ * 为什么不能写死:提示段是**模型可见的事实**。把「a N-expert, M-division roster」写成
221
+ * 字面量之后,名册增删或改分区之后那个 N/M 就对模型说谎,且没有任何东西会红
222
+ * (2026-09-13 定性的恒存缺陷之一:数据更新了,模型看到的事实还是旧的)。现算 + 按目录
223
+ * 缓存:一次 readdir 的开销,换来提示永远等于该名册的真实规模。
230
224
  * @param root - 名册目录(包内快照或用户数据目录)。
231
225
  * @returns 规模;目录读不到时 `experts` 为 0(调用方据此改用不带数字的说法,而不是报 0 位)。
232
226
  */
@@ -239,8 +233,9 @@ function rosterScale(root) {
239
233
  try {
240
234
  const divisions = readdirSync(key, { withFileTypes: true }).filter((entry) => entry.isDirectory());
241
235
  // **递归**数 `.md`:名册里存在 `game-development/unity/<slug>.md` 这类二级分组,
242
- // 只数一层会少算(2026-09-13 实测:一层 301 / 递归 316,与 README、source.json、
243
- // zh/names.json 的 316 一致)。分区数只取一级目录 —— 二级目录是分组不是分区。
236
+ // 只数一层会少算(2026-09-13 实测:一层 vs 递归差几十;具体数随名册增长而变,
237
+ // 所以**不**把数字写在这里 —— 真源是 `data/source.json` 与 verify 的 [15] 名册工具,
238
+ // 本注释只解释"为什么必须递归")。分区数只取一级目录 —— 二级目录是分组不是分区。
244
239
  const experts = readdirSync(key, { recursive: true }).filter((name) => String(name).endsWith(".md")).length;
245
240
  scale = { experts, divisions: divisions.length };
246
241
  } catch {
@@ -263,6 +258,41 @@ export function apply(ctx, config) {
263
258
  ctx.logger?.error?.(`[t-team] 名册快照同步失败,将按已有数据继续:${seeded.error}`);
264
259
  }
265
260
 
261
+ // ---- 技能开关(原 dsh-plugin-skill-gate)----
262
+ // ⚠️ 用**动态** ctx.inject 而不是静态 `inject = ["skills"]`:后者是强依赖,宿主某版本没有
263
+ // skills 服务时 T专家 会整个不激活 —— 连专家名册与召唤一起没。这里拿不到就只少技能开关。
264
+ if (typeof ctx.inject === "function") {
265
+ // ⚠️ 只装一次:`ctx.inject` 的回调在依赖服务**被重新提供**时会再次触发,重复装配会把
266
+ // `ctx.skills` 层层包装(wrapper 累积、卸载时的还原也会错位),而且 cordis 的
267
+ // `reflect.provide` 对同名服务是**硬冲突**(`service "skillGateHost" has been registered
268
+ // at <fiber>`,见 cordis 的 provide 实现:`if (this.store[key]) throw`)—— 第二次装配
269
+ // 必然抛在 provide 那一步。
270
+ //
271
+ // 但"只装一次"不能等于"之后什么都不说":宿主真把 skills 换成新实例时(另一个插件重载并
272
+ // 重新 provide 了它),停用名单仍作用在**旧**实例上,新实例的技能不受约束 ——
273
+ // 那是静默失效,用户关掉技能却发现它照样能被召唤,且日志里一个字都没有。所以这里出声。
274
+ let installedSkills; // undefined = 还没装过
275
+ ctx.inject(["skills"], (scoped) => {
276
+ const skillsCtx = scoped ?? ctx;
277
+ const skills = skillsCtx?.skills;
278
+ if (installedSkills === undefined) {
279
+ installedSkills = skills;
280
+ try {
281
+ installSkillGate(skillsCtx, { storePath: config.skillGateStore });
282
+ } catch (error) {
283
+ ctx.logger?.error?.("[t-team] 技能开关装配失败(专家名册不受影响):", error);
284
+ }
285
+ return;
286
+ }
287
+ if (skills === installedSkills) return; // 同一个实例:已经包过,幂等
288
+ ctx.logger?.warn?.("[t-team] 宿主的 skills 服务换了新实例,而技能开关仍作用在旧实例上:"
289
+ + "此后新装/重载的技能可能不受停用名单约束。重载 T专家 插件(或重启宿主)即可恢复;"
290
+ + "专家名册与召唤不受影响。");
291
+ });
292
+ } else {
293
+ ctx.logger?.warn?.("[t-team] 上下文没有 inject:技能开关不可用(专家名册不受影响)。");
294
+ }
295
+
266
296
 
267
297
 
268
298
 
@@ -303,7 +333,7 @@ export function apply(ctx, config) {
303
333
  if (typeof settingsService?.register === "function") {
304
334
  // ── 旧宿主(≤0.1.6):按命名空间注册,读写都走返回的句柄。
305
335
  scope = settingsService.register(SETTINGS_NAMESPACE, settingsSchema, {
306
- base: { enabled: [], enabledSeeded: false, enabledSeedVersion: 0, showActivePulse: true },
336
+ base: { enabled: [], enabledSeeded: false, enabledSeedVersion: 0 },
307
337
  applies: "live",
308
338
  validate: (value) => {
309
339
  const list = value?.enabled;
@@ -329,15 +359,13 @@ export function apply(ctx, config) {
329
359
  "t-team: 设置页策略",
330
360
  );
331
361
  }
332
- if (typeof ctx.on === "function") {
333
- }
334
362
  return;
335
363
  }
336
364
  ctx.logger?.warn?.("[t-team] settings 服务既没有 register 也没有 mutate:专家启停不可用(名册与召唤不受影响)。");
337
365
  });
338
366
  }
339
367
  /**
340
- * 读偏好段(专家启停 + 界面开关)。
368
+ * 读偏好段(专家启停)。
341
369
  *
342
370
  * 来源严格按 `prefsMode` 二选一(理由见 prefsMode 注释);0.1.7 的 Config 字段是
343
371
  * volatile 引用,统一 unwrap 后再用。
@@ -351,7 +379,6 @@ export function apply(ctx, config) {
351
379
  enabled: unwrapVolatile(source.enabled),
352
380
  enabledSeeded: unwrapVolatile(source.enabledSeeded),
353
381
  enabledSeedVersion: unwrapVolatile(source.enabledSeedVersion),
354
- showActivePulse: unwrapVolatile(source.showActivePulse),
355
382
  };
356
383
  }
357
384
  return undefined;
@@ -872,53 +899,10 @@ export function apply(ctx, config) {
872
899
  }
873
900
  }
874
901
 
875
- /**
876
- * 读界面偏好(定时事项按钮 + 活跃指示)。
877
- * 读当前值统一走 `readPrefs()`(与 enabledSet() 同一来源):旧宿主是 settings 命名空间句柄,
878
- * 0.1.7+ 是本 entry Config 的 volatile 字段。
879
- * 直接调 settingsService.get().value 在本机版本的设置服务上不成立(读到 undefined 抛错)。
880
- *
881
- * 两个键都**必有**且缺省为 true:设置文件里没有这个键时保持与默认一致,
882
- * 客户端于是不必到处兜 undefined(wire schema 也是这么声明的)。
883
- */
884
- function getUiPrefs() {
885
- const current = readPrefs();
886
- return {
887
- showActivePulse: current?.showActivePulse !== false,
888
- };
889
- }
890
-
891
- /** 只取 patch 里**确实是 boolean** 的键(其余一律忽略:坏值不该被写成"关")。 */
892
- function normalizePrefsPatch(patch) {
893
- const source = patch !== null && typeof patch === "object" ? patch : {};
894
- return {
895
- ...(typeof source.showActivePulse === "boolean" ? { showActivePulse: source.showActivePulse } : {}),
896
- };
897
- }
898
-
899
- /**
900
- * 写界面偏好(局部更新,不碰 enabled)。
901
- *
902
- * 写成功后再把「定时事项」那一位落到调度上:面板开关与后台定时器必须是同一个事实,
903
- * 否则用户关了开关、cron 还在后台开新会话。
904
- */
905
- async function setUiPrefs(patch) {
906
- const next = { ...getUiPrefs(), ...normalizePrefsPatch(patch) };
907
- await settingsService.mutate(
908
- SETTINGS_NAMESPACE,
909
- [
910
- { op: "set", path: ["showActivePulse"], value: next.showActivePulse },
911
- ],
912
- revision(),
913
- );
914
- return getUiPrefs();
915
- }
916
-
917
902
  const catalogService = {
918
903
  snapshot, prompt, setEnabled, root: config.root,
919
904
  createExpert, updateExpert, deleteExpert,
920
905
  createCategory, updateCategory, deleteCategory,
921
- getUiPrefs, setUiPrefs,
922
906
  };
923
907
  ctx.reflect.provide(CATALOG_SERVICE, catalogService);
924
908
 
@@ -980,23 +964,6 @@ export function apply(ctx, config) {
980
964
  }
981
965
  }
982
966
 
983
- /** 预检报告的中文渲染。 */
984
- function renderPlanReport(value) {
985
- const lines = [value.ok === true ? "预检通过:这套任务可以按顺序创建。" : `预检未通过:${value.problems.length} 个问题。`];
986
- for (const problem of value.problems ?? []) {
987
- const where = problem.id === undefined ? "" : `[${problem.id}] `;
988
- lines.push(`- ✗ ${where}${problem.subject === "" ? "" : `${problem.subject}:`}${problem.error}`);
989
- }
990
- if ((value.warnings ?? []).length > 0) {
991
- lines.push("提示(不阻塞):");
992
- for (const warning of value.warnings) lines.push(`- ! ${warning}`);
993
- }
994
- if ((value.order ?? []).length > 0) {
995
- lines.push(`建议创建/执行顺序:${value.order.join(" → ")}`);
996
- }
997
- return lines.join("\n");
998
- }
999
-
1000
967
  // ---- 工具 ----
1001
968
  ctx.tools.register(defineTool({
1002
969
  name: "list_t_experts",
@@ -55,6 +55,22 @@ export const catalogSnapshotSchema = z.object({
55
55
  // 漏声明时面板拿到的快照里根本没有它们,界面上表现为"分区标签是空的"。
56
56
  customDivision: z.string().optional(),
57
57
  customDivisionLabel: z.string().optional(),
58
+ /**
59
+ * 名册健康状态。
60
+ *
61
+ * ⚠️ 这个字段在 0.4.6 之前**一直没声明**:host 侧早就在快照里返回它、注释也写着
62
+ * 「面板可以据此提示『中文侧车没找到 / 有 N 个专家文件被跳过』」,但 zod 会把它剥掉 ——
63
+ * 面板永远收不到。于是"中文侧车没找到"这种会让整个名册**静默变全英文**的状况,
64
+ * 用户在界面上看不到任何线索,只能对着一片英文名猜。
65
+ * 与上面 `customDivision` 那两个字段是同一个坑(那里的注释已经警告过"漏声明会被剥掉"),
66
+ * 只是这次踩在一个嵌套对象上,更不容易被发现。
67
+ */
68
+ sidecar: z.object({
69
+ zhRoot: z.string(),
70
+ present: z.boolean(),
71
+ skippedFiles: z.number().int().min(0),
72
+ unreadableDivisions: z.array(z.string()),
73
+ }).optional(),
58
74
  /** 可选的落点分类(面板「分类」下拉与管理页):官方在前,其余按目录/声明发现。 */
59
75
  categories: z.array(z.object({
60
76
  key: z.string(),
@@ -70,20 +86,4 @@ export const enabledStateSchema = z.object({
70
86
  revision: z.number().int().min(0),
71
87
  });
72
88
 
73
- /**
74
- * 界面偏好(宿主持久化,客户端只读展示 + 写入)。
75
- *
76
- * 两个键都是 `boolean` 且**必有**:host 侧读取时缺省一律补 true,客户端因此不必到处兜 undefined。
77
- * 加新键要同时改 host(lib/index.js 的 settingsSchema 与 getUiPrefs)与客户端消费处。
78
- */
79
- export const uiPrefsSchema = z.object({
80
- // 窗口右上角「活跃指示」是否显示。宿主**总是**返回布尔(缺省按 true 算),所以这里必填。
81
- showActivePulse: z.boolean(),
82
- });
83
-
84
- /** `setUiPrefs` 的入参:想改哪个传哪个(未传的键保持原值)。 */
85
- export const uiPrefsPatchSchema = z.object({
86
- showActivePulse: z.boolean().optional(),
87
- });
88
-
89
89
  export const promptSchema = z.object({ prompt: z.string() });
package/lib/remote.js CHANGED
@@ -28,9 +28,9 @@ import {
28
28
  enabledStateSchema,
29
29
  expertSchema,
30
30
  promptSchema,
31
- uiPrefsPatchSchema,
32
- uiPrefsSchema,
33
31
  } from "./remote-schemas.js";
32
+ import { snapshotSchema as skillGateSnapshotSchema } from "./skill-gate/remote-schemas.js";
33
+ import { GATE_SERVICE } from "./skill-gate/constants.js";
34
34
 
35
35
 
36
36
  /** 一个 direct 调用描述符。 */
@@ -48,17 +48,27 @@ function descriptor(method, parameters, typeSymbol, schema) {
48
48
 
49
49
  const DESCRIPTORS = [
50
50
  descriptor("getCatalog", [], "TTeamCatalog", catalogSnapshotSchema),
51
- descriptor("getUiPrefs", [], "TTeamUiPrefs", uiPrefsSchema),
52
- // 入参从「单个 boolean」改成 patch 对象:界面偏好不止一项,
53
- // 加键不必再加方法(未传的键保持原值,host 侧做兜底)。
54
- descriptor("setUiPrefs", [
51
+ // ---- 技能开关(2026-09-28 从 dsh-plugin-skill-gate 并入)----
52
+ // ⚠️ 它们挂在**本插件已有的 tTeam 命名空间**下,没有单开第二个 remote 入口。
53
+ // 起因:合并时先做成独立入口(`dsh-plugin-t-expert/skill-gate-remote` + `skillGate` 命名空间),
54
+ // 但同一个包再挂一份 remote 在实机上没能生效(客户端 `ctx.get("remote.skillGate")` 始终是
55
+ // undefined,面板显示「服务不可用」)。改挂到已被证明可用的 tTeam 通路上,确定性更高、
56
+ // 也少一个入口要维护。方法名沿用原插件的 getSnapshot / setDisabled。
57
+ descriptor("getSnapshot", [], "SkillGateSnapshot", skillGateSnapshotSchema),
58
+ descriptor("setDisabled", [
55
59
  {
56
- name: "patch",
57
- wire: "patch",
60
+ name: "disabled",
61
+ wire: "disabled",
58
62
  source: "json",
59
- codec: { mode: "strict", typeSymbol: "TTeamUiPrefsPatch", schema: uiPrefsPatchSchema, create: () => uiPrefsPatchSchema },
63
+ codec: { mode: "strict", typeSymbol: "string[]", schema: z.array(z.string().max(128)).max(4000), create: () => z.array(z.string().max(128)).max(4000) },
60
64
  },
61
- ], "TTeamUiPrefs", uiPrefsSchema),
65
+ {
66
+ name: "expectedRevision",
67
+ wire: "expectedRevision",
68
+ source: "json",
69
+ codec: { mode: "strict", typeSymbol: "number", schema: z.number().int().min(0), create: () => z.number().int().min(0) },
70
+ },
71
+ ], "SkillGateSnapshot", skillGateSnapshotSchema),
62
72
  descriptor("setEnabled", [
63
73
  {
64
74
  name: "enabled",
@@ -200,7 +210,11 @@ function exposeRemoteMethods(Class, methods) {
200
210
  }
201
211
 
202
212
  class TTeamRemote extends TypertRemoteService {
203
- static inject = ["settings", "typert"];
213
+ // ⚠️ `settings` 此前在 inject 里但**未被读过**:cordis 要等 inject 里每一个服务就绪才实例化
214
+ // 这个类(见 cordis 的 inject 实现),宿主 settings 一旦晚到,定时任务面板就跟着报
215
+ // "服务暂时不可用",排查的人却被引去查 typert 与调度引擎 —— 因为这才是表面报错处。
216
+ // [8c] 现在盯着:`static inject` 不能含任何未被读取的字段。
217
+ static inject = ["typert"];
204
218
 
205
219
  constructor(ctx) {
206
220
  super(ctx, "tTeam");
@@ -234,30 +248,36 @@ class TTeamRemote extends TypertRemoteService {
234
248
  return service;
235
249
  }
236
250
 
237
- /** 动态名册:完整专家列表 + 已启用 slug + 修订号。 */
238
- async getCatalog() {
251
+ /** 技能开关:完整快照(技能清单 + 计数 + 视角)。 */
252
+ async getSnapshot() {
239
253
  try {
240
- return await this.catalog().snapshot();
254
+ return await skillGate.call(this).snapshot();
241
255
  } catch (error) {
242
- // 服务缺失已在 catalog() 里铸成 tTeam/host-unavailable;这里只兜业务失败。
243
- throw this.businessError(error, "tTeam/catalog-unreadable");
256
+ throw this.businessError(error, "tTeam/skill-gate-unreadable");
244
257
  }
245
258
  }
246
259
 
247
- /** 整体替换启用列表;过期修订号由 settings 服务拒绝。 */
248
- async getUiPrefs() {
260
+ /** 技能开关:整份替换停用名单(带乐观锁的修订号)。 */
261
+ async setDisabled(disabled, expectedRevision) {
262
+ const service = skillGate.call(this);
263
+ if (service.getRevision() !== expectedRevision) {
264
+ // @ts-expect-error 上游类型缺口:同上,tTeam/* 业务码不在闭合的 RemoteErrorDetailsMap 里。
265
+ throw new RemoteError("tTeam/conflict", "设置已被其他窗口修改,请重试。", { expectedRevision: String(expectedRevision) });
266
+ }
249
267
  try {
250
- return await this.catalog().getUiPrefs();
268
+ return await service.setDisabled(disabled);
251
269
  } catch (error) {
252
- throw this.businessError(error, "tTeam/ui-prefs-unreadable");
270
+ throw this.businessError(error, "tTeam/skill-gate-unwritable");
253
271
  }
254
272
  }
255
273
 
256
- async setUiPrefs(patch) {
274
+ /** 动态名册:完整专家列表 + 已启用 slug + 修订号。 */
275
+ async getCatalog() {
257
276
  try {
258
- return await this.catalog().setUiPrefs(patch);
277
+ return await this.catalog().snapshot();
259
278
  } catch (error) {
260
- throw this.businessError(error, "tTeam/ui-prefs-unwritable");
279
+ // 服务缺失已在 catalog() 里铸成 tTeam/host-unavailable;这里只兜业务失败。
280
+ throw this.businessError(error, "tTeam/catalog-unreadable");
261
281
  }
262
282
  }
263
283
 
@@ -307,13 +327,24 @@ class TTeamRemote extends TypertRemoteService {
307
327
  * `error.code` 只有**形如 `<domain>/<reason>`** 才能当 wire code 用:系统 errno
308
328
  * (`EACCES`/`ENOENT`/`EPERM`…)过线后被客户端按 code 分支时会误当领域码(D-17),
309
329
  * 所以形态不符一律回落 `fallbackCode`。
310
- * @param error - 被捕获的原错误。
311
- * @param fallbackCode - 该访问器的领域码。
330
+ * @param {unknown} error - 被捕获的原错误。
331
+ * @param {string} fallbackCode - 该访问器的领域码。
332
+ *
333
+ * ⚠️ 这两个类型标注不能省:`error` 缺标注时它是 `any`,`candidate` 跟着变 `any`,
334
+ * 于是下面 `new RemoteError(...)` 的类型检查整个失效(TS 对 any 一律放行)——
335
+ * 表现是这里**本该有**的那道「上游类型缺口」抑制指令根本不需要存在,
336
+ * 而一旦谁补上标注,tsc 就会立刻报它 unused。辅助补丁同一个方法踩过这个坑(0.13.0 修)。
312
337
  */
313
338
  businessError(error, fallbackCode) {
314
339
  if (error instanceof RemoteError) return error;
315
340
  const message = error instanceof Error ? error.message : String(error);
316
- const candidate = typeof error === "object" && error !== null && typeof error.code === "string" ? error.code : undefined;
341
+ // `unknown` 经 `typeof === "object"` 收窄后在 TS 眼里仍是 `object`(上面没有 code),
342
+ // 所以显式当成「可能带 code 的未知对象」来读 —— 比把参数标成 any 好,any 会往下传染。
343
+ const maybeCode = /** @type {{ code?: unknown }} */ (
344
+ typeof error === "object" && error !== null ? error : {}
345
+ ).code;
346
+ const candidate = typeof maybeCode === "string" ? maybeCode : undefined;
347
+ // @ts-expect-error 上游类型缺口:同上,tTeam/* 业务码不在闭合的 RemoteErrorDetailsMap 里。
317
348
  return new RemoteError(DOMAIN_CODE_PATTERN.test(candidate ?? "") ? candidate : fallbackCode, message, {});
318
349
  }
319
350
 
@@ -387,9 +418,22 @@ class TTeamRemote extends TypertRemoteService {
387
418
  }
388
419
  }
389
420
 
421
+ /**
422
+ * host 侧的技能开关服务(由 lib/skill-gate/install.js 在 skills 就绪后 provide)。
423
+ * 与 catalog() 同样的口径:服务缺失给领域错误,不裸抛。
424
+ */
425
+ function skillGate() {
426
+ const service = this.ctx.get(GATE_SERVICE);
427
+ if (service === undefined) {
428
+ throw this.hostUnavailable(GATE_SERVICE, "技能开关服务不可用(宿主没挂上 skills 服务,或技能开关装配失败)");
429
+ }
430
+ return service;
431
+ }
432
+
390
433
  exposeRemoteMethods(TTeamRemote, [
391
434
  "getCatalog", "setEnabled", "getPrompt",
392
- "getUiPrefs", "setUiPrefs",
435
+ // 技能开关(原 dsh-plugin-skill-gate 的两个方法,2026-09-28 并入)
436
+ "getSnapshot", "setDisabled",
393
437
  "createExpert", "updateExpert", "deleteExpert",
394
438
  "createCategory", "updateCategory", "deleteCategory",
395
439
  ]);
@@ -0,0 +1,4 @@
1
+ // @ts-check
2
+ export const name = "skill-gate";
3
+ /** host 内部服务名;wire 命名空间是 remote 插件的 `skillGate`,两者不能撞。 */
4
+ export const GATE_SERVICE = "skillGateHost";
@@ -0,0 +1,139 @@
1
+ // @ts-check
2
+ /**
3
+ * 扫描默认技能根(~/.dsh/skills、~/.agents/skills)。
4
+ *
5
+ * 设置页不能只靠 `ctx.skills.snapshot({})`:web 上 skill-filesystem 挂在 preset 层,
6
+ * 不带 scope 时全局层往往只剩宿主 runtime 注册的那几条。
7
+ */
8
+ import { readdir, readFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { isSkillName } from "./overlay.js";
12
+
13
+ /** @param {string} [configuredHome] */
14
+ export function defaultSkillRoots(configuredHome) {
15
+ const dshHome = typeof configuredHome === "string" && configuredHome.trim() !== ""
16
+ ? configuredHome.trim()
17
+ : (process.env.DSH_HOME ?? join(homedir(), ".dsh"));
18
+ const agentsHome = process.env.DSH_AGENTS_HOME ?? join(homedir(), ".agents");
19
+ return [
20
+ { path: join(dshHome, "skills"), source: "user-dsh" },
21
+ { path: join(agentsHome, "skills"), source: "user-agents" },
22
+ ];
23
+ }
24
+
25
+ /**
26
+ * @param {Iterable<{ path: string, source: string }>} roots
27
+ * @returns {Promise<{ name: string, description: string, source: string, provider: string, invocation: { modelInvocable: boolean, userInvocable: boolean } }[]>}
28
+ */
29
+ export async function scanSkillRoots(roots) {
30
+ /** @type {Map<string, { name: string, description: string, source: string, provider: string, invocation: { modelInvocable: boolean, userInvocable: boolean } }>} */
31
+ const found = new Map();
32
+ for (const root of roots) {
33
+ let entries;
34
+ try {
35
+ entries = await readdir(root.path, { withFileTypes: true });
36
+ } catch (error) {
37
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
38
+ throw error;
39
+ }
40
+ for (const entry of entries) {
41
+ if (entry.name === ".system" || entry.name.startsWith(".")) continue;
42
+ const full = join(root.path, entry.name);
43
+ const file = entry.isDirectory()
44
+ ? join(full, "SKILL.md")
45
+ : (entry.isFile() && entry.name.endsWith(".md") ? full : "");
46
+ if (file === "") continue;
47
+ const parsed = await readSkillSummary(file, entry.isDirectory() ? entry.name : entry.name.replace(/\.md$/u, ""));
48
+ if (parsed === undefined) continue;
49
+ if (!found.has(parsed.name)) {
50
+ found.set(parsed.name, {
51
+ ...parsed,
52
+ source: root.source,
53
+ provider: "filesystem",
54
+ });
55
+ }
56
+ }
57
+ }
58
+ return [...found.values()];
59
+ }
60
+
61
+ /**
62
+ * @param {Iterable<Iterable<{ name?: unknown } | undefined> | undefined | null>} lists
63
+ * @returns {object[]}
64
+ */
65
+ export function mergeSkillLists(lists) {
66
+ /** @type {Map<string, object>} */
67
+ const byName = new Map();
68
+ for (const list of lists) {
69
+ if (list == null) continue;
70
+ for (const skill of list) {
71
+ if (skill === undefined || skill === null || typeof skill.name !== "string") continue;
72
+ if (!byName.has(skill.name)) byName.set(skill.name, skill);
73
+ }
74
+ }
75
+ return [...byName.values()];
76
+ }
77
+
78
+ /**
79
+ * @param {string} path
80
+ * @param {string} fallbackName
81
+ */
82
+ async function readSkillSummary(path, fallbackName) {
83
+ let raw;
84
+ try {
85
+ raw = await readFile(path, "utf8");
86
+ } catch (error) {
87
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined;
88
+ throw error;
89
+ }
90
+ const fields = parseFrontmatterScalars(raw);
91
+ const name = isSkillName(fields.name ?? "") ? fields.name : (isSkillName(fallbackName) ? fallbackName : "");
92
+ if (name === "") return undefined;
93
+ const nativeOff = truthy(fields["disable-model-invocation"]);
94
+ return {
95
+ name,
96
+ description: fields.description ?? "",
97
+ invocation: {
98
+ modelInvocable: !nativeOff,
99
+ userInvocable: !falsey(fields["user-invocable"]),
100
+ },
101
+ };
102
+ }
103
+
104
+ /** @param {string} raw */
105
+ export function parseFrontmatterScalars(raw) {
106
+ const block = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw);
107
+ if (block === null) return {};
108
+ /** @type {Record<string, string>} */
109
+ const fields = {};
110
+ for (const line of block[1].split(/\r?\n/u)) {
111
+ const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line.trim());
112
+ if (match === null) continue;
113
+ fields[match[1]] = stripScalar(match[2]);
114
+ }
115
+ return fields;
116
+ }
117
+
118
+ /** @param {string} value */
119
+ function stripScalar(value) {
120
+ const trimmed = value.trim();
121
+ if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
122
+ return trimmed.slice(1, -1);
123
+ }
124
+ return trimmed;
125
+ }
126
+
127
+ /** @param {string | undefined} value */
128
+ function truthy(value) {
129
+ if (value === undefined) return false;
130
+ const lowered = value.trim().toLowerCase();
131
+ return lowered === "true" || lowered === "yes" || lowered === "1";
132
+ }
133
+
134
+ /** @param {string | undefined} value */
135
+ function falsey(value) {
136
+ if (value === undefined) return false;
137
+ const lowered = value.trim().toLowerCase();
138
+ return lowered === "false" || lowered === "no" || lowered === "0";
139
+ }