dsh-skill-tag 0.1.17 → 0.1.19

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.
@@ -181,6 +181,20 @@ tagging-skills 那 17 个长期漏网的原因(详见 `deny-shadow-覆盖率.m
181
181
  再发 —— 一个不合法候选会让**整个** `provider.list()` 抛错,宿主随即丢弃本插件的
182
182
  全部贡献(日志 `skill provider "skill-tag" skipped`),比漏一个更糟。
183
183
 
184
+ **两段都要校验(2026-11-12,issue #6)**。上面这条当时只堵了「`denied` 里看不见
185
+ 的名字」那一段,**看得见的**那一段(照抄上游 summary)一直没校验,于是扫盘带进来的
186
+ 脏名字能原样发出去。触发链:`src/skill-scan.js` 把技能根下裸放的 `README-官方技能说明.md`
187
+ 当成技能(名字取文件名去 `.md`)→ 进 universe → `denied = universe − visible` 里也有它 →
188
+ 第一段照抄发射 → 宿主 `validateCandidate` 抛错 → **所有对话**每轮都失败,报
189
+ `returned invalid skill name`。
190
+
191
+ 现在两层纵深防御:
192
+
193
+ - `src/skill-scan.js`:根级裸放 `.md` **必须有 frontmatter** 才算技能,名字非 kebab-case
194
+ 一律跳过 —— 与宿主 `discoverRoot` / `parseSkillFile` 同口径(宿主对同样的文件打的是
195
+ `skill file ... ignored: invalid skill name`)。
196
+ - `lib/index.js`:第一段与第二段都过 `isSkillName()`,兜住任何别的来源漏进来的脏名字。
197
+
184
198
  ## 5. 约束(踩过的坑)
185
199
 
186
200
  - **cordis 服务必须声明式注入**:`export const inject` 里没有的服务,读 `ctx.xxx`
package/lib/index.js CHANGED
@@ -31,7 +31,6 @@ const PROVIDER_NAME = "skill-tag";
31
31
  /** 影子候选的 source(宿主要求是字符串,且只用于展示/排序以外的标识)。 */
32
32
  const PROVIDER_SOURCE = "skill-tag";
33
33
 
34
- const ON = { modelInvocable: true, userInvocable: true };
35
34
  const DENY = { modelInvocable: false, userInvocable: false };
36
35
 
37
36
  /**
@@ -101,8 +100,15 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
101
100
  // 还是上游那份,影子只换掉 invocation 与 provider,右列能看出"它被屏蔽了"。
102
101
  const shadows = [];
103
102
  const emitted = new Set();
103
+ const invalid = new Set();
104
104
  for (const summary of summaries) {
105
105
  if (!summary || !denied.has(summary.name)) continue;
106
+ // 看得见 ≠ 合法(2026-11-12,issue #6):扫盘会把技能根下裸放的
107
+ // `README-官方技能说明.md` 这类文件当成技能,名字取文件名去 `.md`。
108
+ // 旧实现只在第二段校验,这一段整条照抄 → 非法名字原样发出去 →
109
+ // 宿主 validateCandidate 抛错 → 整个 provider 作废 → **所有对话**
110
+ // 报 "returned invalid skill name" 且每轮都失败。两段都必须校验。
111
+ if (!isSkillName(summary.name)) { invalid.add(summary.name); continue; }
106
112
  shadows.push({
107
113
  ...summary,
108
114
  provider: PROVIDER_NAME,
@@ -123,10 +129,9 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
123
129
  // 修法:屏蔽是**按名字**生效的,不需要知道那名字长什么样 —— 看不见的
124
130
  // 名字直接造一条最小合法候选,就能在近层整条覆盖上游那份。
125
131
  let backfilled = 0;
126
- const invalid = [];
127
132
  for (const name of denied) {
128
133
  if (emitted.has(name)) continue;
129
- if (!isSkillName(name)) { invalid.push(name); continue; }
134
+ if (!isSkillName(name)) { invalid.add(name); continue; }
130
135
  shadows.push({
131
136
  name,
132
137
  description: `[blocked by skill-tag] ${name}`,
@@ -148,8 +153,8 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
148
153
  + ` shadowed=${emitted.size} backfilled=${backfilled}`
149
154
  + `${upstream.lastDegraded === true ? " hostSnapshot=skipped(nested)" : ""}`);
150
155
  }
151
- if (invalid.length > 0) {
152
- logWarn(`[skill-tag] denied names skipped (not kebab-case): ${invalid.slice(0, 8).join(", ")}`);
156
+ if (invalid.size > 0) {
157
+ logWarn(`[skill-tag] names skipped (not kebab-case): ${[...invalid].slice(0, 8).join(", ")}`);
153
158
  }
154
159
  } catch { /* 观测不得影响主路径 */ }
155
160
  }
@@ -553,6 +558,22 @@ export function getWorkspaceRegistry(ctx) {
553
558
  return null;
554
559
  }
555
560
 
561
+ /**
562
+ * 安全取宿主 connection 服务(Host/Origin 与浏览器鉴权)。
563
+ * 与 getWorkspaceRegistry 同理:走 ctx.get 探测,不写进 inject,缺席时返回 null。
564
+ * @param {object} ctx cordis context
565
+ * @returns {object|null}
566
+ */
567
+ function getConnection(ctx) {
568
+ try {
569
+ if (typeof ctx?.get === "function") {
570
+ const svc = ctx.get("connection");
571
+ if (svc) return svc;
572
+ }
573
+ } catch { /* 未装载 */ }
574
+ return null;
575
+ }
576
+
556
577
  /**
557
578
  * 会话 → 工作区 id 的解析(宿主 workspaceRegistry 是唯一权威来源)。
558
579
  *
@@ -596,8 +617,6 @@ export function workspaceIdOfPath(registry, cwd) {
596
617
 
597
618
  // ── Wiring ───────────────────────────────────────────────────────────────────
598
619
 
599
- const controls = new Set();
600
-
601
620
  export const name = "dsh-skill-tag";
602
621
 
603
622
  export const inject = ["skills", "agents", "storageDomain", "webServer"];
@@ -612,17 +631,44 @@ export async function apply(ctx, config) {
612
631
  const enabled = config?.enabled !== false;
613
632
 
614
633
  // 1. Persistence: open our storage domain; close on dispose (§12).
634
+ //
635
+ // 关闭必须排在在飞的刷新之后。Cordis 的 fiber 卸载是**兄弟 disposer 并发**
636
+ // (vendor/cordis fiber.ts:720 `Promise.all(this._disposables...)`),所以如果
637
+ // domain.close() 和仍在跑的 refreshSession 同时执行,后者会撞上
638
+ // DomainError("closed")。做法:一个共享的 inflight 登记表 + 先停接收新工作、
639
+ // 再等已接受的结算、最后才 close。
640
+ const inflight = new Set();
641
+ let shuttingDown = false;
642
+
643
+ /** 登记一个在飞的异步任务,dispose 时会等它结算。 */
644
+ function track(promise) {
645
+ const p = Promise.resolve(promise).catch(() => {}).finally(() => inflight.delete(p));
646
+ inflight.add(p);
647
+ return p;
648
+ }
649
+
650
+ //
651
+ // storageDomain 在 inject 里声明为强依赖,但这里仍保留缺席兜底:单测与
652
+ // 极简 cordis 面(runtime.test.mjs)会直接构造不带该服务的 ctx 调 apply,
653
+ // 兜底保证"缺可选服务不崩溃"这条既有契约不破。真宿主里 inject 会先挡住
654
+ // 激活,这条分支不会走到,但它守的是另一条约束。
615
655
  let repo;
616
656
  if (ctx.storageDomain) {
617
657
  const domain = await ctx.storageDomain.open(skillTagDomain);
618
658
  // cordis effect semantics (cordis lib/index.js:1142): the thunk runs NOW and
619
659
  // its RETURNED function becomes the dispose hook — so return the closer,
620
660
  // never call it here. `() => domain.close()` would close the domain at setup.
621
- ctx.effect(() => () => domain.close());
661
+ //
662
+ // 顺序:先置 shuttingDown(拒绝新的刷新)→ 等在飞的结算 → 再关 domain。
663
+ // 这样消费者(读路径、HTTP handler)始终跑在还活着的 domain 上。
664
+ ctx.effect(() => () => {
665
+ shuttingDown = true;
666
+ return Promise.allSettled([...inflight]).then(() => domain.close());
667
+ });
622
668
  repo = createDshRepository(domain);
623
669
  ctx.logger?.info?.("[skill-tag] storage domain 'skill_tag' opened");
624
670
  } else {
625
- // host planes without storageDomain (unit tests, non-persistent runs)
671
+ // 测试面 / 无存储宿主:退回内存仓库,行为可跑但不持久化。
626
672
  const { createMemoryRepository } = await import("../src/storage/repository.js");
627
673
  repo = createMemoryRepository();
628
674
  ctx.logger?.warn?.("[skill-tag] storageDomain service missing — running in-memory");
@@ -643,6 +689,14 @@ export async function apply(ctx, config) {
643
689
  // 3. Session→denied-skill-names live map. Denials derive from the boolean
644
690
  // config + per-conversation four-zone state (§11). Recomputed on demand.
645
691
  const sessionDenials = new Map(); // sessionId -> Set<string>
692
+
693
+ // 每个 agent 注册 provider 时拿到的 control,用于配置变更后 invalidate。
694
+ //
695
+ // 这张表**不能**放模块顶层:模块级 Set 的身份来自模块加载而非 fiber,
696
+ // 插件重载后新旧实例共用同一份,且 agent 销毁时旧 control 留在里面,
697
+ // 之后每次 recomputeDenials 都要对已释放的对象白调一次 invalidate()。
698
+ // 放这里 + 按 agent 删除,生命周期才跟 apply 一致。
699
+ const controls = new Set();
646
700
  function deniedFor(sessionId) {
647
701
  let set = sessionDenials.get(sessionId);
648
702
  if (!set) { set = new Set(); sessionDenials.set(sessionId, set); }
@@ -701,6 +755,7 @@ export async function apply(ctx, config) {
701
755
  const refreshing = new Set();
702
756
  async function refreshBySession(sessionId, cwd, scope = null) {
703
757
  if (!sessionId) return;
758
+ if (shuttingDown) return; // 卸载中:不要再动即将关闭的 domain
704
759
  if (refreshing.has(sessionId)) return;
705
760
  refreshing.add(sessionId);
706
761
  try {
@@ -846,11 +901,14 @@ export async function apply(ctx, config) {
846
901
  const denied = deniedFor(sessionId);
847
902
  // agent 侧的 cwd 兜底(provider options.cwd 优先,见 createFilterProvider)
848
903
  const agentCwd = agent.session?.header?.cwd ?? agent.session?.cwd;
849
- ctx.effect(() =>
850
- agent.ctx.effect(() =>
904
+ // 每个 agent 持有自己那份 control,便于销毁时精确摘除。
905
+ let ownControl = null;
906
+ ctx.effect(() => {
907
+ const outerDispose = agent.ctx.effect(() =>
851
908
  skills.registerProvider((control) => {
852
909
  try {
853
910
  controls.add(control);
911
+ ownControl = control;
854
912
  } catch { /* 控制面已释放 */ }
855
913
  return createFilterProvider(sessionId, upstream, denied, async (options) => {
856
914
  // 读路径上按需刷新:新对话的第一次 list() 也必须已过滤,
@@ -861,8 +919,14 @@ export async function apply(ctx, config) {
861
919
  await refreshBySession(sessionId, options?.cwd ?? agentCwd, agent);
862
920
  }, (message) => ctx.logger?.warn?.(message));
863
921
  }),
864
- ),
865
- );
922
+ );
923
+ // 清理顺序:先摘 control(不再对它 invalidate),再撤 provider。
924
+ return () => {
925
+ if (ownControl !== null) controls.delete(ownControl);
926
+ ownControl = null;
927
+ return outerDispose();
928
+ };
929
+ });
866
930
  ctx.logger?.info?.(`[skill-tag] attached per-agent provider, session=${sessionId}`);
867
931
  } catch (error) {
868
932
  ctx.logger?.error?.("[skill-tag] agent provider attach failed", error);
@@ -895,6 +959,13 @@ export async function apply(ctx, config) {
895
959
 
896
960
  ctx.effect(() => () => { controls.clear(); sessionDenials.clear(); });
897
961
 
962
+ // 会话销毁即回收该会话的拒绝集。缺这条时 Map 只增不减:每开一个对话就多
963
+ // 一条 Set,关掉也不删,长跑进程按会话数无界增长。
964
+ ctx.on("session/disposed", (session) => {
965
+ const sessionId = sessionIdOfAgent({ session }) ?? session?.id;
966
+ if (sessionId) sessionDenials.delete(sessionId);
967
+ });
968
+
898
969
  // 4b. 把「给技能打标签」这份手册注册成一个真实的 skill。
899
970
  // 别人装上本插件后,他们的 agent 在 /skills 里就能看到 `tagging-skills`,
900
971
  // 从而照着它给自己的技能打标签 —— 这是 m05073 要求的「可移植」落点:
@@ -941,9 +1012,13 @@ export async function apply(ctx, config) {
941
1012
  ctx.on("domain/changed", (change) => {
942
1013
  try {
943
1014
  if (change?.domain !== "skill_tag") return;
1015
+ // 卸载中不再接新刷新:这些刷新会去读写正在关闭的 domain。
1016
+ if (shuttingDown) return;
944
1017
  for (const agent of ctx.get("agents")?.list?.() ?? []) {
945
- // refreshSession 自己已 async + try/catch,这里再包一层防同步抛出
946
- try { void refreshSession(agent); } catch { /* ignore */ }
1018
+ // refreshSession 自己已 async + try/catch,这里再包一层防同步抛出。
1019
+ // 用 track() 登记:原先的 `void refreshSession(agent)` 没有 owner,
1020
+ // 卸载时既不等它结算也不取消它。
1021
+ try { track(refreshSession(agent)); } catch { /* ignore */ }
947
1022
  }
948
1023
  } catch (error) {
949
1024
  try {
@@ -953,8 +1028,9 @@ export async function apply(ctx, config) {
953
1028
  });
954
1029
 
955
1030
  // 6. Settings-page API (host half of the UI; client half in lib/client.js).
956
- // Guarded: test planes may lack webServer.
957
- if (ctx.webServer && typeof ctx.webServer.register === "function") {
1031
+ // 与 storageDomain 同理:webServer 虽是 inject 里的强依赖,测试面会构造
1032
+ // 不带它的 ctx,所以保留缺席守卫,保证 apply 在最小面上不抛。
1033
+ if (typeof ctx.webServer?.register === "function") {
958
1034
  // listSkills(cwd): 技能发现。设置页没有 cwd → 只扫用户级根目录;
959
1035
  // 「管理标签」浮层知道自己在哪个工作区/对话,于是传 cwd 把项目级技能也扫进来。
960
1036
  mountApi(
@@ -993,9 +1069,13 @@ export async function apply(ctx, config) {
993
1069
  const ids = entity?.sessionIds;
994
1070
  return Array.isArray(ids) ? [...ids] : [];
995
1071
  },
1072
+ // Host/Origin + 浏览器鉴权。webServer 只做路由匹配、不查来源,
1073
+ // 不传它就等于任意外部页面都能跨源调用这些写端点。
1074
+ getConnection(ctx),
996
1075
  );
997
1076
  } else {
998
- ctx.logger?.warn?.("[skill-tag] webServer missing — settings API not mounted");
1077
+ // 强依赖缺席只可能是测试面(真宿主里 inject 会挡住激活)。
1078
+ ctx.logger?.warn?.("[skill-tag] webServer.register unavailable — settings API not mounted");
999
1079
  }
1000
1080
 
1001
1081
  ctx.logger?.info?.("[skill-tag] apply() complete", {
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Skill tags",
4
+ "description": "Tags DSH skills and uses one boolean expression to pick, per workspace and per conversation, which subset of skills is handed to the model."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "技能标签",
4
+ "description": "给 DSH 的技能打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-tag",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "给 DSH 的技能(skill)打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,13 +23,15 @@
23
23
  "exports": {
24
24
  ".": "./lib/index.js",
25
25
  "./client": "./lib/client.js",
26
- "./package.json": "./package.json"
26
+ "./package.json": "./package.json",
27
+ "./locale/*.json": "./locale/*.json"
27
28
  },
28
29
  "files": [
29
30
  "lib",
30
31
  "src",
31
32
  "skills",
32
33
  "tools",
34
+ "locale/*.json",
33
35
  "cordis.patch.yml",
34
36
  "README.md",
35
37
  "LICENSE",
package/src/api.js CHANGED
@@ -57,26 +57,55 @@ function wsShape(ws) {
57
57
  * 工作区 id → 该工作区下的会话 id 列表(宿主 workspaceRegistry entity.sessionIds)。
58
58
  * 「一键下发」要知道发给谁;为 null 时退回对话自己记录的 sourceWorkspaceId。
59
59
  */
60
- export function createApiHandler(services, log, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null) {
60
+ export function createApiHandler(services, log, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null, connection = null) {
61
61
  const json = (res, code, obj) => {
62
62
  res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
63
63
  res.end(JSON.stringify(obj));
64
64
  };
65
+ const MAX_BODY = 1_000_000;
65
66
  const readBody = (req) =>
66
67
  new Promise((resolve, reject) => {
67
68
  let data = "";
69
+ let settled = false;
68
70
  req.on("data", (c) => {
71
+ if (settled) return;
69
72
  data += c;
70
- if (data.length > 1_000_000) reject(new Error("body too large"));
73
+ // 超限必须停:原实现 reject 之后仍继续累积字符串,
74
+ // 等于把「拒绝」写成了「慢慢吃满内存」。
75
+ if (data.length > MAX_BODY) {
76
+ settled = true;
77
+ req.destroy?.();
78
+ reject(new Error("body too large"));
79
+ }
80
+ });
81
+ req.on("end", () => {
82
+ if (!settled) { settled = true; resolve(data); }
83
+ });
84
+ req.on("error", (e) => {
85
+ if (!settled) { settled = true; reject(e); }
71
86
  });
72
- req.on("end", () => resolve(data));
73
- req.on("error", reject);
74
87
  });
75
88
 
76
89
  return async function handler(req, res) {
77
90
  const url = new URL(req.url ?? "/", "http://localhost");
78
91
  const path = url.pathname.replace(/^\/skill-tag\/api/, "") || "/";
79
92
  const method = req.method ?? "GET";
93
+
94
+ // 先过宿主 Connection 的 Host/Origin 与浏览器鉴权。webServer 本身不做任何
95
+ // 来源检查,不接这道防线的话,任意网页都能跨源调这些端点:写操作走
96
+ // mode:'no-cors' 的 simple request 不需要预检,读不到响应但写入已生效。
97
+ const rejection = connection?.requestRejection?.({ headers: req.headers });
98
+ if (rejection !== undefined) return json(res, rejection, { ok: false, error: "rejected" });
99
+
100
+ // 写操作必须是 JSON:挡掉 text/plain 的跨源 simple request(它不触发预检),
101
+ // 也让浏览器的跨源请求先走 CORS 预检。
102
+ if (method === "POST" || method === "PUT") {
103
+ const type = String(req.headers?.["content-type"] ?? "").toLowerCase();
104
+ if (!type.startsWith("application/json")) {
105
+ return json(res, 415, { ok: false, error: "content-type must be application/json" });
106
+ }
107
+ }
108
+
80
109
  let body = {};
81
110
  if (method === "POST" || method === "PUT") {
82
111
  try {
@@ -439,7 +468,7 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
439
468
 
440
469
  // ── 「管理标签」双列界面 ───────────────────────────────────────
441
470
  // 左列 = 标签条件(表达式);右列 = 该工作区掌握/禁用的技能。
442
- if (path === "/workspaces/expression" && (method === "POST" || method === "GET")) {
471
+ if (path === "/workspaces/expression" && method === "POST") {
443
472
  // 保存左列表达式(空串 = 清空条件 = 回到"全部技能")
444
473
  const out = await services.workspaces.setExpression(q("workspaceId"), q("expression") ?? "");
445
474
  return json(res, 200, { ok: true, workspace: wsShape(out.record), validation: out.validation });
@@ -571,7 +600,7 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
571
600
  // · 已配置的对话必须显式 overwrite=true 才覆盖(界面二次勾选确认)。
572
601
  //
573
602
  // 这是 0.1.9 那轮修复的直接延续:用户改的东西不能被无差别覆盖。
574
- if ((path === "/workspaces/push-preview" || path === "/workspaces/push") && (method === "GET" || method === "POST")) {
603
+ if ((path === "/workspaces/push-preview" || path === "/workspaces/push") && method === "POST") {
575
604
  const wid = q("workspaceId");
576
605
  if (!wid) return json(res, 400, { ok: false, error: "缺少 workspaceId" });
577
606
  // 该工作区下的对话:**宿主 sessionIds 是唯一权威来源**。
@@ -627,7 +656,7 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
627
656
  // 对话拥有**自己的**配置快照(own):标签条件 + 手动添加 + 禁用。
628
657
  // 工作区配置不会自动流入对话,除非:新建对话(自动继承一次)或
629
658
  // 用户点了「继承工作区配置」按钮。
630
- if (path === "/conversations/config" && (method === "GET" || method === "POST")) {
659
+ if (path === "/conversations/config" && method === "POST") {
631
660
  const cid = q("conversationId");
632
661
  const wid = workspaceIdForConversation(cid);
633
662
  // 打开「管理标签(对话)」是**纯读**:只保证对话行存在,绝不播种 own。
@@ -683,14 +712,19 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
683
712
  };
684
713
  }
685
714
 
686
- /** Mount the handler on a cordis ctx (inject webServer). */
687
- export function mountApi(ctx, services, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null) {
715
+ /**
716
+ * Mount the handler on a cordis ctx (inject webServer).
717
+ *
718
+ * connection 用来做 Host/Origin 与浏览器鉴权:webServer 自身只做路由匹配,
719
+ * 不做任何来源检查。它可能缺席(无浏览器组合),那时 handler 会跳过校验。
720
+ */
721
+ export function mountApi(ctx, services, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null, connection = null) {
688
722
  ctx.effect(
689
723
  () =>
690
724
  ctx.webServer.register({
691
725
  kind: "prefix",
692
726
  path: "/skill-tag/api",
693
- handler: createApiHandler(services, ctx.logger, listSkills, resolveWorkspaceId, resolveWorkspacePath, resolveWorkspaceSessions),
727
+ handler: createApiHandler(services, ctx.logger, listSkills, resolveWorkspaceId, resolveWorkspacePath, resolveWorkspaceSessions, connection),
694
728
  }),
695
729
  "skill-tag: api",
696
730
  );
package/src/skill-scan.js CHANGED
@@ -70,6 +70,17 @@ function collectRoot(dir, depth, root, out) {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * 合法技能名:kebab-case,与宿主 @deepseek-ai/dsh-skill 的 SKILL_NAME 同一正则。
75
+ *
76
+ * 为什么扫描器也要过这一关(2026-11-12,issue #6):扫描结果会进 universe,
77
+ * 于是 denied = universe − visible 里也会出现它;只要这个名字非法,宿主
78
+ * validateCandidate 就会抛错、整个 provider 作废、**所有对话**都跑不下去。
79
+ * 宿主自己的文件系统 provider 早就这么做了(`skill file ... ignored: invalid
80
+ * skill name`),自扫描必须同口径,否则插件会把宿主本来就不认的名字当成技能。
81
+ */
82
+ const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
83
+
73
84
  function pushSkill(out, mdPath, root, disabled, fallbackName) {
74
85
  let fm = null;
75
86
  try {
@@ -77,8 +88,15 @@ function pushSkill(out, mdPath, root, disabled, fallbackName) {
77
88
  } catch {
78
89
  return; // unreadable file — skip
79
90
  }
91
+ // 裸放的根级 .md(flat skill)与宿主同口径:**必须有 frontmatter**。
92
+ // 没有 frontmatter 的 .md 只是一份普通文档(README、笔记…),不是技能;
93
+ // 旧实现会用文件名凭空造一个技能名,正是 issue #6 的来源。
94
+ const isFlat = fallbackName !== undefined;
95
+ if (isFlat && fm === null) return;
80
96
  const name = fm?.name || fallbackName || path.basename(path.dirname(mdPath));
81
97
  if (!name) return;
98
+ // 非法名字宁可丢掉:它既进不了宿主注册表,还会炸掉整批候选。
99
+ if (!SKILL_NAME_RE.test(name)) return;
82
100
  out.push({
83
101
  name,
84
102
  description: fm?.description ?? "",