dsh-plugin-tool-management 0.20.0 → 0.20.2

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 (62) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +3 -1
  3. package/README_EN.md +1 -1
  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 +2 -0
  22. package/lib/client.js +4040 -2446
  23. package/lib/compat/preset-reach.js +5 -2
  24. package/lib/context-inject-frame.js +13 -8
  25. package/lib/hub.js +8 -5
  26. package/lib/index.js +56 -20
  27. package/lib/mcp/manager.js +21 -0
  28. package/lib/mcp/state-section.js +1 -1
  29. package/lib/memories/archive-engine.js +4 -5
  30. package/lib/memories/constants.js +2 -2
  31. package/lib/op-registry.js +7 -5
  32. package/lib/ops/memory.js +42 -4
  33. package/lib/ops/scene-records.js +43 -9
  34. package/lib/ops/sessions.js +5 -39
  35. package/lib/ops/trash.js +45 -5
  36. package/lib/request-gate.js +9 -2
  37. package/lib/scene-prompt-sync.js +48 -23
  38. package/lib/scenes/candidates.js +5 -1
  39. package/lib/sessions/workspace.js +0 -10
  40. package/lib/skills/catalog.js +17 -5
  41. package/lib/skills/core.js +121 -5
  42. package/lib/skills/frontmatter.js +31 -0
  43. package/lib/skills/manager-state.js +41 -2
  44. package/lib/skills/service.js +46 -12
  45. package/lib/skills/skill-roots.js +54 -4
  46. package/lib/skills/skill-trash.js +118 -12
  47. package/lib/skills/upload-pipeline.js +110 -26
  48. package/lib/subagents/catalog.js +9 -5
  49. package/lib/subagents/service.js +44 -6
  50. package/lib/subagents/tools.js +45 -15
  51. package/lib/tool-table-switch.js +6 -6
  52. package/lib/tools/deps.js +43 -0
  53. package/lib/tools/mcp.js +68 -27
  54. package/lib/tools/memory.js +49 -9
  55. package/lib/tools/prompt.js +26 -9
  56. package/lib/tools/remove.js +206 -0
  57. package/lib/tools/scene.js +18 -13
  58. package/lib/tools/skills.js +162 -55
  59. package/lib/tools/subagent.js +65 -31
  60. package/lib/tools/table.js +39 -12
  61. package/lib/trash-id.js +16 -0
  62. package/package.json +4 -3
package/lib/ops/trash.js CHANGED
@@ -34,6 +34,21 @@ export function buildTrashOps(rc) {
34
34
  invalidateSnapshot();
35
35
  return { ok: true, trashId };
36
36
  }
37
+ /**
38
+ * 同组里第一个空出来的退号名:`测` → `测(1)` → `测(2)`…(全角括号,与界面 `uniqueName`
39
+ * 同一写法)。超过段名长度上限的候选直接跳过 —— 退号名本身也必须是合法段名,否则
40
+ * `locateRule` 查不到、恢复时还会写出一个磁盘不接受的名字。
41
+ */
42
+ async function nextFreeRuleName(group, name) {
43
+ for (let i = 1; i <= 999; i += 1) {
44
+ const candidate = `${name}(${i})`;
45
+ if (!isValidGroupSegment(candidate))
46
+ return null;
47
+ if (!(await locateRule(group, candidate)))
48
+ return candidate;
49
+ }
50
+ return null;
51
+ }
37
52
  async function rulesRestore(args) {
38
53
  const trashId = String((args && args.trashId) || '');
39
54
  if (!isValidTrashId(trashId))
@@ -56,11 +71,29 @@ export function buildTrashOps(rc) {
56
71
  || (manifest.group !== '' && !isValidGroupPath(manifest.group))) {
57
72
  return fail('error.rules.notFound', `回收站条目损坏:${trashId}`);
58
73
  }
59
- // 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
74
+ // 恢复目标已存在(期间用户重建了同名规则)→ 默认拒绝,避免覆盖。
75
+ //
76
+ // 「同名先问一句」(用户 2026-10-14):界面上不该静默退号、也不该只丢一句错误让人自己
77
+ // 回去改名。所以拒绝时**连候选名字一起回**(`params.next`)—— 弹窗才能如实写出"确认后
78
+ // 会叫 `测(1)`"。用户点确认后带 `dedupe: true` 再来一次,这一次真的按退号的名字恢复。
79
+ //
80
+ // `dedupe` 是**可选入参、缺省即旧行为**:模型工具(memory_manager_*)走的是同一个 op,
81
+ // 它们依赖"撞名就拒"来决定新建还是更新,不能因为界面想要退号就改掉这条语义。
60
82
  const conflict = await locateRule(manifest.group, manifest.name);
83
+ let name = manifest.name;
61
84
  if (conflict) {
62
- return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
85
+ const next = await nextFreeRuleName(manifest.group, manifest.name);
86
+ if (args?.dedupe !== true || !next) {
87
+ // 专用错误码(四个回收站统一):界面按它认出"这是撞名",据此弹确认窗 —— 而不是靠
88
+ // 比文案。`error.rules.exists` 留给 `rules-create`,那条是"新建撞名",语义不同。
89
+ return fail('error.dup.conflict', `已存在同名记忆:${conflict.id}`, {
90
+ name: manifest.name,
91
+ ...(next ? { next } : {}),
92
+ });
93
+ }
94
+ name = next;
63
95
  }
96
+ const renamed = name !== manifest.name;
64
97
  const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
65
98
  // 落点根内断言(realpath 口径):`isValidGroupPath` 只挡 `..` 与分隔符,挡不住
66
99
  // 「中间目录是指向根外的链接」。与 `rulesRemove` 的删除前断言同一道闸。
@@ -74,8 +107,8 @@ export function buildTrashOps(rc) {
74
107
  // 去删目录。场景回收站(`sceneTrashRestore`)早有这条回滚(`moveIntoTrash` 反向搬回),
75
108
  // 记忆回收站这条缺了。这里方向相反:源还在回收站,所以回滚是**删掉没放完整的副本**。
76
109
  const target = form === 'bundle'
77
- ? join(groupDir, manifest.name)
78
- : join(groupDir, manifest.name + '.md');
110
+ ? join(groupDir, name)
111
+ : join(groupDir, name + '.md');
79
112
  try {
80
113
  await mkdir(groupDir, { recursive: true });
81
114
  if (form === 'bundle')
@@ -102,7 +135,14 @@ export function buildTrashOps(rc) {
102
135
  }
103
136
  await rm(trashDir, { recursive: true, force: true });
104
137
  invalidateSnapshot();
105
- return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
138
+ // 回传**最终落盘的名字**:退号恢复时它与回收站里的原名不同,界面要按它来回执
139
+ // (见 42-shared-ui.js 的 `resultRestore`),否则用户看到的是"已恢复:测"而磁盘上叫「测(1)」。
140
+ return {
141
+ ok: true,
142
+ name,
143
+ renamed,
144
+ rule: await buildProjected(manifest.group ? `${manifest.group}/${name}` : name),
145
+ };
106
146
  }
107
147
  /**
108
148
  * 记忆回收站列表(只读):`<stateDir>/trash/memories-trash/<trashId>/`(`rulesRemove` 移入,
@@ -138,13 +138,20 @@ export function createSceneLock(deps) {
138
138
  return null;
139
139
  }
140
140
  }
141
- /** 「当前场景已锁定」的拒绝文案(模型工具用;无活动场景 / 未锁定 → null)。 */
141
+ /**
142
+ * 「当前场景已锁定」的拒绝文案(模型工具用;无活动场景 / 未锁定 → null)。
143
+ *
144
+ * ⚠️ 读者是**模型**,不是面板用户:这句只陈述事实(哪个场景、被拒、可预期的规则),
145
+ * **不指路 UI**。「到场景页解锁」是给坐在界面前的人看的,模型照着做不了 —— 它只会
146
+ * 把这句读成"再试一次也许就好了",于是重试同一次写入。要用户去解锁,由模型转述。
147
+ * (与 `src/tools/*.ts` 里那批回执同一条纪律;面板侧的中文报错是另一回事,那些读者是人。)
148
+ */
142
149
  async function lockedSceneGuard() {
143
150
  const scene = await activeSceneName();
144
151
  if (!scene)
145
152
  return null;
146
153
  const locked = await lockedSceneNames();
147
- return locked.includes(scene) ? `场景「${scene}」已锁定:先到场景页解锁再改。` : null;
154
+ return locked.includes(scene) ? `场景「${scene}」已锁定,本次写入被拒:锁定期间该域不可修改,需用户解锁后再试。` : null;
148
155
  }
149
156
  return { lockedSceneNames, activeSceneName, lockedSceneGuard };
150
157
  }
@@ -173,30 +173,47 @@ export function createScenePromptSync(deps) {
173
173
  }
174
174
  return complete ? out : null;
175
175
  }
176
+ /**
177
+ * 进场景的第一笔写入之前,把当时的基线记下来(已有待恢复的基线就不覆盖:连续切场景时
178
+ * 基线始终是「进场景之前」那一份)。
179
+ * @returns 需要让用户知道的原因(写不进去 / 记下来是空的);一切正常返回 undefined。
180
+ */
181
+ async function captureBaseline(scene) {
182
+ const saved = await readBaseline();
183
+ if (saved && saved.content !== null)
184
+ return undefined;
185
+ const cur = await deps.prompts.getCurrent();
186
+ const content = cur && cur.ok && cur.exists ? String(cur.content) : null;
187
+ const failed = await writeBaseline({
188
+ v: 1,
189
+ at: Date.now(),
190
+ presetId: cur && cur.ok && cur.presetId ? String(cur.presetId) : null,
191
+ content,
192
+ });
193
+ // 基线写不进去 = 退出场景时恢复不回来。**照旧进场景**(那是用户的要求),
194
+ // 但必须当场说出来,别让用户以为还能退回去(审查 F14)。
195
+ if (failed !== null) {
196
+ return `进场景前的提示词基线没能写入(${failed}):AGENTS.md 已切到场景「${scene}」绑定的预设,但退出场景时恢复不回原来的内容(${deps.baselineFile})。`;
197
+ }
198
+ // 记下来的是**空的**(进场景时 AGENTS.md 就不存在、或读不出来):退出场景时没有正文
199
+ // 可写回,AGENTS.md 会留在场景态。与「写失败」是两件事,后果一样,同样当场说
200
+ // (2026-10-15 smoke-test P0-2:这一种此前要等到退出之后,才被误报成「快照损坏」)。
201
+ if (content === null) {
202
+ return `进场景前的 AGENTS.md 没有内容可记(文件不存在或读不出来):退出场景时恢复不回原来的状态,AGENTS.md 会留在场景「${scene}」绑定的预设上。`;
203
+ }
204
+ return undefined;
205
+ }
176
206
  async function sync() {
177
207
  const current = await state();
178
208
  if (!current)
179
209
  return { unchanged: true };
180
210
  if (drives(current)) {
181
- if (current.duplicate)
182
- return { applied: current.presetId, unchanged: true };
183
- // 进场景的第一笔写入之前,先记下当时的基线(已有快照就不覆盖:连续切场景时
184
- // 基线始终是"进场景之前"那一份)。
185
- let baselineError;
186
- const saved = await readBaseline();
187
- if (!saved || saved.content === null) {
188
- const cur = await deps.prompts.getCurrent();
189
- const failed = await writeBaseline({
190
- v: 1,
191
- at: Date.now(),
192
- presetId: cur && cur.ok && cur.presetId ? String(cur.presetId) : null,
193
- content: cur && cur.ok && cur.exists ? String(cur.content) : null,
194
- });
195
- // 基线写不进去 = 退出场景时恢复不回来。**照旧进场景**(那是用户的要求),
196
- // 但必须当场说出来,别让用户以为还能退回去(审查 F14)。
197
- if (failed !== null) {
198
- baselineError = `进场景前的提示词基线没能写入(${failed}):AGENTS.md 已切到场景「${current.scene}」绑定的预设,但退出场景时恢复不回原来的内容(${deps.baselineFile})。`;
199
- }
211
+ // 基线必须在 `duplicate` 早退**之前**记(2026-10-15 smoke-test P0-2):早退省的只是
212
+ // 「AGENTS.md 不用再写一遍」,不是「不用留退路」。原先这一句在早退之后,于是
213
+ // 「进一个内容已经等于该预设的场景」完全不记基线 —— 退出时恢复不回原内容。
214
+ const baselineError = await captureBaseline(current.scene);
215
+ if (current.duplicate) {
216
+ return { applied: current.presetId, unchanged: true, ...(baselineError ? { baselineError } : {}) };
200
217
  }
201
218
  const res = await deps.prompts.apply(current.presetId);
202
219
  if (res && res.ok === false)
@@ -205,16 +222,24 @@ export function createScenePromptSync(deps) {
205
222
  }
206
223
  // 没有场景驱动 → 如果刚从驱动态退出,把进场景前的基线写回去。
207
224
  const saved = await readBaseline();
208
- if (!saved || saved.content === null) {
209
- // 「没有快照」是常态(没进过场景);「快照文件在、但读不出来」是**故障** ——
210
- // 那意味着恢复目标丢了,AGENTS.md 的提示词基线会悄悄留在场景态。这里曾经一律
211
- // 静默 `unchanged`,用户看到的是"没场景驱动所以什么都没做"(审查 P2-16)。
225
+ // 「快照文件在、但读不出来」是**故障** —— 那意味着恢复目标丢了,AGENTS.md 的提示词基线
226
+ // 会悄悄留在场景态。这里曾经一律静默 `unchanged`,用户看到的是"没场景驱动所以什么都没做"
227
+ // (审查 P2-16)。但两种"没有正文"必须分开(2026-10-15 smoke-test P0-2):
228
+ // · `saved === null` → 文件缺失 / 半截 JSON / 解析失败;
229
+ // · `saved.content === null` → 下面 `writeBaseline({presetId:null, content:null})` 写下的
230
+ // 「没有待恢复基线」哨兵,是**正常终态**(每次正常退出场景之后都会留下它)。
231
+ // 原先两者共用 `!saved || saved.content === null` 一个条件,于是每次正常退出之后,下一次
232
+ // 无场景驱动的同步都把哨兵判成损坏,报一句假 WARN —— 而假 WARN 长得和真故障一样,
233
+ // 反过来把"恢复目标真丢了"这条信号淹掉。
234
+ if (saved === null) {
212
235
  if (await baselineExists()) {
213
236
  warn(`scene-prompt: baseline snapshot exists but is unreadable: ${deps.baselineFile}`);
214
237
  return { error: `基线快照读不出来(文件可能损坏),未能恢复 AGENTS.md:${deps.baselineFile}` };
215
238
  }
216
239
  return { unchanged: true };
217
240
  }
241
+ if (saved.content === null)
242
+ return { unchanged: true };
218
243
  const res = await deps.prompts.restore(saved.content);
219
244
  if (res && res.ok === false)
220
245
  return { error: String(res.error || '恢复 AGENTS.md 失败') };
@@ -155,8 +155,11 @@ export function createCandidates(deps) {
155
155
  }
156
156
  }
157
157
  /**
158
- * 记忆候选(档案编辑器第 4 段「记忆」):`[{ id, scene, name, description }]`。
158
+ * 记忆候选(档案编辑器第 4 段「记忆」):`[{ id, scene, name, description, attach? }]`。
159
159
  * 只读、不读正文(勾选集只存 id);任何异常降级为空列表,不让档案弹窗崩掉。
160
+ *
161
+ * `attach` 是宿主在 `rules-list` 里给 bundle 记忆补的附件摘要,这里原样带过去 ——
162
+ * 场景编辑器要在行上标一枚回形针,让用户看见"勾这条会连带把参考资料一起带进场景"。
160
163
  */
161
164
  async function memoryCandidates() {
162
165
  try {
@@ -170,6 +173,7 @@ export function createCandidates(deps) {
170
173
  scene: String(x.group || ''),
171
174
  name: String(x.name || ''),
172
175
  description: String(x.description || ''),
176
+ ...(x.attach ? { attach: x.attach } : {}),
173
177
  }));
174
178
  }
175
179
  catch {
@@ -544,16 +544,6 @@ var ArchiveWorkspaceRegistry = class {
544
544
  }
545
545
  return { attached, attachSkipped };
546
546
  }
547
- /**
548
- * 归档委托宿主。官方 UI 不经过本方法,而由 domain/changed 事件
549
- * 同步归档时刻;重复归档不重置保留期。
550
- */
551
- async archiveSession(sessionId) {
552
- // 委托宿主原生入口;宿主没有原生入口时走本门面的适配路径(能力探测决定)。
553
- this.bridge.checkWorkspace("archive");
554
- await this.registry.archiveSession(sessionId);
555
- await this.reconcileArchiveLedger();
556
- }
557
547
  /**
558
548
  * 列出已归档会话的展示元数据:sessionId / createdAt / cwd / title / archivedAt。
559
549
  * title 经投影缓存 best-effort 读取(cachedSnapshot 的标题行,旧格式记录退
@@ -93,17 +93,22 @@ export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES,
93
93
  export function createSkillCatalog(deps, opts = {}) {
94
94
  const ttlMs = opts.ttlMs ?? 30_000;
95
95
  const now = opts.now ?? (() => Date.now());
96
- let value = '';
96
+ // 缓存的是**清单数据**而不是渲染结果:清单本体只依赖磁盘(一次读盘),而尾句要不要点名那个
97
+ // 查询工具取决于**这个 agent 手里有没有它**。渲染是纯字符串操作,代价可忽略;这个布尔就
98
+ // 两档,于是"每个 step 同步渲染"退化成一次查表(与人设目录按深度缓存同一手法)。
99
+ let data = null;
97
100
  let loadedAt = Number.NEGATIVE_INFINITY;
98
101
  let inflight = null;
102
+ let rendered = null;
99
103
  const recompute = async () => {
100
104
  try {
101
105
  const result = await deps.state();
102
106
  if (result && result.ok !== false) {
103
- value = renderSkillCatalog(result.data ?? result, opts.maxEntries, opts.maxDescription, deps.listToolVisible ? deps.listToolVisible() : true);
107
+ data = result.data ?? result;
108
+ rendered = null;
104
109
  }
105
110
  }
106
- catch { /* 保留上一次的值;首次失败则维持 '' */ }
111
+ catch { /* 保留上一次的值;首次失败则维持 null */ }
107
112
  loadedAt = now();
108
113
  };
109
114
  const revalidate = () => {
@@ -113,10 +118,17 @@ export function createSkillCatalog(deps, opts = {}) {
113
118
  return inflight;
114
119
  };
115
120
  return {
116
- text: () => {
121
+ text: (agent) => {
117
122
  if (now() - loadedAt > ttlMs)
118
123
  void revalidate();
119
- return value;
124
+ if (data === null)
125
+ return '';
126
+ const visible = deps.listToolVisible ? deps.listToolVisible(agent) : true;
127
+ if (rendered && rendered.visible === visible)
128
+ return rendered.text;
129
+ const out = renderSkillCatalog(data, opts.maxEntries, opts.maxDescription, visible);
130
+ rendered = { visible, text: out };
131
+ return out;
120
132
  },
121
133
  refresh: () => {
122
134
  loadedAt = Number.NEGATIVE_INFINITY;
@@ -17,6 +17,9 @@ import { promises as fs, } from "node:fs";
17
17
  import { randomUUID } from "node:crypto";
18
18
  import { discoverReadonlyEntries, validDiscoveryName, } from "./readonly-discovery.js";
19
19
  import { isSameOrDescendant } from "../paths.js";
20
+ // 回收站条目 id(与 hub / 记忆 / 人设 / 场景 / 提示词共用一个生成器)。不能引 `../hub.js` ——
21
+ // 那边引了本文件的 resolveDshHome,引回去就是环;所以生成器放在 `../trash-id.js` 这一叶上。
22
+ import { newTrashId } from "../trash-id.js";
20
23
  import { OFFICE_SKILL_NAMES, OFFICIAL_SOURCE_KEY, officialOfficeRuntimeSection, } from "./official.js";
21
24
  import { parseSkillDoc, toKebab, unquote } from "./frontmatter.js";
22
25
  // 拆出去的公开函数照旧挂在 core.js 上(tools/skills.ts 引 toKebab,memories/* 与 ops/memory.ts
@@ -49,7 +52,7 @@ import { checkedWritableRootDefinition, movePathWithFallback, safeExistingEntryP
49
52
  // ./core.js 引),照旧再出口一次。
50
53
  export { customRootsFromState, effectiveSkillPolicy, entryOf, groupLoadableSkillsByName, parseBoolValue, readManagerState, scanEntries, state, writeManagerState, } from "./manager-state.js";
51
54
  import { CUSTOM_ROOT_KEY_RE, PROJECT_ROOT_KEY_RE, customRootsFromState, effectiveSkillPolicy, entryOf, groupLoadableSkillsByName, isInsideResolvedRoot, readManagerState, scanDeduplicatedRoots, writeFileAtomically, writeManagerState, } from "./manager-state.js";
52
- import { KEBAB_RE, pathsOverlap, } from "./upload-pipeline.js";
55
+ import { KEBAB_RE, normalizeUploadPath, pathsOverlap, uploadError, writeUploadedEntries, } from "./upload-pipeline.js";
53
56
  import { customRootKey, dshRootPath, isDefaultSkillSource, managerHomePath, managerStatePath, projectIdentity, projectRoots, projectSourceSafe, readonlyError, resolvedPath, rootByKey, rootDefinition, trashRootPath, userRoots, } from "./skill-roots.js";
54
57
  import { codedError, entryPath, lstatOrNull, renameWithRetry, pathIdentity, temporaryPath, TRANSIENT_RENAME_CODES, } from "./core-util.js";
55
58
  const USER_DSH_POLICY_RANK = 399;
@@ -713,6 +716,24 @@ export async function markSkillsDisabled(entries, log) {
713
716
  return invalidManagerStateWrite();
714
717
  let dirty = false;
715
718
  for (const e of clean) {
719
+ /*
720
+ * 同一张表里的两个名单**互斥**(`disabledSkills` = 显式关、`enabledSkills` = 显式开,
721
+ * 见 manager-state 的 validManagerStateDocument)。名字进了 disabled 就必须从 enabled 摘掉。
722
+ *
723
+ * 少了这一步,「显式启用过 → 删除 → 从回收站恢复」这条链会把同一个名字同时留在两份名单
724
+ * 上(删除不清策略、恢复又要默认停用),整份状态随即被判非法 —— 插件 fail-closed 停用
725
+ * **所有**技能,而且 `writable` 一并变 false,用户在界面上连一个开关都按不动,等于被锁死在
726
+ * 一份自己造不出来的状态上(用户 2026-10-14 实测踩到)。
727
+ *
728
+ * 摘除要放在下面那个 `continue` **之前**:名字已经在 disabled 里、却还挂在 enabled 上
729
+ * (就是被锁死的那份文件的形状)时,`continue` 会把清理一并跳过去。
730
+ */
731
+ const explicit = current.state.enabledSkills[e.root] || [];
732
+ const keptExplicit = explicit.filter((name) => name !== e.name);
733
+ if (keptExplicit.length !== explicit.length) {
734
+ current.state.enabledSkills[e.root] = keptExplicit;
735
+ dirty = true;
736
+ }
716
737
  const prev = current.state.disabledSkills[e.root] || [];
717
738
  if (prev.indexOf(e.name) >= 0)
718
739
  continue;
@@ -873,7 +894,11 @@ export async function deleteSkill(root, name, log, options = {}) {
873
894
  params: { name },
874
895
  };
875
896
  const targets = await safeExistingEntryPaths(definition.path, name);
876
- const id = `${Date.now()}-${randomUUID()}`;
897
+ // 回收站条目 id 走五个域共用的那个生成器(2026-10-15 smoke-test P1-7):此前这里另写了一套
898
+ // `${Date.now()}-${randomUUID()}`,于是同一次操作里记忆/人设/场景/提示词拿到 `mv8faxqe-…`
899
+ // 这种形状、技能拿到 `1792001296466-62d3d393-…`。模型按 `action=list` 的 id 跨域 restore,
900
+ // 两种长相只会让它多猜一层。读侧只校验单个合法路径段,旧条目照旧能列、能恢复。
901
+ const id = newTrashId();
877
902
  const trashRoot = trashRootPath();
878
903
  const stage = join(trashRoot, `.stage-${randomUUID()}`);
879
904
  const finalPath = join(trashRoot, id);
@@ -972,11 +997,12 @@ function yamlString(value) {
972
997
  return JSON.stringify(String(value));
973
998
  }
974
999
  export async function createSkill(input, log, options = {}) {
975
- // v0.4:默认落点由「DSH 技能目录」改为 hub 内的 `tool-management/skills/`;
976
- // 调用方显式传 options.root(如项目根)时仍以调用方为准。
1000
+ // 默认落点是「DSH 技能目录」(用户裁定 2026-10-15 改回):v0.4-v0.20.0 期间这里是 hub。
1001
+ // hub(`tool-management/skills/`)现在只服务「导入技能」功能 —— 那边走
1002
+ // `skillCreateRootPath()`,不经这里。调用方显式传 options.root(如项目根)时仍以调用方为准。
977
1003
  const requestedRoot = Object.prototype.hasOwnProperty.call(options, "root")
978
1004
  ? options.root
979
- : rootByKey("hub") || rootByKey("dsh");
1005
+ : rootByKey("dsh") || rootByKey("hub");
980
1006
  const definition = await checkedWritableRootDefinition(requestedRoot);
981
1007
  if (definition && definition.ok === false)
982
1008
  return definition;
@@ -1020,6 +1046,31 @@ export async function createSkill(input, log, options = {}) {
1020
1046
  };
1021
1047
  if (description.length > 500 || body.length > 1 << 18)
1022
1048
  return { ok: false, error: "技能内容过长", code: "error.create.tooLarge" };
1049
+ // 附件的闸一律复用导入那条链(normalizeUploadPath + writeUploadedEntries 里的 32 MiB /
1050
+ // 64 MiB / 1000 条),不另立规矩:界面上新建的技能与导入的技能带同一份 `references/`
1051
+ // 时,落盘形态必须一致。SKILL.md 是本次要写的正文,不能被附件占用。
1052
+ const files = Array.isArray(input && input.files) ? input.files : [];
1053
+ try {
1054
+ for (const item of files) {
1055
+ const rel = normalizeUploadPath(item?.path).path;
1056
+ if (/(^|\/)skill\.md$/i.test(rel))
1057
+ return {
1058
+ ok: false,
1059
+ error: `附件不能占用 SKILL.md(正文由本次创建写入): ${rel}`,
1060
+ code: "error.create.reservedPath",
1061
+ params: { path: rel },
1062
+ };
1063
+ }
1064
+ }
1065
+ catch (error) {
1066
+ return uploadError(error);
1067
+ }
1068
+ if (files.length && form === "flat")
1069
+ return {
1070
+ ok: false,
1071
+ error: "单文件技能不能带附件:附件需要目录形态",
1072
+ code: "error.create.filesWithFlat",
1073
+ };
1023
1074
  if (await safeExistingEntryPaths(root, name).then((items) => items.length > 0))
1024
1075
  return {
1025
1076
  ok: false,
@@ -1043,10 +1094,18 @@ export async function createSkill(input, log, options = {}) {
1043
1094
  try {
1044
1095
  await fs.mkdir(stage);
1045
1096
  await fs.writeFile(join(stage, "SKILL.md"), content, "utf8");
1097
+ // 附件写进**同一个临时目录**再整体改名到位:中途失败时目标路径根本不存在,
1098
+ // 不会留下「有 SKILL.md 没有 references」的半截技能(下方 catch 顺手把 stage 清掉)。
1099
+ if (files.length)
1100
+ await writeUploadedEntries(stage, files);
1046
1101
  await renameWithRetry(stage, target);
1047
1102
  }
1048
1103
  catch (error) {
1049
1104
  await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
1105
+ // 带附件时把失败收拢成结果对象:路径非法 / 超限这些都是用户输入问题,抛出去只会
1106
+ // 让界面拿到一句英文堆栈。不带附件时照旧抛(与改动前一字不差)。
1107
+ if (files.length)
1108
+ return uploadError(error);
1050
1109
  throw error;
1051
1110
  }
1052
1111
  if (log)
@@ -1146,6 +1205,59 @@ export async function updateSkill(input, log, options = {}) {
1146
1205
  log("update", `更新 ${existingDoc}`);
1147
1206
  return { name, path: existingDoc, root: definition.key };
1148
1207
  }
1208
+ /** 详情弹窗列出的 bundle 附件上限:再多就不是给人看的清单了(2026-10-16)。 */
1209
+ const MAX_DETAIL_ATTACHMENTS = 200;
1210
+ const MAX_DETAIL_ATTACHMENT_DEPTH = 6;
1211
+ /**
1212
+ * 列出 bundle 目录里除顶层 `SKILL.md` 之外的普通文件(相对路径 + 字节数)。
1213
+ *
1214
+ * 走 lstat 并跳过符号链:这份清单会原样列进详情弹窗、也会打印给模型看,
1215
+ * 里面混一条指向目录树之外的链就是误导。深度与条数都有上限,超了如实标 truncated。
1216
+ */
1217
+ async function listBundleAttachments(dir) {
1218
+ const files = [];
1219
+ let truncated = false;
1220
+ async function walk(base, prefix, depth) {
1221
+ let items;
1222
+ try {
1223
+ items = await fs.readdir(base, { withFileTypes: true });
1224
+ }
1225
+ catch {
1226
+ return;
1227
+ }
1228
+ for (const item of items) {
1229
+ const rel = prefix ? `${prefix}/${item.name}` : item.name;
1230
+ if (depth === 0 && item.name.toLowerCase() === "skill.md")
1231
+ continue;
1232
+ const abs = join(base, item.name);
1233
+ let stat;
1234
+ try {
1235
+ stat = await fs.lstat(abs);
1236
+ }
1237
+ catch {
1238
+ continue;
1239
+ }
1240
+ if (stat.isSymbolicLink())
1241
+ continue;
1242
+ if (stat.isDirectory()) {
1243
+ if (depth + 1 >= MAX_DETAIL_ATTACHMENT_DEPTH)
1244
+ continue;
1245
+ await walk(abs, rel, depth + 1);
1246
+ continue;
1247
+ }
1248
+ if (!stat.isFile())
1249
+ continue;
1250
+ if (files.length >= MAX_DETAIL_ATTACHMENTS) {
1251
+ truncated = true;
1252
+ return;
1253
+ }
1254
+ files.push({ path: rel, size: stat.size });
1255
+ }
1256
+ }
1257
+ await walk(dir, "", 0);
1258
+ files.sort((a, b) => a.path.localeCompare(b.path));
1259
+ return { files, truncated };
1260
+ }
1149
1261
  export async function skillDetail(keyOrRoot, name, options = {}) {
1150
1262
  const scopedRoots = await projectRoots(options.projectCwds);
1151
1263
  // 支持传 key 或 definition(自定义来源由 service 层解析后传入)。
@@ -1183,6 +1295,8 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
1183
1295
  }
1184
1296
  const doc = parseSkillDoc(raw);
1185
1297
  const summary = entryOf(name, entry.kind, entry.docPath, doc);
1298
+ // bundle 才有附件;flat 是单文件,给 null 让调用方分清"没有目录"与"目录是空的"。
1299
+ const attachments = entry.kind === "bundle" ? await listBundleAttachments(dirname(entry.docPath)) : null;
1186
1300
  return {
1187
1301
  root: root.key,
1188
1302
  name,
@@ -1197,6 +1311,8 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
1197
1311
  diagnostics: summary.diagnostics,
1198
1312
  loadable: summary.loadable,
1199
1313
  sourceReadOnly: !root.mutable,
1314
+ attachments: attachments ? attachments.files : null,
1315
+ attachmentsTruncated: attachments ? attachments.truncated : false,
1200
1316
  };
1201
1317
  }
1202
1318
  /**
@@ -94,3 +94,34 @@ export function unquote(v) {
94
94
  }
95
95
  return s;
96
96
  }
97
+ // ── 声明名改写(退号落盘后同步 frontmatter)─────────────────────────────────
98
+ /** frontmatter 整块(开栏 / 字段区 / 闭栏三段各自成组,便于逐字回拼)。 */
99
+ const DECLARED_NAME_BLOCK_RE = /^(?<open>(?:\uFEFF)?[^\S\r\n]*---[^\S\r\n]*(?:\r\n|\n))(?<fields>[\s\S]*?)(?<close>^[^\S\r\n]*---[^\S\r\n]*(?:\r\n|\n|$))/m;
100
+ /** 顶格的 `name:` 行;缩进的嵌套字段(块标量的续行等)不匹配。 */
101
+ const DECLARED_NAME_LINE_RE = /^name[^\S\r\n]*:[^\r\n]*/m;
102
+ /**
103
+ * 把 frontmatter 的 `name:` 行改写成新名字,其余逐字节保留(连 CRLF 都不动);
104
+ * 认不出形态(没有 frontmatter / 没有 `name:` 行 / 改完不变)时返回 null。
105
+ *
106
+ * **调用方保证 `name` 过 KEBAB_RE**(只含 `[a-z0-9-]`,无需转义;本模块不引
107
+ * upload-pipeline 的 KEBAB_RE —— 那边引了本模块,引回来就成了环)。
108
+ *
109
+ * 退号落盘后必须调用(导入撞名、回收站恢复):`canonicalSkillName` 取的是
110
+ * 声明名而不是条目名,只改目录会"看着叫 foo-1、其实还是 foo"—— 两份同名
111
+ * 技能在「同名首选」里被并成一组,而那颗开关挑的是来源,按下去什么都不会变。
112
+ * 没有 `name:` 行的文档返回 null 是**良性**的:声明名为空时 canonicalSkillName
113
+ * 回落到条目名,退号后的目录名就是它的名字。
114
+ */
115
+ export function rewriteDeclaredName(text, name) {
116
+ const match = DECLARED_NAME_BLOCK_RE.exec(text);
117
+ if (!match || !match.groups)
118
+ return null;
119
+ const fields = match.groups.fields;
120
+ // 函数式替换:字段区里可能带 `$&` / `$'` 之类字面量,字符串式替换会把它们当占位符展开。
121
+ const rewritten = fields.replace(DECLARED_NAME_LINE_RE, () => `name: ${name}`);
122
+ if (rewritten === fields)
123
+ return null;
124
+ const open = match.groups.open;
125
+ const close = match.groups.close;
126
+ return text.replace(DECLARED_NAME_BLOCK_RE, () => `${open}${rewritten}${close}`);
127
+ }
@@ -451,6 +451,30 @@ export function normalizeManagerState(value) {
451
451
  if (forgotten.length)
452
452
  normalized.removedSources = normalized.removedSources.filter((key) => !forgotten.includes(key));
453
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
+ }
454
478
  return normalized;
455
479
  }
456
480
  export function normalizeCustomRoots(value) {
@@ -630,6 +654,17 @@ export function markWinners(items, options = {}) {
630
654
  name: winner.entry.name,
631
655
  enabled: winnerEnabled,
632
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
+ */
633
668
  }
634
669
  if (options.markWinner !== false)
635
670
  winner.view.winner = true;
@@ -716,8 +751,12 @@ export async function state(options = {}) {
716
751
  });
717
752
  continue;
718
753
  }
719
- // 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
720
- 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)
721
760
  continue;
722
761
  if (truncated)
723
762
  result.warnings.push({