dsh-plugin-tool-management 0.19.1 → 0.20.1

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 (66) hide show
  1. package/CHANGELOG.md +915 -829
  2. package/README.md +237 -254
  3. package/README_EN.md +234 -223
  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/images/logo.gif +0 -0
  21. package/docs/update.md +32 -30
  22. package/lib/client.js +9480 -6970
  23. package/lib/compat/preset-reach.js +5 -2
  24. package/lib/context-inject-frame.js +13 -8
  25. package/lib/hub.js +104 -11
  26. package/lib/index.js +72 -24
  27. package/lib/mcp/manager.js +45 -13
  28. package/lib/mcp/secret-guard.js +2 -2
  29. package/lib/mcp/state-section.js +1 -1
  30. package/lib/memories/archive-engine.js +4 -5
  31. package/lib/memories/constants.js +11 -4
  32. package/lib/memories/service.js +6 -1
  33. package/lib/op-registry.js +13 -11
  34. package/lib/ops/memory.js +42 -4
  35. package/lib/ops/scene-records.js +43 -9
  36. package/lib/ops/sessions.js +67 -46
  37. package/lib/ops/trash.js +107 -8
  38. package/lib/request-gate.js +9 -2
  39. package/lib/scene-prompt-sync.js +48 -23
  40. package/lib/sessions/history.js +32 -22
  41. package/lib/sessions/workspace.js +0 -10
  42. package/lib/skills/catalog.js +17 -5
  43. package/lib/skills/core.js +189 -7
  44. package/lib/skills/frontmatter.js +31 -0
  45. package/lib/skills/manager-state.js +73 -3
  46. package/lib/skills/service.js +53 -13
  47. package/lib/skills/skill-roots.js +54 -4
  48. package/lib/skills/skill-trash.js +118 -12
  49. package/lib/skills/upload-pipeline.js +110 -26
  50. package/lib/subagents/catalog.js +9 -5
  51. package/lib/subagents/service.js +44 -6
  52. package/lib/subagents/tools.js +45 -15
  53. package/lib/tool-table-switch.js +6 -6
  54. package/lib/tools/deps.js +43 -0
  55. package/lib/tools/mcp.js +74 -27
  56. package/lib/tools/memory.js +49 -9
  57. package/lib/tools/prompt.js +38 -9
  58. package/lib/tools/remove.js +206 -0
  59. package/lib/tools/scene.js +155 -32
  60. package/lib/tools/skills.js +162 -55
  61. package/lib/tools/subagent.js +65 -31
  62. package/lib/tools/table.js +39 -12
  63. package/lib/trash-id.js +16 -0
  64. package/lib/trash-retention.js +3 -3
  65. package/package.json +115 -114
  66. package/lib/mcp/inspect-probe.js +0 -199
@@ -269,6 +269,8 @@ export function defaultManagerState() {
269
269
  // 也不参与 provider 候选)。与「停用来源」不同——停用仍列出技能、只是不可调用。
270
270
  // 源文件一个字节都不动;清空这个数组即可恢复。
271
271
  removedSources: [],
272
+ // 用户从回收站「永久删除」的来源:连"已移除"这条记录都不再出现(见 core-types 的说明)。
273
+ forgottenSources: [],
272
274
  };
273
275
  }
274
276
  export function validStateSkillName(name) {
@@ -324,6 +326,12 @@ export function validManagerStateDocument(value) {
324
326
  value.removedSources.some((key) => typeof key !== "string" || key.length === 0 || key.length > 128))
325
327
  return false;
326
328
  }
329
+ // 永久删除的来源:同上(可选字段、同样的 key 形状)。
330
+ if (value.forgottenSources !== undefined) {
331
+ if (!Array.isArray(value.forgottenSources) ||
332
+ value.forgottenSources.some((key) => typeof key !== "string" || key.length === 0 || key.length > 128))
333
+ return false;
334
+ }
327
335
  // 同名首选表:可选字段;只校验容器与值类型,键值逐条在 normalize 里裁剪(见 validPreferredSkillName)。
328
336
  if (value.preferredSkills !== undefined) {
329
337
  if (!value.preferredSkills ||
@@ -426,6 +434,47 @@ export function normalizeManagerState(value) {
426
434
  !isDefaultSkillSource(key))),
427
435
  ].sort();
428
436
  }
437
+ // 永久删除的来源:同一套口径(已知 key、非默认来源),另外**从 removedSources 里摘掉**
438
+ // —— 一条来源要么"已移除"、要么"已永久删除",同时挂在两份名单上只会让两处显示打架。
439
+ {
440
+ const raw = Array.isArray(value.forgottenSources) ? value.forgottenSources : [];
441
+ const known = new Set([
442
+ ...userRoots().map((root) => root.key),
443
+ ...customKeys,
444
+ ]);
445
+ const forgotten = [
446
+ ...new Set(raw.filter((key) => typeof key === "string" &&
447
+ known.has(key) &&
448
+ !isDefaultSkillSource(key))),
449
+ ].sort();
450
+ normalized.forgottenSources = forgotten;
451
+ if (forgotten.length)
452
+ normalized.removedSources = normalized.removedSources.filter((key) => !forgotten.includes(key));
453
+ }
454
+ /*
455
+ * 自愈:同一个名字不许同时挂在 `disabledSkills` 与 `enabledSkills` 上 —— 那是
456
+ * `validManagerStateDocument` 判整份文档非法的一种形状。
457
+ *
458
+ * 为什么这里**自愈**而不是照旧报错:`readManagerState` 碰到非法文档会 fail-closed 停用
459
+ * **所有**技能,并且把 `writable` 一起置 false —— 用户在界面上连一个开关都按不动,等于被
460
+ * 锁死在一份自己造不出来的状态里(用户 2026-10-14 实测踩到,出这条冲突的链是
461
+ * 「显式启用 → 删除 → 从回收站恢复」,写入侧已在 `markSkillsDisabled` 修掉)。
462
+ * 而这条不一致本身是无害的:两份名单都只是"用户显式说过什么",选哪边都能让插件正常工作。
463
+ * 这与本函数既有的自愈口径一致(指向未知来源的首选、非法的自定义来源条目都是逐条丢掉)。
464
+ *
465
+ * 收敛方向取**保守的那一边**:保留 disabled、摘掉 enabled。反向(让 enabled 赢)会静默把
466
+ * 一个技能打开并送进模型上下文 —— 那才是危险方向;而"关着"用户看得见,翻一下开关就好。
467
+ * 这也正是 v0.8.5「新建 / 导入 / 恢复默认停用」本来要的落点。
468
+ */
469
+ for (const [key, disabled] of Object.entries(normalized.disabledSkills)) {
470
+ const enabled = normalized.enabledSkills[key];
471
+ if (!Array.isArray(disabled) || !disabled.length)
472
+ continue;
473
+ if (!Array.isArray(enabled) || !enabled.length)
474
+ continue;
475
+ const off = new Set(disabled);
476
+ normalized.enabledSkills[key] = enabled.filter((name) => !off.has(name));
477
+ }
429
478
  return normalized;
430
479
  }
431
480
  export function normalizeCustomRoots(value) {
@@ -605,6 +654,17 @@ export function markWinners(items, options = {}) {
605
654
  name: winner.entry.name,
606
655
  enabled: winnerEnabled,
607
656
  };
657
+ /*
658
+ * 契约:`shadowedBy.root === item.root.key` ⟺ 撞名发生在**同一个来源目录内部**
659
+ * (两个文件夹都写着 `name: X`),而不是"同一个技能在两个来源里各有一份"。
660
+ *
661
+ * 两者必须分得开,因为界面能采取的动作完全不同:跨来源时那颗「启用这个」写的是
662
+ * `preferredSkills[名] = 来源 key`(真的能换掉生效的那一份);同来源内部则怎么写都
663
+ * 指不出区别 —— 首选表的值就是来源 key,两份都在同一个来源里。用户 2026-10-14 报的
664
+ * 正是这颗**按下去什么都不会变**的假按钮(技能页把它摆在两份同名之间)。
665
+ * 界面按这个判据收掉按钮(见 client 的 `renderSkill`),但**保留"被覆盖"这个标记**:
666
+ * 同来源里的落选那份确实不会被加载,如实说比什么都不说好。
667
+ */
608
668
  }
609
669
  if (options.markWinner !== false)
610
670
  winner.view.winner = true;
@@ -618,11 +678,17 @@ export async function state(options = {}) {
618
678
  const removedKeys = new Set(Array.isArray(policyResult.state.removedSources)
619
679
  ? policyResult.state.removedSources
620
680
  : []);
681
+ // 「永久删除」过的来源(回收站 → 目录 里那条记录的终点):连行都不留 —— 界面不该再有
682
+ // 它的位置,「恢复读取」也无从谈起(用户 2026-10-12 要求 codex / Claude 也能永久删除)。
683
+ // 注意它同时也在 `activeUserRoots` 里被摘掉,所以下面这层过滤只影响**列表展示**。
684
+ const forgottenKeys = new Set(Array.isArray(policyResult.state.forgottenSources)
685
+ ? policyResult.state.forgottenSources
686
+ : []);
621
687
  // 已移除的来源仍然出现在 roots 里(界面要能显示「已移除」并允许恢复),但**不扫描**:
622
688
  // 技能不入列表、不参与重名决胜 —— 这正是用户要的「不读取这个文件夹」。
623
689
  // 注意这里必须用**全部**来源 + 自定义来源,不能只用 activeUserRoots(),
624
690
  // 否则被移除的来源会连行都不剩,界面就无从提供「恢复读取」。
625
- const user = [...userRoots(), ...customRootsFromState(policyResult.state)];
691
+ const user = [...userRoots(), ...customRootsFromState(policyResult.state)].filter((root) => !forgottenKeys.has(root.key));
626
692
  const scannable = user.filter((root) => !removedKeys.has(root.key));
627
693
  const userScans = await scanDeduplicatedRoots(scannable);
628
694
  const projectWarnings = [];
@@ -685,8 +751,12 @@ export async function state(options = {}) {
685
751
  });
686
752
  continue;
687
753
  }
688
- // 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
689
- if (root.scope === "project" && !exists && root.kind !== "project-dsh")
754
+ // 项目来源只在真的有技能条目时才注册(skill-roots.ts 的 projectRoots,用户
755
+ // 2026-10-15 裁定「无技能的项目来源不注册」);走到这里还不存在的项目根没有可显示的
756
+ // 内容,跳过。旧实现曾反过来 —— 「即使 .dsh/skills 不存在也把可写根返给创建表单」,
757
+ // 而创建落点 2026-10-15 已收敛到用户级目录(createRoots 排除 scope=project),
758
+ // 那条 carve-out 只剩把空来源泵进场景切换的副作用,故删。
759
+ if (root.scope === "project" && !exists)
690
760
  continue;
691
761
  if (truncated)
692
762
  result.warnings.push({
@@ -5,7 +5,7 @@
5
5
  // 沿用上游机制:全局层接管 + 每个活动 agent scope 内二次注册。
6
6
  import { appendFile, rename, rm, stat } from 'node:fs/promises';
7
7
  import { isInsideRootResolved } from '../paths.js';
8
- import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, updateSkill, skillDetail, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, } from './core.js';
8
+ import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, updateSkill, skillDetail, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, forgetSource, logPath, } from './core.js';
9
9
  import { watchDirectories } from './dir-watch.js';
10
10
  import { registerAgentSkillProviders, externalSkillProvider } from './provider-registration.js';
11
11
  import { permanentlyDeleteTrashSafely } from './system-trash.js';
@@ -79,6 +79,21 @@ function notifyChatCatalog(ctx, invalidateSkills) {
79
79
  }
80
80
  catch { /* 斜杠菜单刷新失败不影响已完成的操作 */ }
81
81
  }
82
+ /**
83
+ * 本插件**可写**的技能来源集合,共两员:
84
+ *
85
+ * - `hub`(`$DSH_HOME/tool-management/skills/`,界面名「导入技能」)—— 导入 / 上传的落点;
86
+ * - `dsh`(`$DSH_HOME/skills/`,界面名「DSH 技能」)—— **新建**技能的落点(用户裁定
87
+ * 2026-10-15:「新增技能默认 DSH 技能;导入技能功能才是导入技能目录」)。
88
+ *
89
+ * 其余根(官方内置 / agents / codex / claude / 自定义 / 项目)一律不碰。
90
+ *
91
+ * 这个集合曾经只有 `hub`:新建默认落点还在 hub 的年代那么判够用。落点改到 dsh 之后,
92
+ * 只认 hub 会把「新建 → 再存一次」整条回路掐断 —— 新建默认落 dsh,改的分支永远找不到
93
+ * hub 里的胜出者,每次都回 `error.update.notOwnRoot`。所以它与新建落点是同一条裁定
94
+ * 的两面,必须一起扩。
95
+ */
96
+ const PLUGIN_WRITABLE_ROOTS = new Set(['hub', 'dsh']);
82
97
  /** 全插件共用的审计日志:单一串行写队列,避免多处写同一文件交叉。 */
83
98
  let sharedLog = null;
84
99
  export function pluginLog() {
@@ -233,6 +248,8 @@ export function createSkillsService(ctx) {
233
248
  // 批量启停与单条启停同权:配了访问令牌的宿主必须同样要求带令牌。
234
249
  'skill-set-all',
235
250
  'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
251
+ // 「永久删除来源记录」(回收站 → 目录):与上面两条同权,是写操作。
252
+ 'skill-source-forget',
236
253
  ]);
237
254
  /**
238
255
  * 新建 / 导入 / 恢复技能后统一「默认停用」。**失败不能吞。**
@@ -310,23 +327,30 @@ export function createSkillsService(ctx) {
310
327
  return { enabled, changed, failed };
311
328
  }), afterWrite),
312
329
  // 创建 / 导入 / 删除 / 回收站
313
- // 创建默认落 hub(`tool-management/skills/`):UI 不传 root 时走这里;
314
- // core.js 的 createSkill 默认值是第二道保险(同口径)。官方 `~/.dsh/skills/` 仍作为来源列出。
330
+ // 创建默认落 **DSH 技能目录**(用户裁定 2026-10-15):v0.4 起这里是 hub,现在改回 dsh ——
331
+ // hub(`tool-management/skills/`)只服务「导入技能」功能。与 UI「创建技能」的
332
+ // `defaultCreateRoot`、core.js 的 createSkill 默认值同口径;core 里的那份是第二道保险。
333
+ // 落点变了但有一件事没变:`skill_manager_switch` / `_remove` 侧跨来源解析照旧兜得住(P0-1)。
315
334
  'skill-create': wrap((args) => write(async () => {
316
335
  // requestRoot 未命中时回传原始 key,让 core 的 error.root.unknown 带上来源名
317
336
  // (否则传了不存在的 root 会收到「技能来源不存在:(空)」这种指错方向的提示)。
318
- const key = String(args.root || 'hub');
319
- const res = await createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot(key)) || key });
337
+ const key = String(args.root || 'dsh');
338
+ const res = await createSkill({ name: args.name, description: args.description, body: args.body, files: args.files }, log, { root: (await requestRoot(key)) || key });
320
339
  // v0.8.5:新建技能默认不启动(用户裁定)——与子智能体 / MCP 同口径。
321
340
  if (res && res.ok !== false && res.name) {
322
341
  return disableNewSkills([{ root: String(res.root || 'hub'), name: String(res.name) }], res);
323
342
  }
324
343
  return res;
325
344
  }), afterWrite),
326
- // 改写**本插件自己写的那一份**技能。只认 hub 里的胜出者:同名技能在 dsh / agents /
327
- // claude / 自定义根各有一份时只有一份生效,改错那一份会返回 OK 而技能毫无变化
328
- // (用户实测踩过:同一技能四份并存,启错根等于没启)。胜出者判定要读来源排序与首选
329
- // 设置,属于状态层,所以在这里做;core 的 `updateSkill` 只保证不越出给定根。
345
+ // 改写**本插件自己写的那一份**技能。先跨来源找胜出者,再要求它落在本插件可写的两个根
346
+ // (hub / dsh,见 PLUGIN_WRITABLE_ROOTS)里:同名技能在 dsh / agents / claude / 自定义根
347
+ // 各有一份时只有一份生效,改错那一份会返回 OK 而技能毫无变化(用户实测踩过:同一技能
348
+ // 四份并存,启错根等于没启)。胜出者判定要读来源排序与首选设置,属于状态层,所以在这里
349
+ // 做;core 的 `updateSkill` 只保证不越出给定根。
350
+ //
351
+ // 门禁为什么不能只认 hub:新建默认落点 2026-10-15 改到 dsh 后,模型 `skill_manager_save`
352
+ // 建出来的技能全在 dsh;只认 hub 时改的分支永远找不到 hub 胜出者,「新建 → 再存一次」
353
+ // 直接回 error.update.notOwnRoot。可写集合跟着落点一起扩才闭环。
330
354
  'skill-update': wrap((args) => write(async () => {
331
355
  const wanted = String(args.name || '').trim();
332
356
  const s = await readState();
@@ -345,15 +369,17 @@ export function createSkillsService(ctx) {
345
369
  return { ok: false, code: 'error.update.notFound', params: { name: wanted }, error: `技能不存在:${wanted}` };
346
370
  }
347
371
  const winner = hits.find((h) => !h.shadowedBy) || hits[0];
348
- if (winner.rootKey !== 'hub') {
372
+ if (!PLUGIN_WRITABLE_ROOTS.has(winner.rootKey)) {
349
373
  return {
350
374
  ok: false,
351
375
  code: 'error.update.notOwnRoot',
352
376
  params: { name: wanted, root: winner.rootKey },
353
- error: `技能 ${wanted} 的生效副本在来源「${winner.rootKey}」,不在本插件落点(hub = $DSH_HOME/tool-management/skills):本插件不修改官方根里的技能`,
377
+ error: `技能 ${wanted} 的生效副本在来源「${winner.rootKey}」,不在本插件可写的目录(hub = 导入落点;dsh = $DSH_HOME/skills):本插件不修改官方根里的技能`,
354
378
  };
355
379
  }
356
- return updateSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot('hub')) || 'hub' });
380
+ // 写**胜出者所在的那个根**,不能写死 hub:同名两边各有一份时写错那份,
381
+ // 正是这道判定要防的「返回 OK 而技能毫无变化」。
382
+ return updateSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot(winner.rootKey)) || winner.rootKey });
357
383
  }), afterWrite),
358
384
  'skill-import': wrap((args) => write(async () => {
359
385
  const res = await importSkill(String(args.source || ''), log, {
@@ -367,6 +393,10 @@ export function createSkillsService(ctx) {
367
393
  'skill-upload': wrap((args) => write(async () => {
368
394
  const res = await importUploadedSkill({ name: args.name, entries: args.entries, zip: args.zip }, log, {
369
395
  conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
396
+ // 上传导入撞名一律退号新增(用户 2026-10-15):静默跳过会让用户想要的
397
+ // 那份根本没进来,结果里只多一行"已跳过"。目录导入(skill-import)
398
+ // 不带这个开关,仍是 skip / overwrite 二选一。
399
+ dedupe: true,
370
400
  });
371
401
  // 上传/导入同口径:默认停用。
372
402
  const names = res && Array.isArray(res.imported) ? res.imported : (res && res.name ? [res.name] : []);
@@ -374,8 +404,14 @@ export function createSkillsService(ctx) {
374
404
  }), afterWrite),
375
405
  'skill-delete': wrap((args) => write(async () => deleteSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), log)), afterWrite),
376
406
  'skill-trash-restore': wrap((args) => write(async () => {
377
- const res = await restoreTrash(String(args.id || ''), log, projectOptions());
407
+ // `dedupe: true` = 界面上"已存在同名技能,是否恢复为 foo-1"确认过之后那一次
408
+ // (用户 2026-10-14);不带时撞名照旧拒绝,并把候选名字放在 `params.next` 里回给弹窗。
409
+ const res = await restoreTrash(String(args.id || ''), log, {
410
+ ...projectOptions(),
411
+ dedupe: args.dedupe === true,
412
+ });
378
413
  // 恢复的技能默认停用(v0.8.5 用户裁定:新建/导入/恢复一律不启动,手动开启)。
414
+ // `res.name` 是**最终落盘的名字**(退号恢复时不是回收站里的原名),启停状态必须按它写。
379
415
  if (res && res.ok !== false && res.name) {
380
416
  return disableNewSkills([{ root: String(res.root || 'hub'), name: String(res.name) }], res);
381
417
  }
@@ -385,6 +421,10 @@ export function createSkillsService(ctx) {
385
421
  // 自定义技能目录:添加(绝对路径,只读接入)/ 移除(连同策略键)。
386
422
  'skill-custom-add': wrap((args) => write(() => addCustomRoot(args && args.path, args && args.label, log)), afterWrite),
387
423
  'skill-custom-remove': wrap((args) => write(() => removeCustomRoot(args && args.key, log)), afterWrite),
424
+ // 永久删除一条**按约定发现**的来源记录(agents / codex / claude)。与 `skill-custom-remove`
425
+ // 的分工见 core.ts 的 `forgetSource`:那条管用户手加的自定义目录,这条管推导出来的来源
426
+ // —— 后者不在状态文件的 customRoots 里,必须另记一份"已永久删除"名单才删得掉。
427
+ 'skill-source-forget': wrap((args) => write(() => forgetSource(args && args.root, log)), afterWrite),
388
428
  };
389
429
  /**
390
430
  * 该绝对路径是否落在「本插件认识的技能来源」内。
@@ -34,11 +34,13 @@ export function userRoots() {
34
34
  // 官方内置紧随 DSH 技能之后(用户裁定):同为一眼要看的来源,排在导入技能之前。
35
35
  ...(official === null ? [] : [official]),
36
36
  {
37
- // v0.4:插件**新建/导入**的技能落到 hub 内(用户要求:插件产生的文件收在
37
+ // v0.4:插件**导入/上传**的技能落到 hub 内(用户要求:插件产生的文件收在
38
38
  // $DSH_HOME/tool-management/)。rank 高于 DSH 技能,同名时 hub 版本遮蔽官方目录里的;
39
39
  // 官方 ~/.dsh/skills/ 仍作为可切换来源列出(不搬走、不删)。
40
40
  //
41
- // 界面名「导入技能」:它是插件导入/新建技能的落点,不是"管理器自己的一类技能"。
41
+ // 界面名「导入技能」:它是插件**导入/上传**的落点 —— v0.20.0 及以前新建也落这里,
42
+ // 2026-10-15 用户裁定「新增技能默认 DSH 技能,导入技能功能才用导入技能目录」后,
43
+ // 新建改走 DSH 技能目录(见 core.ts 的 createSkill 默认值)。
42
44
  // 与 dsh 同为默认来源:路径必须读取(不可移除、不可停用),里面的技能可删。
43
45
  key: "hub",
44
46
  path: join(resolveDshHome(), "tool-management", "skills"),
@@ -137,6 +139,46 @@ export async function projectSourceSafe(definition) {
137
139
  }
138
140
  return !(await overlapsUserSkillRoot(definition.path));
139
141
  }
142
+ /**
143
+ * 项目来源目录底下有没有至少一条技能条目(只看形状,不读正文):
144
+ * · 可写来源(`.dsh/skills`)—— 至少一个子目录里带 `SKILL.md`(与 scanEntries 的
145
+ * mutable 分支同形状:顶层 flat `.md` 只属于只读来源);
146
+ * · 只读来源(`.agents/skills`)—— 顶层有子目录(里面才可能藏 bundle)或 `.md` 文件
147
+ * (含根自身的 SKILL.md),与 discoverReadonlyEntries 同一形状。
148
+ *
149
+ * 目录缺失 / 是链接 / 读不了,以及点号开头的隐藏条目,一律按「没有」处理 —— 注册一个
150
+ * 没有技能的来源只有噪音:占一个来源位,还会在场景切换时产生「停用 project-dsh:<路径
151
+ * 哈希>」这类只有内部键的提示(用户 2026-10-15 报的)。
152
+ */
153
+ async function hasSkillEntry(path, mutable) {
154
+ const stat = await lstatOrNull(path);
155
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink())
156
+ return false;
157
+ let items;
158
+ try {
159
+ items = await fs.readdir(path, { withFileTypes: true });
160
+ }
161
+ catch {
162
+ return false;
163
+ }
164
+ for (const item of items) {
165
+ if (item.isSymbolicLink() || item.name.startsWith("."))
166
+ continue;
167
+ if (mutable) {
168
+ if (!item.isDirectory())
169
+ continue;
170
+ const doc = await lstatOrNull(join(path, item.name, "SKILL.md"));
171
+ if (doc && doc.isFile() && !doc.isSymbolicLink())
172
+ return true;
173
+ continue;
174
+ }
175
+ if (item.isDirectory())
176
+ return true;
177
+ if (item.isFile() && item.name.toLowerCase().endsWith(".md"))
178
+ return true;
179
+ }
180
+ return false;
181
+ }
140
182
  export async function projectRoots(projectCwds = [], diagnostics) {
141
183
  const projects = new Map();
142
184
  for (const cwd of Array.isArray(projectCwds) ? projectCwds : []) {
@@ -207,8 +249,16 @@ export async function projectRoots(projectCwds = [], diagnostics) {
207
249
  },
208
250
  ];
209
251
  for (const candidate of candidates) {
210
- if (await projectSourceSafe(candidate))
211
- roots.push(candidate);
252
+ if (!(await projectSourceSafe(candidate)))
253
+ continue;
254
+ // 无技能的项目来源不注册(用户 2026-10-15 裁定):目录不存在、是链接或底下没有
255
+ // 一条技能条目时,这个来源对用户不可见也不可用 —— 硬注册出来只会平白占一个来源位、
256
+ // 在场景切换时弹「停用 project-dsh:<路径哈希>」这类只有内部键的提示,而新建技能的
257
+ // 落点也早已不含项目来源(createRoots 只收用户级)。本仓库就没有 .dsh/skills,
258
+ // 用户就是在场景预览卡上看到这一条才裁的。
259
+ if (!(await hasSkillEntry(candidate.path, candidate.mutable === true)))
260
+ continue;
261
+ roots.push(candidate);
212
262
  }
213
263
  }
214
264
  return roots;
@@ -9,8 +9,11 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
9
9
  import { promises as fs } from "node:fs";
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { isSameOrDescendant } from "../paths.js";
12
- import { codedError, entryPath, lstatOrNull, pathIdentity, renameWithRetry, } from "./core-util.js";
12
+ import { codedError, entryPath, lstatOrNull, pathIdentity, renameWithRetry, temporaryPath, } from "./core-util.js";
13
+ import { rewriteDeclaredName } from "./frontmatter.js";
13
14
  import { dshRootPath, overlapsUserSkillRoot, projectIdentity, projectRoots, readonlyError, rootByKey, rootDefinition, skillCreateRootPath, trashRootPath, } from "./skill-roots.js";
15
+ // `KEBAB_RE` 的单一真相源在 upload-pipeline(manager-state / core 也都从那里取)。
16
+ import { KEBAB_RE } from "./upload-pipeline.js";
14
17
  export function writableRootDefinition(root) {
15
18
  const definition = rootDefinition(root);
16
19
  if (!definition || definition.mutable !== true)
@@ -19,7 +22,7 @@ export function writableRootDefinition(root) {
19
22
  return resolve(definition.path) === resolve(dshRootPath())
20
23
  ? rootByKey("dsh")
21
24
  : null;
22
- // v0.4:hub 内技能目录同为用户级可写根(插件新建/导入的落点)。
25
+ // v0.4:hub 内技能目录同为用户级可写根(插件导入/上传的落点)。
23
26
  if (definition.key === "hub")
24
27
  return resolve(definition.path) === resolve(skillCreateRootPath())
25
28
  ? rootByKey("hub")
@@ -204,6 +207,75 @@ export async function listTrash() {
204
207
  }
205
208
  return result.sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt)));
206
209
  }
210
+ /**
211
+ * 目标根里第一个空出来的退号名:`foo` → `foo-1` → `foo-2`…(与界面 `uniqueName` 的
212
+ * `'dash'` 档同一写法)。
213
+ *
214
+ * 用半角连字符而不是全角括号:技能条目名必须过 `KEBAB_RE`,`foo(1)` 会被 `toKebab` 抹成
215
+ * `foo-1` —— 写出来本来就是 `foo-1`,不如一开始就按这个写,省得"看着叫甲、落盘叫乙"。
216
+ *
217
+ * 候选本身不合法(超长 / 空)时直接放弃:再往后试也不会变合法。
218
+ */
219
+ async function nextFreeSkillName(root, name) {
220
+ for (let i = 1; i <= 999; i += 1) {
221
+ const candidate = `${name}-${i}`;
222
+ if (entryPath(root, candidate) === null)
223
+ return null;
224
+ if ((await safeExistingEntryPaths(root, candidate)).length === 0)
225
+ return candidate;
226
+ }
227
+ return null;
228
+ }
229
+ /**
230
+ * 退号恢复时把技能**声明名**改成新的落盘名(用户 2026-10-14)。
231
+ *
232
+ * 为什么必须改:`canonicalSkillName()` 取的是 frontmatter 里的 `name:`,**不是文件夹名** ——
233
+ * 技能页的同名判定、`preferredSkills` 首选表、喂给宿主的 provider 候选名,全都以它为准。
234
+ * 只把目录改成 `foo-1` 而里面仍写着 `name: foo`,等于恢复出一份"看着叫 foo-1、其实还是 foo"
235
+ * 的技能:它会跟原件被判成同名,技能页于是给它一颗「启用这个」——而那颗按钮挑的是**来源**,
236
+ * 两份都在同一个来源目录里,按下去什么都不会变。
237
+ *
238
+ * 只重写 `name:` 那一行的值,其余 frontmatter(块标量、`disable-model-invocation` 等)与正文
239
+ * **逐字节保留**(正则只吃一个行内片段,连 CRLF 都不动):恢复的语义是"把原件放回去",
240
+ * 不是"按当前模板重写一遍"——后者会顺手把用户写在别的键上的东西抹掉。
241
+ *
242
+ * 不动内容的情形一律返回 false(调用方只用来记日志):名字不过 `KEBAB_RE`、形态认不出、
243
+ * 没有 frontmatter、原本就没有 `name:` 行、声明名本来就对。**认不出就一个字节都不写** ——
244
+ * 猜错形态会把附件目录里的 SKILL.md 当成技能文档改掉;替用户补一个缺失的 `name:` 也不行,
245
+ * 那会把一份坏技能伪装成好的。
246
+ */
247
+ async function retitleRestoredSkill(root, name, metadata) {
248
+ if (!KEBAB_RE.test(name))
249
+ return false;
250
+ // 形态与上面的 `entryNameFor` 同一口径:bundle 收的是 `<原名>`,flat 收的是 `<原名>.md`。
251
+ const isBundle = metadata.entries.includes(metadata.name);
252
+ const isFlat = metadata.entries.includes(`${metadata.name}.md`);
253
+ // 两个都认得出或都认不出 = 形状不明,不猜。
254
+ if (isBundle === isFlat)
255
+ return false;
256
+ const docPath = isBundle
257
+ ? join(root, name, "SKILL.md")
258
+ : resolve(root, `${name}.md`);
259
+ const doc = await fs.readFile(docPath, "utf8");
260
+ // 声明名改写走 frontmatter.ts 的共享助手(导入撞名的退号也用它):只换 `name:`
261
+ // 一行的值,其余逐字节保留;认不出形态返回 null —— 一个字节都不写。
262
+ // 不加引号:`KEBAB_RE` 已经保证这个名字只含 `[a-z0-9-]`,没有任何需要转义的字符 ——
263
+ // 与插件自己的写手(`createSkill` / `updateSkill` 的 `name: ${name}`)同一形状,
264
+ // 免得"新建出来叫 demo-1、恢复出来叫 \"demo-1\""这种无谓的差别。
265
+ const content = rewriteDeclaredName(doc, name);
266
+ if (content === null)
267
+ return false;
268
+ const temp = temporaryPath(docPath, "restore");
269
+ try {
270
+ await fs.writeFile(temp, content, "utf8");
271
+ await renameWithRetry(temp, docPath);
272
+ }
273
+ catch (error) {
274
+ await fs.rm(temp, { force: true }).catch(() => undefined);
275
+ throw error;
276
+ }
277
+ return true;
278
+ }
207
279
  export async function restoreTrash(id, log, options = {}) {
208
280
  const metadata = await readTrashMetadata(id);
209
281
  if (!metadata)
@@ -217,31 +289,65 @@ export async function restoreTrash(id, log, options = {}) {
217
289
  if (!definition || definition.ok === false)
218
290
  return definition || readonlyError("restore");
219
291
  const root = definition.path;
292
+ /*
293
+ * 同名先问一句(用户 2026-10-14):撞名时**默认仍拒绝**,但把候选名字一起回(`params.next`),
294
+ * 界面弹窗才能如实写出"确认后会叫 `foo-1`";用户确认后带 `dedupe: true` 重来,这一次真的
295
+ * 按退号的名字落盘。
296
+ *
297
+ * `dedupe` 缺省即旧行为 —— 这条 op 目前只有界面在用,但保留"缺省拒"这层是为了不给未来的
298
+ * 调用方埋一个"撞名就悄悄改名"的默认值。
299
+ */
300
+ let name = metadata.name;
220
301
  const conflicts = await safeExistingEntryPaths(root, metadata.name);
221
- if (conflicts.length)
222
- return {
223
- ok: false,
224
- error: `无法恢复,同名技能已存在: ${metadata.name}`,
225
- code: "error.trash.conflict",
226
- params: { name: metadata.name },
227
- };
302
+ if (conflicts.length) {
303
+ const candidate = await nextFreeSkillName(root, metadata.name);
304
+ if (options.dedupe !== true || candidate === null)
305
+ return {
306
+ ok: false,
307
+ error: `已存在同名技能: ${metadata.name}`,
308
+ // 专用错误码(四个回收站统一):界面按它认出"这是撞名"并弹确认窗,而不是靠比文案。
309
+ code: "error.dup.conflict",
310
+ params: { name: metadata.name, ...(candidate ? { next: candidate } : {}) },
311
+ };
312
+ name = candidate;
313
+ }
314
+ const renamed = name !== metadata.name;
315
+ /**
316
+ * 回收站条目里的**顶层条目名**跟着退号,其余(bundle 目录里的附件 / 脚本)原样 ——
317
+ * `entries` 装的是当初移进回收站时那批顶层名字:bundle 是 `<名>`,flat 是 `<名>.md`
318
+ * (见 core.ts 的 deleteSkill)。只有这两个与名字有关,别的名字改了就是改坏内容。
319
+ */
320
+ const entryNameFor = (fileName) => {
321
+ if (!renamed)
322
+ return fileName;
323
+ if (fileName === metadata.name)
324
+ return name;
325
+ if (fileName === `${metadata.name}.md`)
326
+ return `${name}.md`;
327
+ return fileName;
328
+ };
228
329
  await fs.mkdir(root, { recursive: true });
229
330
  const itemRoot = join(trashRootPath(), id);
230
331
  const moved = [];
231
332
  try {
232
333
  for (const fileName of metadata.entries) {
233
334
  const source = join(itemRoot, fileName);
234
- const destination = join(root, fileName);
335
+ const destination = join(root, entryNameFor(fileName));
235
336
  if (!isSameOrDescendant(itemRoot, source) ||
236
337
  !isSameOrDescendant(root, destination))
237
338
  throw codedError("回收站条目路径非法", "error.trash.invalid", { id });
238
339
  await movePathWithFallback(source, destination, options.renameOptions);
239
340
  moved.push({ source, destination });
240
341
  }
342
+ // 顶层名字改完,文档里声明的名字也要跟上 —— 否则"看着叫 foo-1、其实还是 foo",
343
+ // 技能页仍按 foo 判同名。失败即抛:半份的恢复(目录改了、声明没改)正是要修的那个 bug。
344
+ if (renamed && (await retitleRestoredSkill(root, name, metadata)))
345
+ if (log)
346
+ log("restore", `恢复技能时改写声明名 ${metadata.name} -> ${name}`);
241
347
  await fs.rm(itemRoot, { recursive: true, force: true });
242
348
  if (log)
243
- log("restore", `从回收站恢复 ${metadata.name} -> ${root}`);
244
- return { id, name: metadata.name, root: trashRootMetadata(definition) };
349
+ log("restore", `从回收站恢复 ${name} -> ${root}`);
350
+ return { id, name, renamed, root: trashRootMetadata(definition) };
245
351
  }
246
352
  catch (error) {
247
353
  for (const item of moved.reverse())