dsh-plugin-tool-management 0.17.0 → 0.18.0

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -3
  3. package/README_EN.md +18 -3
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +16 -0
  21. package/lib/client.js +1573 -133
  22. package/lib/compat/capability-specs.js +321 -0
  23. package/lib/compat/host-probe.js +261 -0
  24. package/lib/compat/host-roots.js +168 -0
  25. package/lib/compat/probe-types.js +6 -0
  26. package/lib/compat/probe.js +9 -720
  27. package/lib/context-inject-contract.js +114 -0
  28. package/lib/context-inject-frame.js +147 -0
  29. package/lib/context-inject-select.js +127 -0
  30. package/lib/context-inject-surface.js +97 -0
  31. package/lib/context-inject.js +10 -478
  32. package/lib/http-api.js +229 -0
  33. package/lib/hub.js +2 -0
  34. package/lib/index.js +134 -836
  35. package/lib/mcp/inspect-probe.js +199 -0
  36. package/lib/mcp/manager.js +30 -548
  37. package/lib/mcp/tool-visibility.js +388 -0
  38. package/lib/memories/archive-engine.js +142 -5
  39. package/lib/memories/archive.js +10 -1
  40. package/lib/memories/discovery.js +261 -0
  41. package/lib/memories/export-plan.js +42 -0
  42. package/lib/memories/memories-types.js +1 -0
  43. package/lib/memories/service.js +4 -38
  44. package/lib/memories/snapshot.js +10 -252
  45. package/lib/op-registry.js +21 -0
  46. package/lib/ops/compat.js +11 -0
  47. package/lib/ops/quick-prompts.js +26 -0
  48. package/lib/ops/scene-records.js +40 -0
  49. package/lib/ops/scene-sync.js +71 -1
  50. package/lib/ops/sessions.js +13 -9
  51. package/lib/ops/snapshot.js +18 -12
  52. package/lib/prompts/quick-service.js +211 -0
  53. package/lib/scene-linkage.js +184 -0
  54. package/lib/sessions/archive-schemas.js +121 -0
  55. package/lib/sessions/path-key.js +46 -0
  56. package/lib/sessions/workspace.js +5 -163
  57. package/lib/settings-sidecars.js +143 -0
  58. package/lib/skills/core-types.js +8 -0
  59. package/lib/skills/core-util.js +81 -0
  60. package/lib/skills/core.js +56 -2326
  61. package/lib/skills/dir-watch.js +71 -0
  62. package/lib/skills/frontmatter.js +96 -0
  63. package/lib/skills/homes.js +14 -0
  64. package/lib/skills/import-pipeline.js +231 -0
  65. package/lib/skills/manager-state.js +800 -0
  66. package/lib/skills/official.js +269 -0
  67. package/lib/skills/provider-registration.js +89 -0
  68. package/lib/skills/service.js +5 -240
  69. package/lib/skills/skill-roots.js +287 -0
  70. package/lib/skills/skill-trash.js +265 -0
  71. package/lib/skills/system-trash.js +97 -0
  72. package/lib/skills/upload-pipeline.js +374 -0
  73. package/lib/slash-settings.js +25 -0
  74. package/lib/subagents/persona-parse.js +312 -0
  75. package/lib/subagents/persona-types.js +5 -0
  76. package/lib/subagents/persona-write.js +93 -0
  77. package/lib/subagents/run-provider.js +159 -0
  78. package/lib/subagents/service.js +14 -638
  79. package/lib/subagents/tool-filter.js +98 -0
  80. package/lib/token-config.js +240 -0
  81. package/lib/tool-table-switch.js +163 -0
  82. package/lib/tools/scene.js +40 -9
  83. package/package.json +3 -2
@@ -0,0 +1,800 @@
1
+ // manager 状态层:本插件对技能来源的**记账**——状态文档的形状与归一、读写与失败回落、
2
+ // 生效启停策略的解析,以及喂给它的来源扫描与同名去重(entryOf / scanEntries / markWinners)。
3
+ //
4
+ // 声明从 skills/core.ts 按行号区间搬来,正文一字未改(唯一的容差是 `--export-top-level`
5
+ // 加的 `export ` 前缀,moved-verify 逐字对拍)。
6
+ //
7
+ // 为什么这三件事是一层:`effectiveSkillPolicy` 读的、`writeManagerState` 写的、
8
+ // `scanDeduplicatedRoots` 喂的都是同一份进程内 singleton `state`(下面那个 const)。
9
+ // 拆成两层就得让一层去引另一层的私有状态,等于没拆。
10
+ //
11
+ // singleton 换了住户没换 semantics:`state` 仍是**每进程一份**的模块级 const,
12
+ // core.ts 与其余各域引到的都是同一个对象 —— 与搬走前逐字相同的引用,不是拷贝。
13
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
14
+ import { promises as fs } from "node:fs";
15
+ import { randomUUID } from "node:crypto";
16
+ import { isSameOrDescendant } from "../paths.js";
17
+ import { parseSkillDoc, unquote } from "./frontmatter.js";
18
+ import { discoverReadonlyEntries, validDiscoveryName } from "./readonly-discovery.js";
19
+ import { codedError, entryPath, lstatOrNull, MAX_ENTRY_NAME_LENGTH, pathIdentity, renameWithRetry, } from "./core-util.js";
20
+ import { customRootKey, isDefaultSkillSource, managerHomePath, managerStatePath, projectRoots, resolvedPath, rootDefinition, userRoots, } from "./skill-roots.js";
21
+ import { listTrash } from "./skill-trash.js";
22
+ import { KEBAB_RE } from "./upload-pipeline.js";
23
+ export const PROJECT_ROOT_KEY_RE = /^project-(?:dsh|agents):[a-f0-9]{16}$/;
24
+ export const CUSTOM_ROOT_KEY_RE = /^custom-[a-f0-9]{16}$/;
25
+ export function customRootsFromState(stateValue) {
26
+ const state = stateValue;
27
+ const list = state && Array.isArray(state.customRoots) ? state.customRoots : [];
28
+ const out = [];
29
+ for (const item of list) {
30
+ if (!item || typeof item !== "object")
31
+ continue;
32
+ const path = typeof item.path === "string" ? item.path : "";
33
+ const key = typeof item.key === "string" ? item.key : "";
34
+ if (!isAbsolute(path) || !CUSTOM_ROOT_KEY_RE.test(key))
35
+ continue;
36
+ if (key !== customRootKey(path))
37
+ continue;
38
+ out.push({
39
+ key,
40
+ path,
41
+ label: typeof item.label === "string" && item.label.trim()
42
+ ? item.label.trim().slice(0, 64)
43
+ : "自定义目录",
44
+ // 用户加进来的来源(相对「按约定发现」的 dsh / hub / agents / codex / claude):
45
+ // 界面据此决定要不要给「永久删除」——只有自定义来源能真正从插件里删掉记录。
46
+ custom: true,
47
+ mutable: false,
48
+ toggleable: true,
49
+ native: false,
50
+ scope: "user",
51
+ rank: 600,
52
+ });
53
+ }
54
+ return out;
55
+ }
56
+ export async function isInsideResolvedRoot(rootReal, path) {
57
+ return isSameOrDescendant(rootReal, await resolvedPath(path));
58
+ }
59
+ export async function writeFileAtomically(path, content) {
60
+ const temp = join(dirname(path), `.${basename(path)}.dssm-${randomUUID()}.tmp`);
61
+ try {
62
+ await fs.writeFile(temp, content, "utf8");
63
+ // Windows 上目标文件会被杀软/索引器短暂占住(EPERM/EACCES/EBUSY)——
64
+ // 场景模式进出时这里写的是技能来源/策略状态,rename 被撞 = 运行时已切、
65
+ // 状态没落盘。与 memories-index.json 同一处理:重试瞬时占用,全失败才抛。
66
+ await renameWithRetry(temp, path);
67
+ }
68
+ catch (error) {
69
+ await fs.rm(temp, { force: true }).catch(() => undefined);
70
+ throw error;
71
+ }
72
+ }
73
+ export function parseBoolValue(raw) {
74
+ const v = unquote(raw).trim().toLowerCase();
75
+ if (v === "true" || v === "yes" || v === "on" || v === "1")
76
+ return true;
77
+ if (v === "false" || v === "no" || v === "off" || v === "0")
78
+ return false;
79
+ return undefined;
80
+ }
81
+ export function entryOf(name, kind, docPath, doc) {
82
+ const declaredName = doc.map.name !== undefined ? unquote(doc.map.name) : "";
83
+ const description = doc.map.description !== undefined ? unquote(doc.map.description) : "";
84
+ const modelValue = parseBoolValue(doc.map["disable-model-invocation"]);
85
+ const userValue = parseBoolValue(doc.map["user-invocable"]);
86
+ const modelDisabled = modelValue === true;
87
+ const userDisabled = userValue === false;
88
+ const invocationPolicyValid = (doc.map["disable-model-invocation"] === undefined ||
89
+ modelValue !== undefined) &&
90
+ (doc.map["user-invocable"] === undefined || userValue !== undefined);
91
+ const diagnostics = [];
92
+ if (!doc.hasFrontmatter)
93
+ diagnostics.push({
94
+ level: "error",
95
+ code: "diagnostic.frontmatter.missing",
96
+ });
97
+ if (doc.hasFrontmatter && !declaredName)
98
+ diagnostics.push({ level: "error", code: "diagnostic.name.missing" });
99
+ else if (declaredName && !KEBAB_RE.test(declaredName))
100
+ diagnostics.push({
101
+ level: "error",
102
+ code: "diagnostic.name.invalid",
103
+ params: { name: declaredName },
104
+ });
105
+ if (doc.hasFrontmatter && !description)
106
+ diagnostics.push({
107
+ level: "error",
108
+ code: "diagnostic.description.missing",
109
+ });
110
+ if (!invocationPolicyValid)
111
+ diagnostics.push({ level: "error", code: "diagnostic.invocation.invalid" });
112
+ return {
113
+ name,
114
+ declaredName,
115
+ kind,
116
+ docPath,
117
+ description,
118
+ modelInvocable: !modelDisabled,
119
+ userInvocable: !userDisabled,
120
+ invocationPolicyValid,
121
+ hasFrontmatter: doc.hasFrontmatter,
122
+ // 调用策略值异常可以由 manager 本地策略覆盖;结构本身合法即可加载。
123
+ loadable: doc.hasFrontmatter && KEBAB_RE.test(declaredName) && description !== "",
124
+ diagnostics,
125
+ };
126
+ }
127
+ export async function scanEntries(root, options = {}) {
128
+ const definition = rootDefinition(root);
129
+ if (definition && !definition.mutable) {
130
+ const discovered = await discoverReadonlyEntries(definition.path);
131
+ // metadataOnly 时条目的摘要字段由调用方按需读取(见 visibleEntryForRoot)。
132
+ if (options.metadataOnly)
133
+ return discovered;
134
+ const entries = [];
135
+ for (const entry of discovered.entries) {
136
+ try {
137
+ entries.push({
138
+ ...entryOf(entry.name, entry.kind, entry.docPath, parseSkillDoc(await fs.readFile(entry.realDocPath, "utf8"))),
139
+ ...entry,
140
+ });
141
+ }
142
+ catch {
143
+ /* 忽略已失效或不可读技能。 */
144
+ }
145
+ }
146
+ return { ...discovered, entries };
147
+ }
148
+ // 传入 definition 对象时取它的 path;其余情况按 key 字符串处理。
149
+ root = definition ? definition.path : root;
150
+ const rootStat = await lstatOrNull(resolve(root));
151
+ if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink())
152
+ return { exists: false, entries: [] };
153
+ let items;
154
+ try {
155
+ items = await fs.readdir(root, { withFileTypes: true });
156
+ }
157
+ catch {
158
+ return { exists: false, entries: [] };
159
+ }
160
+ const byName = new Map();
161
+ const rootReal = await resolvedPath(root);
162
+ for (const it of items) {
163
+ try {
164
+ if (it.isSymbolicLink())
165
+ continue;
166
+ if (it.isDirectory() && entryPath(root, it.name) !== null) {
167
+ const docPath = join(root, it.name, "SKILL.md");
168
+ const st = await lstatOrNull(docPath);
169
+ if (!st ||
170
+ !st.isFile() ||
171
+ st.isSymbolicLink() ||
172
+ !(await isInsideResolvedRoot(rootReal, docPath)))
173
+ continue;
174
+ const doc = options.metadataOnly
175
+ ? { map: {}, hasFrontmatter: false }
176
+ : parseSkillDoc(await fs.readFile(docPath, "utf8"));
177
+ byName.set(it.name, {
178
+ ...entryOf(it.name, "bundle", docPath, doc),
179
+ entryPath: join(root, it.name),
180
+ realDocPath: await fs.realpath(docPath),
181
+ realEntryPath: await fs.realpath(join(root, it.name)),
182
+ linked: false,
183
+ });
184
+ }
185
+ else if (it.isFile() &&
186
+ it.name.toLowerCase().endsWith(".md") &&
187
+ it.name.toLowerCase() !== "skill.md" &&
188
+ entryPath(root, it.name.slice(0, -3)) !== null) {
189
+ const skillName = it.name.slice(0, -3);
190
+ if (byName.has(skillName))
191
+ continue;
192
+ const docPath = join(root, it.name);
193
+ if (!(await isInsideResolvedRoot(rootReal, docPath)))
194
+ continue;
195
+ const doc = options.metadataOnly
196
+ ? { map: {}, hasFrontmatter: false }
197
+ : parseSkillDoc(await fs.readFile(docPath, "utf8"));
198
+ const realDocPath = await fs.realpath(docPath);
199
+ byName.set(skillName, {
200
+ ...entryOf(skillName, "flat", docPath, doc),
201
+ entryPath: docPath,
202
+ realDocPath,
203
+ realEntryPath: realDocPath,
204
+ linked: false,
205
+ });
206
+ }
207
+ }
208
+ catch {
209
+ /* 跳过不可读条目 */
210
+ }
211
+ }
212
+ const entries = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
213
+ return { exists: true, entries };
214
+ }
215
+ export async function scanDeduplicatedRoots(roots = userRoots(), options = {}) {
216
+ const results = await Promise.all(roots.map(async (root) => [
217
+ root,
218
+ await scanEntries(root, options),
219
+ ]));
220
+ const scans = new Map();
221
+ const groups = new Map();
222
+ for (const [root, scan] of results) {
223
+ scans.set(root.key, scan);
224
+ for (const entry of scan.entries) {
225
+ const identity = pathIdentity(entry.realEntryPath || entry.entryPath || entry.docPath);
226
+ const group = groups.get(identity) || [];
227
+ group.push({ root, entry });
228
+ groups.set(identity, group);
229
+ }
230
+ }
231
+ const winners = new Set();
232
+ for (const group of groups.values()) {
233
+ group.sort((left, right) => Number(left.entry.linked) - Number(right.entry.linked) ||
234
+ left.root.rank - right.root.rank);
235
+ const winner = group[0];
236
+ winner.entry.providerRank = Math.min(...group.map((item) => item.root.rank));
237
+ winner.entry.policyAliases = group.map((item) => ({
238
+ rootKey: item.root.key,
239
+ name: item.entry.name,
240
+ }));
241
+ winners.add(winner.entry);
242
+ }
243
+ for (const scan of scans.values()) {
244
+ scan.entries = scan.entries.filter((entry) => winners.has(entry));
245
+ }
246
+ return scans;
247
+ }
248
+ export function defaultManagerState() {
249
+ const sources = Object.create(null);
250
+ const disabledSkills = Object.create(null);
251
+ const enabledSkills = Object.create(null);
252
+ for (const root of userRoots()) {
253
+ // 默认来源(dsh / hub)没有来源开关,所以不落进 sources 表 —— 它们永远「读取」。
254
+ // 旧版本可能往这里写过 hub 的 false,normalizeManagerState 会顺手丢掉。
255
+ if (!isDefaultSkillSource(root.key))
256
+ sources[root.key] = true;
257
+ disabledSkills[root.key] = [];
258
+ enabledSkills[root.key] = [];
259
+ }
260
+ return {
261
+ version: 1,
262
+ sources,
263
+ disabledSkills,
264
+ enabledSkills,
265
+ customRoots: [],
266
+ // 同名技能「首选来源」:技能名 → 来源 key。缺键 = 按来源 rank 取最高优先级者。
267
+ preferredSkills: Object.create(null),
268
+ // 用户从技能页「移除」的来源:插件**不再读取**这些目录(技能不出现在列表里,
269
+ // 也不参与 provider 候选)。与「停用来源」不同——停用仍列出技能、只是不可调用。
270
+ // 源文件一个字节都不动;清空这个数组即可恢复。
271
+ removedSources: [],
272
+ };
273
+ }
274
+ export function validStateSkillName(name) {
275
+ return validDiscoveryName(name);
276
+ }
277
+ export function validPreferredSkillName(name) {
278
+ return (typeof name === "string" &&
279
+ name.length > 0 &&
280
+ name.length <= MAX_ENTRY_NAME_LENGTH &&
281
+ !name.includes("\0") &&
282
+ !name.includes("/") &&
283
+ !name.includes("\\"));
284
+ }
285
+ export function failClosedManagerState() {
286
+ const state = defaultManagerState();
287
+ for (const key of Object.keys(state.sources))
288
+ state.sources[key] = false;
289
+ return state;
290
+ }
291
+ export function validPolicyLists(value, allowMissing = false) {
292
+ if (value === undefined && allowMissing)
293
+ return true;
294
+ if (!value || typeof value !== "object" || Array.isArray(value))
295
+ return false;
296
+ for (const [key, list] of Object.entries(value)) {
297
+ if (!userRoots().some((root) => root.key === key) &&
298
+ !PROJECT_ROOT_KEY_RE.test(key))
299
+ continue;
300
+ if (!Array.isArray(list) || list.some((name) => !validStateSkillName(name)))
301
+ return false;
302
+ }
303
+ return true;
304
+ }
305
+ export function validManagerStateDocument(value) {
306
+ if (!value ||
307
+ typeof value !== "object" ||
308
+ Array.isArray(value) ||
309
+ value.version !== 1)
310
+ return false;
311
+ if (!value.sources ||
312
+ typeof value.sources !== "object" ||
313
+ Array.isArray(value.sources))
314
+ return false;
315
+ if (!validPolicyLists(value.disabledSkills) ||
316
+ !validPolicyLists(value.enabledSkills, true))
317
+ return false;
318
+ // 自定义来源列表:可选字段;容器必须是数组(条目级合法性由 normalize 逐条裁剪)。
319
+ if (value.customRoots !== undefined && !Array.isArray(value.customRoots))
320
+ return false;
321
+ // 已移除的来源:可选字段;数组里的每一项必须能当来源 key 用。
322
+ if (value.removedSources !== undefined) {
323
+ if (!Array.isArray(value.removedSources) ||
324
+ value.removedSources.some((key) => typeof key !== "string" || key.length === 0 || key.length > 128))
325
+ return false;
326
+ }
327
+ // 同名首选表:可选字段;只校验容器与值类型,键值逐条在 normalize 里裁剪(见 validPreferredSkillName)。
328
+ if (value.preferredSkills !== undefined) {
329
+ if (!value.preferredSkills ||
330
+ typeof value.preferredSkills !== "object" ||
331
+ Array.isArray(value.preferredSkills))
332
+ return false;
333
+ for (const key of Object.values(value.preferredSkills))
334
+ if (typeof key !== "string")
335
+ return false;
336
+ }
337
+ for (const root of userRoots()) {
338
+ if (isDefaultSkillSource(root.key))
339
+ continue;
340
+ if (typeof value.sources[root.key] !== "boolean")
341
+ return false;
342
+ const list = value.disabledSkills[root.key];
343
+ if (!Array.isArray(list) || list.some((name) => !validStateSkillName(name)))
344
+ return false;
345
+ }
346
+ const enabledSkills = value.enabledSkills || {};
347
+ for (const key of new Set([
348
+ ...Object.keys(value.disabledSkills),
349
+ ...Object.keys(enabledSkills),
350
+ ])) {
351
+ const disabled = new Set(value.disabledSkills[key] || []);
352
+ if ((enabledSkills[key] || []).some((name) => disabled.has(name)))
353
+ return false;
354
+ }
355
+ return true;
356
+ }
357
+ export function normalizeManagerState(value) {
358
+ const normalized = defaultManagerState();
359
+ if (!value || typeof value !== "object" || Array.isArray(value))
360
+ return normalized;
361
+ // 自定义来源先归一,其 sources/policy 键才有归属。
362
+ normalized.customRoots = normalizeCustomRoots(value.customRoots);
363
+ const customKeys = new Set(normalized.customRoots.map((root) => root.key));
364
+ for (const root of userRoots()) {
365
+ // 默认来源不接收 sources 标志:旧版本写过的 `sources.hub = false` 在这里被丢弃,
366
+ // 于是「曾经把 hub 停用掉」的用户升级后自动回到必须读取的状态(技能不丢,只是策略位作废)。
367
+ if (!isDefaultSkillSource(root.key) &&
368
+ value.sources &&
369
+ typeof value.sources[root.key] === "boolean")
370
+ normalized.sources[root.key] = value.sources[root.key];
371
+ const list = value.disabledSkills && value.disabledSkills[root.key];
372
+ if (Array.isArray(list))
373
+ normalized.disabledSkills[root.key] = [
374
+ ...new Set(list.filter((name) => validStateSkillName(name))),
375
+ ].sort();
376
+ const enabled = value.enabledSkills && value.enabledSkills[root.key];
377
+ if (Array.isArray(enabled))
378
+ normalized.enabledSkills[root.key] = [
379
+ ...new Set(enabled.filter((name) => validStateSkillName(name))),
380
+ ].sort();
381
+ }
382
+ if (value.sources && typeof value.sources === "object" && !Array.isArray(value.sources)) {
383
+ for (const [key, flag] of Object.entries(value.sources)) {
384
+ if (customKeys.has(key) && typeof flag === "boolean")
385
+ normalized.sources[key] = flag;
386
+ }
387
+ }
388
+ for (const field of ["disabledSkills", "enabledSkills"]) {
389
+ const source = value[field];
390
+ if (source && typeof source === "object" && !Array.isArray(source)) {
391
+ for (const [key, list] of Object.entries(source)) {
392
+ if (!PROJECT_ROOT_KEY_RE.test(key) && !customKeys.has(key))
393
+ continue;
394
+ if (!Array.isArray(list))
395
+ continue;
396
+ normalized[field][key] = [
397
+ ...new Set(list.filter(validStateSkillName)),
398
+ ].sort();
399
+ }
400
+ }
401
+ }
402
+ // 同名首选表:键必须是技能声明名、值必须指向一个已知来源(用户级 / 自定义 / 项目级),
403
+ // 否则丢弃该条(源目录被移除后残留的首选会自然失效)。
404
+ const preferred = value.preferredSkills;
405
+ if (preferred && typeof preferred === "object" && !Array.isArray(preferred)) {
406
+ for (const [name, rootKey] of Object.entries(preferred)) {
407
+ if (!validPreferredSkillName(name) || typeof rootKey !== "string")
408
+ continue;
409
+ if (!userRoots().some((root) => root.key === rootKey) &&
410
+ !customKeys.has(rootKey) &&
411
+ !PROJECT_ROOT_KEY_RE.test(rootKey))
412
+ continue;
413
+ normalized.preferredSkills[name] = rootKey;
414
+ }
415
+ }
416
+ // 已移除的来源:只保留「确实是已知来源 key」的条目,其余丢弃(避免脏键让整份状态非法)。
417
+ {
418
+ const raw = Array.isArray(value.removedSources) ? value.removedSources : [];
419
+ const known = new Set([
420
+ ...userRoots().map((root) => root.key),
421
+ ...customKeys,
422
+ ]);
423
+ normalized.removedSources = [
424
+ ...new Set(raw.filter((key) => typeof key === "string" &&
425
+ known.has(key) &&
426
+ !isDefaultSkillSource(key))),
427
+ ].sort();
428
+ }
429
+ return normalized;
430
+ }
431
+ export function normalizeCustomRoots(value) {
432
+ if (!Array.isArray(value))
433
+ return [];
434
+ const out = [];
435
+ const seen = new Set();
436
+ for (const item of value) {
437
+ if (!item || typeof item !== "object")
438
+ continue;
439
+ const path = typeof item.path === "string" ? item.path : "";
440
+ const key = typeof item.key === "string" ? item.key : "";
441
+ if (!isAbsolute(path) || !CUSTOM_ROOT_KEY_RE.test(key))
442
+ continue;
443
+ if (key !== customRootKey(path) || seen.has(key))
444
+ continue;
445
+ seen.add(key);
446
+ out.push(typeof item.label === "string" && item.label.trim()
447
+ ? { key, path, label: item.label.trim().slice(0, 64) }
448
+ : { key, path });
449
+ }
450
+ return out;
451
+ }
452
+ export async function readManagerState() {
453
+ try {
454
+ const raw = await fs.readFile(managerStatePath(), "utf8");
455
+ const parsed = JSON.parse(raw);
456
+ // 版本号在归一化时不会被保留(恒为 1),所以要在解析结果上直接判:
457
+ // 缺失按 1 处理(更早的文档没有这个字段),存在但不是 1 才判非法。
458
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)
459
+ && parsed.version !== undefined && parsed.version !== 1)
460
+ throw codedError("invalid manager state schema", "error.state.invalid");
461
+ // 结构守卫:字段**可以缺**(旧文档),但**一旦存在就必须形状正确**。
462
+ // 少了这道守卫,归一化会把「类型写错」当成「键缺失」一起补默认值——于是 `sources: []`
463
+ // 或 `disabledSkills: { dsh: "x" }` 这种真损坏反而被自愈放行,静默丢掉文件里的策略。
464
+ // 契约:**缺键自愈(我们后来加过的东西),类型错必须报错。**
465
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
466
+ for (const field of ["sources", "disabledSkills", "enabledSkills"]) {
467
+ const value = parsed[field];
468
+ if (value === undefined)
469
+ continue;
470
+ if (value === null || typeof value !== "object" || Array.isArray(value))
471
+ throw codedError("invalid manager state schema", "error.state.invalid");
472
+ // 策略表的每个桶必须是数组(条目级合法性仍由 normalize 逐条裁剪)。
473
+ if (field === "disabledSkills" || field === "enabledSkills") {
474
+ for (const bucket of Object.values(value))
475
+ if (!Array.isArray(bucket))
476
+ throw codedError("invalid manager state schema", "error.state.invalid");
477
+ }
478
+ }
479
+ }
480
+ const normalized = normalizeManagerState(parsed);
481
+ if (!validManagerStateDocument(normalized))
482
+ throw codedError("invalid manager state schema", "error.state.invalid");
483
+ return {
484
+ state: normalized,
485
+ warning: null,
486
+ writable: true,
487
+ };
488
+ }
489
+ catch (error) {
490
+ // 系统异常按可选 code 读取,保持既有 ENOENT 判定。
491
+ if (error && error.code === "ENOENT")
492
+ return { state: defaultManagerState(), warning: null, writable: true };
493
+ return {
494
+ state: failClosedManagerState(),
495
+ writable: false,
496
+ warning: {
497
+ code: "warning.state.invalid",
498
+ params: { path: managerStatePath() },
499
+ error: `技能管理器状态文件不可读,所有技能已安全停用且状态写入已锁定: ${managerStatePath()}`,
500
+ },
501
+ };
502
+ }
503
+ }
504
+ export async function writeManagerState(value) {
505
+ await fs.mkdir(managerHomePath(), { recursive: true });
506
+ await writeFileAtomically(managerStatePath(), `${JSON.stringify(normalizeManagerState(value), null, 2)}\n`);
507
+ }
508
+ export function managerSkillOverride(policy, rootKey, name, policyAliases = []) {
509
+ if ((policy.enabledSkills[rootKey] || []).includes(name))
510
+ return true;
511
+ if ((policy.disabledSkills[rootKey] || []).includes(name))
512
+ return false;
513
+ let inheritedEnable = false;
514
+ for (const alias of policyAliases) {
515
+ if (alias.rootKey === rootKey && alias.name === name)
516
+ continue;
517
+ if ((policy.disabledSkills[alias.rootKey] || []).includes(alias.name))
518
+ return false;
519
+ if ((policy.enabledSkills[alias.rootKey] || []).includes(alias.name))
520
+ inheritedEnable = true;
521
+ }
522
+ if (inheritedEnable)
523
+ return true;
524
+ return undefined;
525
+ }
526
+ export function effectiveSkillPolicy(policyResult, root, entry) {
527
+ const override = managerSkillOverride(policyResult.state, root.key, entry.name, entry.policyAliases);
528
+ const sourceEnabled = isDefaultSkillSource(root) ||
529
+ root.scope === "project" ||
530
+ policyResult.state.sources[root.key] !== false;
531
+ if (policyResult.writable === false || !sourceEnabled || override === false) {
532
+ return {
533
+ override,
534
+ sourceEnabled,
535
+ modelInvocable: false,
536
+ userInvocable: false,
537
+ enabled: false,
538
+ };
539
+ }
540
+ if (override === true) {
541
+ return {
542
+ override,
543
+ sourceEnabled,
544
+ modelInvocable: true,
545
+ userInvocable: true,
546
+ enabled: true,
547
+ };
548
+ }
549
+ const modelInvocable = entry.invocationPolicyValid && entry.modelInvocable;
550
+ const userInvocable = entry.invocationPolicyValid && entry.userInvocable;
551
+ return {
552
+ override,
553
+ sourceEnabled,
554
+ modelInvocable,
555
+ userInvocable,
556
+ enabled: modelInvocable && userInvocable,
557
+ };
558
+ }
559
+ export function canonicalSkillName(item) {
560
+ return item.entry.declaredName || item.entry.name;
561
+ }
562
+ export function groupLoadableSkillsByName(items, preferred) {
563
+ const preferredFor = (item) => {
564
+ if (!preferred)
565
+ return null;
566
+ const key = preferred[canonicalSkillName(item)];
567
+ return typeof key === "string" ? key : null;
568
+ };
569
+ const ordered = [...items].sort((a, b) => {
570
+ const rankA = preferredFor(a) === a.root.key ? 0 : 1;
571
+ const rankB = preferredFor(b) === b.root.key ? 0 : 1;
572
+ if (rankA !== rankB)
573
+ return rankA - rankB;
574
+ return a.root.rank - b.root.rank;
575
+ });
576
+ const groups = new Map();
577
+ for (const item of ordered) {
578
+ if (!item.entry.loadable)
579
+ continue;
580
+ const name = canonicalSkillName(item);
581
+ const group = groups.get(name) || [];
582
+ group.push(item);
583
+ groups.set(name, group);
584
+ }
585
+ return groups;
586
+ }
587
+ export function markWinners(items, options = {}) {
588
+ const winners = new Map();
589
+ const preferred = options.preferred;
590
+ for (const [canonicalName, [winner, ...shadowed],] of groupLoadableSkillsByName(items, preferred)) {
591
+ winners.set(canonicalName, winner);
592
+ // `preferred` = 「这份显式选择**记录在案**且它确实赢了同名之争」,**不等于它正在生效**:
593
+ // 首选来源自己停用时它照样赢(停用不参与选赢家),此时 `view.enabled === false`,
594
+ // 而其余副本拿到 `shadowedBy.enabled === false` —— 界面据此说「两份都不生效」。
595
+ // 这里仍要打上 preferred,否则界面拿不到「取消首选」那颗按钮,用户就没法撤销这次选择。
596
+ if (preferred && preferred[canonicalName] === winner.root.key)
597
+ winner.view.preferred = true;
598
+ if (options.markShadowed !== false) {
599
+ // `enabled` 传赢家的启用状态:界面靠它区分「另一份在跑」与「两份都不生效」。
600
+ // 少了这个字段,被覆盖的一行只能说「同名技能 X 正在生效」—— 而 X 停用时那是假话。
601
+ const winnerEnabled = winner.policy.enabled === true;
602
+ for (const item of shadowed)
603
+ item.view.shadowedBy = {
604
+ root: winner.root.key,
605
+ name: winner.entry.name,
606
+ enabled: winnerEnabled,
607
+ };
608
+ }
609
+ if (options.markWinner !== false)
610
+ winner.view.winner = true;
611
+ winner.view.enabled = winner.policy.enabled;
612
+ winner.view.canonicalName = canonicalName;
613
+ }
614
+ return winners;
615
+ }
616
+ export async function state(options = {}) {
617
+ const policyResult = await readManagerState();
618
+ const removedKeys = new Set(Array.isArray(policyResult.state.removedSources)
619
+ ? policyResult.state.removedSources
620
+ : []);
621
+ // 已移除的来源仍然出现在 roots 里(界面要能显示「已移除」并允许恢复),但**不扫描**:
622
+ // 技能不入列表、不参与重名决胜 —— 这正是用户要的「不读取这个文件夹」。
623
+ // 注意这里必须用**全部**来源 + 自定义来源,不能只用 activeUserRoots(),
624
+ // 否则被移除的来源会连行都不剩,界面就无从提供「恢复读取」。
625
+ const user = [...userRoots(), ...customRootsFromState(policyResult.state)];
626
+ const scannable = user.filter((root) => !removedKeys.has(root.key));
627
+ const userScans = await scanDeduplicatedRoots(scannable);
628
+ const projectWarnings = [];
629
+ const scoped = await projectRoots(options.projectCwds, projectWarnings);
630
+ const trash = await listTrash();
631
+ const result = {
632
+ roots: [],
633
+ projects: [],
634
+ trash,
635
+ warnings: [
636
+ ...(policyResult.warning ? [policyResult.warning] : []),
637
+ ...projectWarnings,
638
+ ],
639
+ };
640
+ // 回收站没有自动清理:超龄条目通过 warnings 提示用户到 Skills 管理页处理,
641
+ // 避免静默删除用户可能还想恢复的内容。
642
+ const TRASH_STALE_DAYS = 30;
643
+ const staleTrashCount = trash.filter((item) => {
644
+ const deletedAt = Date.parse(String(item && item.deletedAt));
645
+ return Number.isFinite(deletedAt) && Date.now() - deletedAt > TRASH_STALE_DAYS * 86400000;
646
+ }).length;
647
+ if (staleTrashCount > 0)
648
+ result.warnings.push({
649
+ code: "warning.trash.stale",
650
+ params: { count: staleTrashCount, days: TRASH_STALE_DAYS },
651
+ error: `回收站中有 ${staleTrashCount} 个条目已超过 ${TRASH_STALE_DAYS} 天,建议到 Skills 管理页清理`,
652
+ });
653
+ const all = [];
654
+ for (const root of [...scoped, ...user]) {
655
+ const isRemoved = removedKeys.has(root.key);
656
+ const { exists, entries, truncated } = isRemoved
657
+ ? { exists: false, entries: [], truncated: false }
658
+ : root.scope === "project"
659
+ ? (await scanDeduplicatedRoots([root])).get(root.key)
660
+ : userScans.get(root.key);
661
+ if (isRemoved) {
662
+ // 已移除:登记一行(供界面显示与恢复),但没有任何技能。
663
+ result.roots.push({
664
+ key: root.key,
665
+ ...(root.kind ? { kind: root.kind } : {}),
666
+ ...(root.localeKey ? { localeKey: root.localeKey } : {}),
667
+ path: root.path,
668
+ label: root.label,
669
+ mutable: root.mutable,
670
+ deletable: root.deletable === true,
671
+ removable: !isDefaultSkillSource(root) && root.scope !== "project",
672
+ // 自定义来源(用户手加的目录):只有它能「永久删除」——按约定发现的来源删不掉。
673
+ custom: root.custom === true,
674
+ // 默认来源(dsh / hub):界面据此隐藏来源开关、只显示「可管理」标记。
675
+ defaultSource: isDefaultSkillSource(root),
676
+ toggleable: root.toggleable,
677
+ native: root.native,
678
+ rank: root.rank,
679
+ scope: root.scope || "user",
680
+ exists: false,
681
+ truncated: false,
682
+ removed: true,
683
+ enabled: false,
684
+ skills: [],
685
+ });
686
+ continue;
687
+ }
688
+ // 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
689
+ if (root.scope === "project" && !exists && root.kind !== "project-dsh")
690
+ continue;
691
+ if (truncated)
692
+ result.warnings.push({
693
+ code: "warning.scan.truncated",
694
+ params: { path: root.path },
695
+ error: `技能扫描达到遍历上限,部分技能未显示: ${root.path}`,
696
+ });
697
+ const skills = [];
698
+ for (const e of entries) {
699
+ const policy = effectiveSkillPolicy(policyResult, root, e);
700
+ const managerEnabled = policyResult.writable !== false &&
701
+ policy.sourceEnabled &&
702
+ policy.override !== false;
703
+ skills.push({
704
+ name: e.name,
705
+ declaredName: e.declaredName,
706
+ kind: e.kind,
707
+ description: e.description,
708
+ modelInvocable: e.modelInvocable,
709
+ userInvocable: e.userInvocable,
710
+ invocationPolicyValid: e.invocationPolicyValid,
711
+ hasFrontmatter: e.hasFrontmatter,
712
+ loadable: e.loadable,
713
+ managerEnabled,
714
+ managerOverride: policy.override === undefined ? null : policy.override,
715
+ effectiveModelInvocable: policy.modelInvocable,
716
+ effectiveUserInvocable: policy.userInvocable,
717
+ diagnostics: e.diagnostics,
718
+ path: e.docPath,
719
+ });
720
+ all.push({
721
+ root,
722
+ entry: e,
723
+ managerEnabled,
724
+ policy,
725
+ view: skills[skills.length - 1],
726
+ });
727
+ }
728
+ result.roots.push({
729
+ key: root.key,
730
+ ...(root.kind ? { kind: root.kind } : {}),
731
+ ...(root.localeKey ? { localeKey: root.localeKey } : {}),
732
+ path: root.path,
733
+ label: root.label,
734
+ mutable: root.mutable,
735
+ // 是否提供「删除」:dsh / hub / 项目级 .dsh/skills 为 true;
736
+ // 外部 Agent 与自定义绝对路径来源为 false(只读)。
737
+ deletable: root.deletable === true,
738
+ toggleable: root.toggleable,
739
+ native: root.native,
740
+ rank: root.rank,
741
+ scope: root.scope || "user",
742
+ ...(root.projectRoot
743
+ ? {
744
+ projectRoot: root.projectRoot,
745
+ projectName: root.projectName,
746
+ workspaceCwds: root.workspaceCwds,
747
+ }
748
+ : {}),
749
+ exists,
750
+ truncated: truncated === true,
751
+ // 界面用:能否从管理器「移除」(不再读取)。dsh / hub / 项目级不可移除。
752
+ removable: !isDefaultSkillSource(root) && root.scope !== "project",
753
+ // 自定义来源(用户手加的目录):只有它能「永久删除」——按约定发现的来源删不掉。
754
+ custom: root.custom === true,
755
+ // 默认来源(dsh / hub):界面据此隐藏来源开关(它们不能停用),换成「可管理」标记。
756
+ defaultSource: isDefaultSkillSource(root),
757
+ removed: false,
758
+ enabled: policyResult.writable !== false &&
759
+ (root.scope === "project" ||
760
+ isDefaultSkillSource(root) ||
761
+ policyResult.state.sources[root.key] !== false),
762
+ skills,
763
+ });
764
+ }
765
+ const userItems = all.filter((item) => item.root.scope !== "project");
766
+ const preferredSkills = policyResult.state.preferredSkills || null;
767
+ markWinners(userItems, { preferred: preferredSkills });
768
+ const projectGroups = new Map();
769
+ for (const item of all.filter((candidate) => candidate.root.scope === "project")) {
770
+ const group = projectGroups.get(item.root.projectRoot) || [];
771
+ group.push(item);
772
+ projectGroups.set(item.root.projectRoot, group);
773
+ }
774
+ for (const projectItems of projectGroups.values()) {
775
+ // 用户级条目参与该 workspace 的优先级判定,但不把其全局视图标记为“被某项目覆盖”。
776
+ const userCopies = userItems.map((item) => ({
777
+ ...item,
778
+ view: { ...item.view },
779
+ }));
780
+ markWinners([...projectItems, ...userCopies], { preferred: preferredSkills });
781
+ }
782
+ const seenProjects = new Set();
783
+ for (const root of result.roots.filter((item) => item.scope === "project")) {
784
+ if (seenProjects.has(root.projectRoot))
785
+ continue;
786
+ seenProjects.add(root.projectRoot);
787
+ result.projects.push({
788
+ root: root.projectRoot,
789
+ name: root.projectName,
790
+ workspaceCwds: root.workspaceCwds,
791
+ });
792
+ }
793
+ result.summary = {
794
+ total: all.length,
795
+ enabled: all.filter((item) => item.view.enabled === true).length,
796
+ disabled: all.filter((item) => item.entry.loadable && item.policy.enabled === false).length,
797
+ issues: all.reduce((count, item) => count + item.entry.diagnostics.length + (item.view.shadowedBy ? 1 : 0), 0),
798
+ };
799
+ return result;
800
+ }