dsh-plugin-tool-management 0.7.0 → 0.8.5

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 (37) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +19 -5
  3. package/README_EN.md +19 -5
  4. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  5. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  6. package/docs/images/2MCP.png +0 -0
  7. package/docs/images/2MCP_en.png +0 -0
  8. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  10. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  12. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  14. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  16. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  18. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  20. package/docs/update.md +31 -0
  21. package/lib/agents-md/service.js +41 -3
  22. package/lib/client.js +913 -415
  23. package/lib/compat/preset-reach.js +1 -1
  24. package/lib/compat/probe.js +12 -2
  25. package/lib/index.js +651 -31
  26. package/lib/mcp/state-section.js +90 -0
  27. package/lib/prompt-sections.js +148 -0
  28. package/lib/rules/archive-engine.js +151 -30
  29. package/lib/rules/archive.js +78 -28
  30. package/lib/rules/service.js +273 -41
  31. package/lib/skills/core.js +36 -1
  32. package/lib/skills/service.js +36 -10
  33. package/lib/subagents/catalog.js +90 -0
  34. package/lib/subagents/service.js +187 -20
  35. package/lib/subagents/tools.js +10 -3
  36. package/package.json +97 -97
  37. package/lib/rules/provider.js +0 -121
@@ -29,12 +29,12 @@ import { randomUUID } from 'node:crypto';
29
29
  import { readFileSync, readdirSync, statSync } from 'node:fs';
30
30
  import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
31
31
  import { basename, dirname, join, resolve } from 'node:path';
32
- import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
32
+ import { parseSkillDoc, renameWithRetry, resolveDshHome, unquote } from '../skills/core.js';
33
33
  import { expandUploads, planMemoryImport } from '../imports/upload.js';
34
- import { createRuleProviderRegistrar } from './provider.js';
34
+ import { SECTION_ORDER, createPromptSectionRegistrar } from '../prompt-sections.js';
35
35
  import { normalizePresetId } from '../agents-md/preset-id.js';
36
- import { normalizeArchive, memoryAllowed } from './archive.js';
37
- import { listTrashEntries, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
36
+ import { normalizeArchive } from './archive.js';
37
+ import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
38
38
  // ── 常量 ───────────────────────────────────────────────────────────────────
39
39
  const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
40
40
  const MAX_DIRECTORIES = 2000; // 目录预算
@@ -102,25 +102,68 @@ export const MEMORIES_DIR = 'memories';
102
102
  export const GLOBAL_SCENE = 'global';
103
103
  /** 保留场景在界面上的显示名(磁盘上仍用 ASCII 目录/文件名)。 */
104
104
  export const GLOBAL_SCENE_LABEL = '全局';
105
+ /**
106
+ * 记忆导出的落盘映射(`bundle-export` 的 `kind: 'memories'` 用,见 design-plan D14)。
107
+ *
108
+ * 记忆有两种形态(见文件头):flat = `<场景>/<name>.md`,bundle = `<场景>/<name>/SKILL.md`;
109
+ * 而且**场景名本身可含 `/`**(多段场景名,见 `isValidGroupPath`)。所以「按 id 的最后一个
110
+ * `/` 切出场景与名字、再拼 `.md`」是错的:bundle 会被读成 `<场景>/<name>.md` → 读不到 →
111
+ * 该项目静默丢失(只在 `missing` 里留个名,界面按「导出成功」显示)。
112
+ *
113
+ * 这里一律按索引里那条规则自己的 `path` 与 `form` 决定;bundle 只交出目录,由调用方
114
+ * 把目录内的文件全部打包(与技能分支同口径)。索引里没有、或已被同名 bundle 遮蔽的 id
115
+ * 进 `missing`——遮蔽的 flat 不会被加载,导出去只会让人以为它能用。
116
+ *
117
+ * 纯函数(不碰磁盘),可直接断言。
118
+ */
119
+ export function planMemoryExport(names, rules) {
120
+ const list = Array.isArray(names) ? names.map((n) => String(n).trim()).filter(Boolean) : [];
121
+ const byId = new Map();
122
+ for (const row of (Array.isArray(rules) ? rules : [])) {
123
+ const id = row && typeof row.id === 'string' ? row.id : '';
124
+ if (id !== '')
125
+ byId.set(id, row);
126
+ }
127
+ const entries = [];
128
+ const missing = [];
129
+ for (const id of list) {
130
+ const row = byId.get(id);
131
+ const abs = row && typeof row.path === 'string' ? row.path : '';
132
+ if (!row || abs === '' || row.shadowed === true) {
133
+ missing.push(id);
134
+ continue;
135
+ }
136
+ entries.push(row.form === 'bundle'
137
+ ? { id, zip: id, abs: dirname(abs), kind: 'dir' }
138
+ : { id, zip: `${id}.md`, abs, kind: 'file' });
139
+ }
140
+ return { entries, missing };
141
+ }
105
142
  // ── 文件工具 ───────────────────────────────────────────────────────────────
106
- /** 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。 */
143
+ /**
144
+ * 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。
145
+ * rename 走 `renameWithRetry`:Windows 上杀软/索引器会短暂占住目标文件报
146
+ * EPERM/EACCES/EBUSY(用户实测:进入/退出模式时 rules-index.json 被 rename 撞上,
147
+ * 运行时已切换但状态落盘失败,界面开关停在旧状态、还得再点一次)。这种占用是
148
+ * 瞬时的,重试几轮就能过去;全失败才清理临时文件并把错误抛出。
149
+ */
107
150
  async function writeFileAtomically(path, content) {
108
151
  const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
109
152
  try {
110
153
  await writeFile(temp, content, 'utf8');
111
- await rename(temp, path);
154
+ await renameWithRetry(temp, path);
112
155
  }
113
156
  catch (error) {
114
157
  await rm(temp, { force: true }).catch(() => undefined);
115
158
  throw error;
116
159
  }
117
160
  }
118
- /** 二进制版原子写(附件用):临时文件 + rename,失败清理临时文件。 */
161
+ /** 二进制版原子写(附件用):临时文件 + rename(同样带瞬时占用重试),失败清理临时文件。 */
119
162
  async function writeFileAtomicBinary(path, data) {
120
163
  const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
121
164
  try {
122
165
  await writeFile(temp, data);
123
- await rename(temp, path);
166
+ await renameWithRetry(temp, path);
124
167
  }
125
168
  catch (error) {
126
169
  await rm(temp, { force: true }).catch(() => undefined);
@@ -347,6 +390,9 @@ function parseSceneEntry(raw) {
347
390
  out.order = obj.order;
348
391
  if (typeof obj.createdAt === 'string' && obj.createdAt !== '')
349
392
  out.createdAt = obj.createdAt;
393
+ // 场景锁定(v0.8):读盘必须原样保留 —— 这里曾只重建已知字段,锁了也会被剥成未锁。
394
+ if (obj.locked === true)
395
+ out.locked = true;
350
396
  return out;
351
397
  }
352
398
  function parseScenes(raw) {
@@ -374,7 +420,7 @@ function parseArchives(raw) {
374
420
  }
375
421
  return out;
376
422
  }
377
- function parseModeState(raw) {
423
+ export function parseModeState(raw) {
378
424
  const obj = (raw && typeof raw === 'object' ? raw : {});
379
425
  const scene = typeof obj.scene === 'string' && obj.scene.trim() !== '' ? obj.scene : null;
380
426
  const snapshotRaw = (obj.snapshot && typeof obj.snapshot === 'object' ? obj.snapshot : {});
@@ -412,9 +458,36 @@ function parseModeState(raw) {
412
458
  list.push(tool);
413
459
  }
414
460
  }
461
+ // v2 快照的**服务器级 / 来源级**名单与 v0.8 的子智能体名单必须原样透传 ——
462
+ // 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与技能来源
463
+ // 永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
464
+ const mcpServers = (Array.isArray(snapshotRaw.mcpServers) ? snapshotRaw.mcpServers : [])
465
+ .filter((x) => !!x && typeof x === 'object')
466
+ .map((x) => ({ id: String(x.id || ''), level: String(x.level || ''), disabled: x.disabled === true }))
467
+ .filter((x) => x.id !== '' && (x.level === 'global' || x.level === 'project'));
468
+ const skillSources = (Array.isArray(snapshotRaw.skillSources) ? snapshotRaw.skillSources : [])
469
+ .filter((x) => !!x && typeof x === 'object')
470
+ .map((x) => ({ root: String(x.root || ''), enabled: x.enabled === true }))
471
+ .filter((x) => x.root !== '');
472
+ const subagents = (Array.isArray(snapshotRaw.subagents) ? snapshotRaw.subagents : []).map((x) => String(x)).filter(Boolean);
473
+ // v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
474
+ //(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
475
+ const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
476
+ .filter((x) => !!x && typeof x === 'object')
477
+ .map((x) => ({ id: String(x.id || ''), note: typeof x.note === 'string' ? x.note : null }))
478
+ .filter((x) => x.id !== '');
415
479
  return {
416
480
  scene,
417
- snapshot: scene ? { mcp, skills: toFlagMap(snapshotRaw.skills) } : null,
481
+ snapshot: scene
482
+ ? {
483
+ mcp,
484
+ skills: toFlagMap(snapshotRaw.skills),
485
+ ...(mcpServers.length ? { mcpServers } : {}),
486
+ ...(skillSources.length ? { skillSources } : {}),
487
+ ...(subagents.length ? { subagents } : {}),
488
+ ...(mcpNotes.length ? { mcpNotes } : {}),
489
+ }
490
+ : null,
418
491
  };
419
492
  }
420
493
  /** 解析 rules-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
@@ -881,9 +954,9 @@ function renderSceneMemory(probe, index, maxBytes) {
881
954
  // 保留场景 global 恒定生效(「全局」= 任何对话都注入);其余由 index.active 决定。
882
955
  if (file.scene !== GLOBAL_SCENE && !active.has(file.scene))
883
956
  continue;
884
- // 场景档案的记忆段(v3):该场景定过 memories 段 → 只有勾选的记忆进段(纯投影,不改文件)。
885
- if (!memoryAllowed(index.archives, file.scene, file.id))
886
- continue;
957
+ // ⚠️ 这里**不再**看场景档案的 memories 段:记忆的开关是**单一真相源** `rules[*].enabled`
958
+ // (见下方 enabled 判定)。档案弹窗里的记忆勾选就是同一个值,所以两边天然一致,
959
+ // 不存在「记忆页开了、档案页还显示未开」的两套状态。
887
960
  const list = buckets.get(file.scene);
888
961
  if (list)
889
962
  list.push(file);
@@ -895,7 +968,7 @@ function renderSceneMemory(probe, index, maxBytes) {
895
968
  let seq = 0;
896
969
  for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
897
970
  const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
898
- const header = `${sceneHeading(scene)}\n\n`;
971
+ const header = `${sceneHeading(scene)}\n\n${SCENE_MEMORY_NOTE}\n\n`;
899
972
  for (const file of sceneFiles) {
900
973
  const block = `### ${ruleHeading(file)}\n\n${String(file.body ?? '').trim()}\n`;
901
974
  candidates.push({
@@ -1034,6 +1107,11 @@ function sceneLabel(scene, index) {
1034
1107
  }
1035
1108
  /** 场景标题:`''` → 全局;其余用目录名(可追溯)。 */
1036
1109
  const sceneHeading = (scene) => `## 场景记忆:${sceneLabel(scene)}`;
1110
+ /**
1111
+ * 场景标题后的固定引导语(常量,不破坏前缀缓存稳定):让模型知道这段是当前场景的
1112
+ * 常驻参考知识,而不是对话历史或临时说明 —— 相关就用、无关可忽略。
1113
+ */
1114
+ const SCENE_MEMORY_NOTE = '(本场景的常驻参考知识:与当前任务相关时直接采用,无关时忽略)';
1037
1115
  /** 单条记忆的标题:显式 description 优先;派生描述与正文重复,改用文件名。 */
1038
1116
  const ruleHeading = (f) => (f.descriptionDerived || !f.description ? f.name : f.description);
1039
1117
  const byteLen = (s) => Buffer.byteLength(s, 'utf8');
@@ -1378,6 +1456,7 @@ export function createRulesService(ctx, deps) {
1378
1456
  prompt: index.scenes?.[name]?.prompt || '',
1379
1457
  // 单选模型下"能不能点开":已被别的场景占用时,这个开关要置灰。
1380
1458
  selectable: name !== SHARED_GROUP && name !== GLOBAL_SCENE && (enabledScene === null || enabledScene === name),
1459
+ locked: index.scenes?.[name]?.locked === true,
1381
1460
  }))
1382
1461
  .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
1383
1462
  }
@@ -1391,6 +1470,8 @@ export function createRulesService(ctx, deps) {
1391
1470
  .sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name));
1392
1471
  const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
1393
1472
  const scenes = sceneRows(snap, index);
1473
+ // 场景锁定状态(v0.8):任意一个场景锁定 = 五个管理域整体冻结。界面据此禁用各页的写控件。
1474
+ const anyLocked = Object.values(index.scenes || {}).some((s) => s.locked === true);
1394
1475
  const projection = sceneMemory();
1395
1476
  const promptProjection = scenePrompt();
1396
1477
  return {
@@ -1400,6 +1481,8 @@ export function createRulesService(ctx, deps) {
1400
1481
  groups: groups.map((g) => ({ ...g, key: g.name })),
1401
1482
  // 场景 = 显式记录(含保留场景 global 与尚无记忆的空场景);active = 是否参与注入。
1402
1483
  scenes,
1484
+ // 任一场景被锁定 = 五个管理域整体冻结(客户端据此禁用写控件;服务端 handlers 另有守卫)。
1485
+ anyLocked,
1403
1486
  activeMode: normalizeActive(index.active) === null ? 'all' : 'custom',
1404
1487
  // 单选模型:当前启用的那个场景(null = 只留 `_shared` 与 `global`)。
1405
1488
  activeScene: enabledSceneOf(index),
@@ -1606,7 +1689,9 @@ export function createRulesService(ctx, deps) {
1606
1689
  // id 恒为 `<场景>/<名>`(与 parseId 同构);场景记录此时必然已存在。
1607
1690
  const createdId = `${targetGroup}/${name}`;
1608
1691
  const index = indexForScene;
1609
- index.rules[createdId] = { order: DEFAULT_ORDER, enabled: true, updatedAt: new Date().toISOString() };
1692
+ // P5:新建记忆默认**不开启** —— 记忆的开关是单一真相源(`enabled`),
1693
+ // 用户裁定「创建的记忆默认不开启,在记忆页开启后对应的场景档案也要显示开启」。
1694
+ index.rules[createdId] = { order: DEFAULT_ORDER, enabled: false, updatedAt: new Date().toISOString() };
1610
1695
  if (!index.groups[targetGroup])
1611
1696
  index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
1612
1697
  await writeIndex(stateDir, index);
@@ -1714,7 +1799,8 @@ export function createRulesService(ctx, deps) {
1714
1799
  }
1715
1800
  // 索引记录:与 rules-create 同口径(order/enabled/updatedAt + 场景分组),用户既有设置不覆盖。
1716
1801
  const entry = index.rules[id];
1717
- index.rules[id] = { ...(entry || {}), order: entry?.order ?? DEFAULT_ORDER, enabled: entry?.enabled ?? true, updatedAt: new Date().toISOString() };
1802
+ // P5:导入的记忆同样默认**不开启**(与 rules-create 同一口径);已存在的条目保留原开关。
1803
+ index.rules[id] = { ...(entry || {}), order: entry?.order ?? DEFAULT_ORDER, enabled: entry?.enabled ?? false, updatedAt: new Date().toISOString() };
1718
1804
  if (!index.groups[item.group])
1719
1805
  index.groups[item.group] = { order: DEFAULT_GROUP_ORDER, label: item.group };
1720
1806
  // 场景记录补齐(导入进来的目录名此前可能没有记录)。
@@ -2168,8 +2254,15 @@ export function createRulesService(ctx, deps) {
2168
2254
  return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
2169
2255
  }
2170
2256
  /**
2171
- * 更新场景记录(描述 / 显示名 / 顺序 / 绑定的提示词预设)。记忆文件不动。
2172
- * 这是「场景有描述」的写入口——旧版场景只有目录名,没有可编辑元数据。
2257
+ * 更新场景记录(描述 / 显示名 / 顺序 / 绑定的提示词预设),**可选改名**(nextName)。
2258
+ *
2259
+ * 场景名就是它的一级目录名(`memories/<场景>/…`),所以改名不是改一个字段:
2260
+ * ① 目录 `memories/<旧>` → `memories/<新>`;
2261
+ * ② 索引里的四处引用一起改:`scenes` 记录、`archives` 档案、`active` 启用集合、`mode.scene`。
2262
+ * 记忆正文一个字节都不动(只是换了所在目录名)。
2263
+ *
2264
+ * 拒绝的三种情况:保留场景 `global` / `_shared`;目标名已被占用(目录或记录);
2265
+ * 该场景正在当前模式里——模式快照是按场景名算的,改了名与快照就对不上(同删除的处理)。
2173
2266
  */
2174
2267
  async function rulesUpdateScene(args) {
2175
2268
  const name = String((args && args.name) || '').trim();
@@ -2219,13 +2312,106 @@ export function createRulesService(ctx, deps) {
2219
2312
  return fail('error.rules.invalidGroup', `非法排序值:${args.order}`);
2220
2313
  next.order = Math.floor(order);
2221
2314
  }
2222
- if (name === GLOBAL_SCENE)
2315
+ // ── 改名(可选):目录 + 索引里的四处引用一起动 ──
2316
+ let finalName = name;
2317
+ const nextRaw = args && args.nextName !== undefined ? String(args.nextName).trim() : '';
2318
+ if (nextRaw !== '' && nextRaw !== name) {
2319
+ if (!isValidGroupSegment(nextRaw)) {
2320
+ return fail('error.rules.invalidGroup', `新场景名非法:${nextRaw}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
2321
+ }
2322
+ if (name === GLOBAL_SCENE)
2323
+ return fail('error.rules.reservedScene', '「全局」是保留场景,不可改名');
2324
+ if (name === SHARED_GROUP || nextRaw === SHARED_GROUP)
2325
+ return fail('error.rules.invalidGroup', '_shared 是保留场景名,不可改名');
2326
+ if ((index.scenes && index.scenes[nextRaw]) || (await pathExists(join(rulesRoot, nextRaw)))) {
2327
+ return fail('error.rules.nameTaken', `目标场景名已被占用:${nextRaw}`);
2328
+ }
2329
+ if (index.mode && index.mode.scene === name) {
2330
+ return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再改名`);
2331
+ }
2332
+ const fromDir = join(rulesRoot, name);
2333
+ const toDir = join(rulesRoot, nextRaw);
2334
+ let movedDir = false;
2335
+ if (await pathExists(fromDir)) {
2336
+ try {
2337
+ await rename(fromDir, toDir);
2338
+ movedDir = true;
2339
+ }
2340
+ catch (e) {
2341
+ return fail('error.rules.ioFailed', `场景目录改名失败:${message(e)}`);
2342
+ }
2343
+ }
2344
+ // 目录已经搬过去了:索引这一步失败就必须把目录搬回来,否则目录名与索引各说各话。
2345
+ try {
2346
+ const scenes = index.scenes || {};
2347
+ if (scenes[name]) {
2348
+ scenes[nextRaw] = scenes[name];
2349
+ delete scenes[name];
2350
+ }
2351
+ else {
2352
+ scenes[nextRaw] = { order: DEFAULT_GROUP_ORDER, createdAt: new Date().toISOString() };
2353
+ }
2354
+ index.scenes = scenes;
2355
+ if (index.archives && index.archives[name]) {
2356
+ index.archives[nextRaw] = index.archives[name];
2357
+ delete index.archives[name];
2358
+ }
2359
+ const act = normalizeActive(index.active);
2360
+ if (act !== null && act.indexOf(name) >= 0)
2361
+ index.active = act.map((n) => (n === name ? nextRaw : n));
2362
+ if (index.mode && index.mode.scene === name)
2363
+ index.mode = { ...index.mode, scene: nextRaw };
2364
+ }
2365
+ catch (e) {
2366
+ if (movedDir) {
2367
+ try {
2368
+ await rename(toDir, fromDir);
2369
+ }
2370
+ catch { /* 回滚失败:错误信息里如实带上原因 */ }
2371
+ }
2372
+ return fail('error.rules.ioFailed', `场景改名后索引更新失败:${message(e)}`);
2373
+ }
2374
+ finalName = nextRaw;
2375
+ }
2376
+ if (finalName === GLOBAL_SCENE)
2223
2377
  next.label = GLOBAL_SCENE_LABEL;
2224
- index.scenes[name] = next;
2378
+ index.scenes[finalName] = next;
2225
2379
  await writeIndex(stateDir, index);
2226
2380
  invalidateSnapshot();
2227
2381
  invalidateProviders();
2228
- return { ok: true, scene: sceneRecordOf(name, next) };
2382
+ return {
2383
+ ok: true,
2384
+ ...(finalName === name ? {} : { renamedFrom: name }),
2385
+ scene: sceneRecordOf(finalName, next),
2386
+ };
2387
+ }
2388
+ /**
2389
+ * 场景锁定(v0.8):锁定后五个管理域(MCP/技能/子智能体/记忆/提示词)整体只读 ——
2390
+ * 场景页的档案编辑与功能页的启停/编辑都被拒(index.ts 的 handlers 守卫 + 界面禁用),
2391
+ * 场景自身的启停(进/退模式)不受影响。`locked` 必须显式给布尔值(与 rules-toggle 同一口径)。
2392
+ * 保留场景 `global` 不在场景页出现,不可锁。
2393
+ */
2394
+ async function rulesSceneLock(args) {
2395
+ const name = String((args && args.scene) || '').trim();
2396
+ if (!isValidGroupPath(name))
2397
+ return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
2398
+ if (name === GLOBAL_SCENE || name === SHARED_GROUP)
2399
+ return fail('error.rules.reservedScene', '「全局 / _shared」是保留场景,不可锁定');
2400
+ if (typeof (args && args.locked) !== 'boolean') {
2401
+ return fail('error.rules.invalidArgs', '缺少参数:locked 必须是布尔值(只按传入值写入,不做"翻转"推断)');
2402
+ }
2403
+ const index = await readIndex(stateDir);
2404
+ if (!index.scenes || !index.scenes[name])
2405
+ return fail('error.rules.notFound', `场景不存在:${name}`);
2406
+ // 未启动的场景不能上锁(用户裁定):锁定的意义是冻结**运行中**场景的配置。
2407
+ // 解锁随时允许 —— 否则退出模式后,被锁的场景就没人能解了。
2408
+ if (args.locked === true && (!index.mode || index.mode.scene !== name)) {
2409
+ return fail('error.rules.sceneNotActive', `场景「${name}」未启动:先启动再锁定`);
2410
+ }
2411
+ index.scenes[name] = { ...index.scenes[name], locked: args.locked === true };
2412
+ await writeIndex(stateDir, index);
2413
+ invalidateSnapshot();
2414
+ return { ok: true, scene: name, locked: args.locked === true };
2229
2415
  }
2230
2416
  /**
2231
2417
  * 删除场景:**仅空目录可删**(避免一次操作带走整组记忆)。
@@ -2245,35 +2431,59 @@ export function createRulesService(ctx, deps) {
2245
2431
  if (!index.scenes?.[name] && !(await pathExists(join(rulesRoot, name)))) {
2246
2432
  return fail('error.rules.notFound', `场景不存在:${name}`);
2247
2433
  }
2248
- let entries = [];
2249
- try {
2250
- entries = await readdir(join(rulesRoot, name));
2251
- }
2252
- catch {
2253
- entries = []; // 目录本来就不存在(只有记录)→ 仍允许删记录
2254
- }
2255
- if (entries.filter((n) => !n.startsWith('.')).length > 0) {
2256
- return fail('error.rules.sceneNotEmpty', `场景「${name}」里还有 ${entries.length} 项,请先删除其中的记忆`);
2257
- }
2258
2434
  // 当前模式场景不可删:快照只在引擎里可退(rules service 反向注入会成环),
2259
2435
  // 直接删除会让运行时启停永久停在档案态且无恢复路径 → 给出可逆出路(先退出模式)。
2260
2436
  if (index.mode?.scene === name) {
2261
2437
  return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再删除`);
2262
2438
  }
2263
- // 场景本体(记录 + 档案)**先移入回收站**,再从索引里摘掉——这样「删除场景」
2264
- // 是可恢复的。记忆目录必须是空的(上面已拦),所以没有正文需要搬。
2265
- const trashed = await moveToTrash('scenes', name, [], {
2439
+ // 删除场景 = **连它下面的全部记忆一起去掉**(用户裁定):不管有没有启用、是不是子目录、
2440
+ // 是不是 bundle 附件,统统收走。
2441
+ // 但一律**先移入回收站**(与场景记录、档案装在同一条条目里),所以「删错了」还能整条恢复:
2442
+ // 记忆文件按原来的相对路径放回,场景记录与档案也一起回来。
2443
+ const sceneDir = join(rulesRoot, name);
2444
+ const moves = [];
2445
+ const collect = async (abs, rel) => {
2446
+ let items = [];
2447
+ try {
2448
+ items = await readdir(abs, { withFileTypes: true });
2449
+ }
2450
+ catch {
2451
+ return; // 目录不存在(只有记录的场景)或读不了 → 当作没有文件
2452
+ }
2453
+ for (const item of items) {
2454
+ const childAbs = join(abs, item.name);
2455
+ const childRel = rel === '' ? item.name : `${rel}/${item.name}`;
2456
+ if (item.isDirectory())
2457
+ await collect(childAbs, childRel);
2458
+ else
2459
+ moves.push({ from: childAbs, dest: childRel });
2460
+ }
2461
+ };
2462
+ await collect(sceneDir, '');
2463
+ const trashed = await moveToTrash('scenes', name, moves, {
2266
2464
  record: (index.scenes && index.scenes[name]) || null,
2267
2465
  archive: (index.archives && index.archives[name]) || null,
2466
+ memories: moves.map((m) => m.dest),
2268
2467
  });
2269
2468
  if (trashed.ok === false) {
2270
2469
  return fail('error.rules.ioFailed', `移入回收站失败:${trashed.error}`);
2271
2470
  }
2272
2471
  try {
2273
- // fs.rm 删目录必须 recursive(即使已确认它是空的),否则报 EISDIR。
2274
- await rm(join(rulesRoot, name), { recursive: true, force: true });
2472
+ // 文件已搬空,剩下的只是空子目录;recursive + force 一并清掉。
2473
+ await rm(sceneDir, { recursive: true, force: true });
2275
2474
  }
2276
2475
  catch (e) {
2476
+ // 目录没清掉就把记忆放回去:宁可整个操作失败,也不要「文件在回收站、场景还留在列表里」。
2477
+ for (const move of moves) {
2478
+ try {
2479
+ await moveOutOfTrash('scenes', trashed.id, move.dest, move.from);
2480
+ }
2481
+ catch { /* 尽力而为 */ }
2482
+ }
2483
+ try {
2484
+ await purgeTrashEntry('scenes', trashed.id);
2485
+ }
2486
+ catch { /* 同上 */ }
2277
2487
  return fail('error.rules.ioFailed', `删除场景目录失败:${message(e)}`);
2278
2488
  }
2279
2489
  // 索引清理一次读-改-写:记录 + 启用集合悬空引用 + 该场景档案(否则 archives 留孤儿条目)。
@@ -2294,7 +2504,7 @@ export function createRulesService(ctx, deps) {
2294
2504
  await writeIndex(stateDir, index);
2295
2505
  invalidateSnapshot();
2296
2506
  invalidateProviders();
2297
- return { ok: true, name, trashId: trashed.id };
2507
+ return { ok: true, name, trashId: trashed.id, movedFiles: moves.length };
2298
2508
  }
2299
2509
  /** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
2300
2510
  async function sceneTrashList() {
@@ -2333,6 +2543,18 @@ export function createRulesService(ctx, deps) {
2333
2543
  catch (e) {
2334
2544
  return fail('error.rules.ioFailed', `重建场景目录失败:${message(e)}`);
2335
2545
  }
2546
+ // 删除场景时一起收进回收站的记忆文件:按原来的相对路径放回(目录已确认是空的,不会覆盖)。
2547
+ const files = Array.isArray(entry.files) ? entry.files : [];
2548
+ const restored = [];
2549
+ for (const rel of files) {
2550
+ try {
2551
+ await moveOutOfTrash('scenes', id, String(rel), join(dir, String(rel)));
2552
+ restored.push(String(rel));
2553
+ }
2554
+ catch (e) {
2555
+ return fail('error.rules.ioFailed', `恢复记忆文件失败(已放回 ${restored.length}/${files.length}):${message(e)}`);
2556
+ }
2557
+ }
2336
2558
  if (data.record && typeof data.record === 'object')
2337
2559
  index.scenes = { ...(index.scenes || {}), [name]: data.record };
2338
2560
  if (data.archive && typeof data.archive === 'object')
@@ -2341,7 +2563,7 @@ export function createRulesService(ctx, deps) {
2341
2563
  await purgeTrashEntry('scenes', id);
2342
2564
  invalidateSnapshot();
2343
2565
  invalidateProviders();
2344
- return { ok: true, name };
2566
+ return { ok: true, name, restoredFiles: restored.length };
2345
2567
  }
2346
2568
  /** 永久删除一条场景回收站条目。 */
2347
2569
  async function sceneTrashDelete(args) {
@@ -2409,9 +2631,17 @@ export function createRulesService(ctx, deps) {
2409
2631
  // ── 组装 ─────────────────────────────────────────────────────────────────
2410
2632
  const registerProviders = () => {
2411
2633
  try {
2412
- const registrar = createRuleProviderRegistrar(ctx, {
2413
- renderActiveScenes: () => sceneMemory().text,
2414
- });
2634
+ // 场景绑定的提示词**不再注入**(2026-09-15 用户裁定:「切换场景,对应的提示词直接把
2635
+ // AGENTS.md 修改」):启用/切换场景、改绑、编辑绑定的预设时,宿主把那份正文写进
2636
+ // `~/.dsh/AGENTS.md`(与「提示词」页的「应用」同一条路,覆盖前多代备份),关掉场景时
2637
+ // 恢复进场景之前的基线。真改文件之后再注入一遍,同一份正文会进上下文两次,而且用户手改
2638
+ // 基线之后还会被重新注入 —— 所以这里只保留场景记忆段。绑定关系的**只读投影**仍由 rules
2639
+ // 服务提供(页面「生效中」标记,以及"文件是否已经同步成它"的判断)。
2640
+ const registrar = createPromptSectionRegistrar(ctx, [{
2641
+ name: 'tool-management:scene-memory',
2642
+ order: SECTION_ORDER.sceneMemory,
2643
+ text: () => sceneMemory().text,
2644
+ }]);
2415
2645
  providerInvalidators.add(registrar.invalidate);
2416
2646
  return () => {
2417
2647
  providerInvalidators.delete(registrar.invalidate);
@@ -2436,6 +2666,7 @@ export function createRulesService(ctx, deps) {
2436
2666
  const writeOps = new Set([
2437
2667
  'rules-create', 'rules-update', 'rules-remove', 'rules-restore', 'rules-toggle', 'rules-import',
2438
2668
  'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene', 'rules-rebind-prompt',
2669
+ 'rules-scene-lock',
2439
2670
  'rules-attach', 'rules-detach', 'rules-trash-remove',
2440
2671
  // 场景回收站(与记忆回收站 rules-trash-* 分开:那套管记忆正文,这套管场景记录与档案)
2441
2672
  'scene-trash-restore', 'scene-trash-delete',
@@ -2459,6 +2690,7 @@ export function createRulesService(ctx, deps) {
2459
2690
  'rules-set-active': (args) => runWrite(() => rulesSetActive(args || {})),
2460
2691
  'rules-create-scene': (args) => runWrite(() => rulesCreateScene(args || {})),
2461
2692
  'rules-update-scene': (args) => runWrite(() => rulesUpdateScene(args || {})),
2693
+ 'rules-scene-lock': (args) => runWrite(() => rulesSceneLock(args || {})),
2462
2694
  'rules-remove-scene': (args) => runWrite(() => rulesRemoveScene(args || {})),
2463
2695
  'scene-trash-list': () => sceneTrashList(),
2464
2696
  'scene-trash-restore': (args) => runWrite(() => sceneTrashRestore(args || {})),
@@ -883,7 +883,10 @@ async function writeFileAtomically(path, content) {
883
883
  const temp = join(dirname(path), `.${basename(path)}.dssm-${randomUUID()}.tmp`);
884
884
  try {
885
885
  await fs.writeFile(temp, content, "utf8");
886
- await fs.rename(temp, path);
886
+ // Windows 上目标文件会被杀软/索引器短暂占住(EPERM/EACCES/EBUSY)——
887
+ // 场景模式进出时这里写的是技能来源/策略状态,rename 被撞 = 运行时已切、
888
+ // 状态没落盘。与 rules-index.json 同一处理:重试瞬时占用,全失败才抛。
889
+ await renameWithRetry(temp, path);
887
890
  }
888
891
  catch (error) {
889
892
  await fs.rm(temp, { force: true }).catch(() => undefined);
@@ -1650,6 +1653,38 @@ async function setPolicySkillEnabled(root, name, enabled, log) {
1650
1653
  export async function setSkillEnabled(root, name, enabled, log) {
1651
1654
  return setPolicySkillEnabled(root, name, enabled, log);
1652
1655
  }
1656
+ /**
1657
+ * 批量显式停用技能(v0.8.5:新建 / 导入 / 回收站恢复默认不启动 —— 用户裁定)。
1658
+ * 直接写 disabledSkills 名单(与 setSkillEnabled 同一张表),不做 frontmatter 等
1659
+ * 二次校验——调用方传入的必然是刚落盘/刚恢复的条目。
1660
+ * 必须在调用方的写锁(service 的 write())内执行。
1661
+ * @param {{ root: string, name: string }[]} entries - root = 来源 key。
1662
+ */
1663
+ export async function markSkillsDisabled(entries, log) {
1664
+ const list = Array.isArray(entries) ? entries : [];
1665
+ const clean = list
1666
+ .map((e) => ({ root: String((e && e.root) || ""), name: String((e && e.name) || "") }))
1667
+ .filter((e) => e.root !== "" && e.name !== "");
1668
+ if (!clean.length)
1669
+ return { ok: true };
1670
+ const current = await readManagerState();
1671
+ if (current.writable === false)
1672
+ return invalidManagerStateWrite();
1673
+ let dirty = false;
1674
+ for (const e of clean) {
1675
+ const prev = current.state.disabledSkills[e.root] || [];
1676
+ if (prev.indexOf(e.name) >= 0)
1677
+ continue;
1678
+ current.state.disabledSkills[e.root] = [...prev, e.name].sort();
1679
+ dirty = true;
1680
+ }
1681
+ if (dirty) {
1682
+ await writeManagerState(current.state);
1683
+ if (log)
1684
+ log("skill-disable-default", `默认停用 ${clean.map((e) => `${e.root}/${e.name}`).join("、")}`);
1685
+ }
1686
+ return { ok: true };
1687
+ }
1653
1688
  /**
1654
1689
  * 同名技能「首选来源」:默认同名技能按来源 rank 取优先级最高者生效、其余显示为被覆盖;
1655
1690
  * 这里让用户显式指定哪个同名技能生效(preferred=false 取消,回到 rank 顺序)。
@@ -9,7 +9,7 @@ import { Worker } from 'node:worker_threads';
9
9
  import { spawn } from 'node:child_process';
10
10
  import { homedir } from 'node:os';
11
11
  import { basename, join } from 'node:path';
12
- import { state, setSkillEnabled, setPreferredSkill, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
12
+ import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
13
13
  const MAX_LOG_BYTES = 1 << 20;
14
14
  const PROVIDER_NAME = 'dsh-plugin-tool-management-external';
15
15
  const message = (e) => String((e && e.message) || e);
@@ -431,17 +431,43 @@ export function createSkillsService(ctx) {
431
431
  // requestRoot 未命中时回传原始 key,让 core 的 error.root.unknown 带上来源名
432
432
  // (否则传了不存在的 root 会收到「技能来源不存在:(空)」这种指错方向的提示)。
433
433
  const key = String(args.root || 'hub');
434
- return createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot(key)) || key });
434
+ const res = await createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot(key)) || key });
435
+ // v0.8.5:新建技能默认不启动(用户裁定)——与子智能体 / MCP 同口径。
436
+ if (res && res.ok !== false && res.name) {
437
+ await markSkillsDisabled([{ root: String(res.root || 'hub'), name: String(res.name) }], log).catch(() => undefined);
438
+ }
439
+ return res;
440
+ }), afterWrite),
441
+ 'skill-import': wrap((args) => write(async () => {
442
+ const res = await importSkill(String(args.source || ''), log, {
443
+ conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
444
+ dryRun: args.dryRun === true,
445
+ });
446
+ // 导入的技能默认停用;部分成功也对 imported 逐条停用。
447
+ const names = res && Array.isArray(res.imported) ? res.imported : [];
448
+ if (names.length)
449
+ await markSkillsDisabled(names.map((n) => ({ root: 'hub', name: String(n) })), log).catch(() => undefined);
450
+ return res;
451
+ }), afterWrite),
452
+ 'skill-upload': wrap((args) => write(async () => {
453
+ const res = await importUploadedSkill({ name: args.name, entries: args.entries, zip: args.zip }, log, {
454
+ conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
455
+ });
456
+ // 上传/导入同口径:默认停用。
457
+ const names = res && Array.isArray(res.imported) ? res.imported : (res && res.name ? [res.name] : []);
458
+ if (names.length)
459
+ await markSkillsDisabled(names.map((n) => ({ root: 'hub', name: String(n) })), log).catch(() => undefined);
460
+ return res;
435
461
  }), afterWrite),
436
- 'skill-import': wrap((args) => write(() => importSkill(String(args.source || ''), log, {
437
- conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
438
- dryRun: args.dryRun === true,
439
- })), afterWrite),
440
- 'skill-upload': wrap((args) => write(() => importUploadedSkill({ name: args.name, entries: args.entries, zip: args.zip }, log, {
441
- conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
442
- })), afterWrite),
443
462
  'skill-delete': wrap((args) => write(async () => deleteSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), log)), afterWrite),
444
- 'skill-trash-restore': wrap((args) => write(() => restoreTrash(String(args.id || ''), log, projectOptions())), afterWrite),
463
+ 'skill-trash-restore': wrap((args) => write(async () => {
464
+ const res = await restoreTrash(String(args.id || ''), log, projectOptions());
465
+ // 恢复的技能默认停用(v0.8.5 用户裁定:新建/导入/恢复一律不启动,手动开启)。
466
+ if (res && res.ok !== false && res.name) {
467
+ await markSkillsDisabled([{ root: String(res.root || 'hub'), name: String(res.name) }], log).catch(() => undefined);
468
+ }
469
+ return res;
470
+ }), afterWrite),
445
471
  'skill-trash-delete': wrap((args) => write(() => permanentlyDeleteTrashSafely(String(args.id || ''), log))),
446
472
  // 自定义技能目录:添加(绝对路径,只读接入)/ 移除(连同策略键)。
447
473
  'skill-custom-add': wrap((args) => write(() => addCustomRoot(args && args.path, args && args.label, log)), afterWrite),