dsh-plugin-tool-management 0.10.0 → 0.12.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 (77) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/README.md +67 -50
  3. package/README_EN.md +61 -38
  4. package/cordis.patch.yml +10 -1
  5. package/docs/images/1-EN.png +0 -0
  6. package/docs/images/1.png +0 -0
  7. package/docs/images/2-EN.png +0 -0
  8. package/docs/images/2.png +0 -0
  9. package/docs/images/3-EN.png +0 -0
  10. package/docs/images/3.png +0 -0
  11. package/docs/images/4-EN.png +0 -0
  12. package/docs/images/4.png +0 -0
  13. package/docs/images/5-EN.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6-EN.png +0 -0
  16. package/docs/images/6.png +0 -0
  17. package/docs/images/7-EN.png +0 -0
  18. package/docs/images/7.png +0 -0
  19. package/docs/images/8-EN.png +0 -0
  20. package/docs/images/8.png +0 -0
  21. package/docs/update.md +132 -12
  22. package/lib/client.js +2826 -547
  23. package/lib/compat/patch-dialect.js +173 -0
  24. package/lib/compat/preset-reach.js +1 -10
  25. package/lib/compat/probe.js +205 -24
  26. package/lib/compat/runtime-notes.js +25 -0
  27. package/lib/context-inject.js +83 -9
  28. package/lib/host-names.js +12 -0
  29. package/lib/http-fence.js +35 -15
  30. package/lib/hub.js +28 -2
  31. package/lib/imports/parsers.js +15 -9
  32. package/lib/imports/upload.js +43 -4
  33. package/lib/index.js +927 -3897
  34. package/lib/mcp/loader-token.js +238 -0
  35. package/lib/mcp/manager.js +1769 -0
  36. package/lib/mcp/override-blocks.js +10 -3
  37. package/lib/mcp/patch-yaml.js +351 -0
  38. package/lib/mcp/secret-guard.js +145 -0
  39. package/lib/{rules → memories}/archive-engine.js +1 -1
  40. package/lib/{rules → memories}/archive.js +1 -1
  41. package/lib/memories/constants.js +128 -0
  42. package/lib/memories/index-io.js +330 -0
  43. package/lib/memories/projection.js +280 -0
  44. package/lib/memories/service.js +686 -0
  45. package/lib/memories/snapshot.js +672 -0
  46. package/lib/ops/candidates.js +64 -0
  47. package/lib/ops/compat.js +226 -0
  48. package/lib/ops/ctx.js +9 -0
  49. package/lib/ops/memory.js +678 -0
  50. package/lib/ops/prompts.js +107 -0
  51. package/lib/ops/scene-records.js +460 -0
  52. package/lib/ops/scene-sync.js +17 -0
  53. package/lib/ops/sessions.js +603 -0
  54. package/lib/ops/trash.js +140 -0
  55. package/lib/paths.js +103 -0
  56. package/lib/prompts/preset-id.js +49 -0
  57. package/lib/{agents-md → prompts}/service.js +1 -1
  58. package/lib/request-gate.js +320 -0
  59. package/lib/scene-prompt-sync.js +4 -4
  60. package/lib/scenes/candidates.js +344 -0
  61. package/lib/{history → sessions}/bridge.js +124 -36
  62. package/lib/sessions/history.js +323 -0
  63. package/lib/{history → sessions}/tombstone.js +1 -1
  64. package/lib/{history → sessions}/workspace.js +151 -50
  65. package/lib/skills/core.js +74 -39
  66. package/lib/skills/readonly-discovery.js +4 -1
  67. package/lib/skills/service.js +98 -13
  68. package/lib/subagents/service.js +199 -55
  69. package/lib/tools/deps.js +8 -0
  70. package/lib/tools/mcp.js +110 -0
  71. package/lib/tools/memory.js +87 -0
  72. package/lib/tools/prompt.js +70 -0
  73. package/lib/tools/skills.js +139 -0
  74. package/lib/tools/subagent.js +40 -0
  75. package/package.json +13 -10
  76. package/lib/agents-md/preset-id.js +0 -49
  77. package/lib/rules/service.js +0 -3078
@@ -0,0 +1,173 @@
1
+ // 官方补丁方言(`cordis.patch.yml`)的**只读复刻** —— 写入宿主配置前的最后一道校验。
2
+ //
3
+ // 为什么需要它:本插件 19 个调用面都在**文本级**改宿主的补丁文件(插块 / 删块 / 换行),
4
+ // 而官方解析这份文件用的是 js-yaml + 自定义 `!!js` 标签(`dsh-app-boot/lib/index.js:17-31`),
5
+ // 并且把「顶层必须是数组」「每项必须是映射」写成显式断言(同文件 `parsePatchList`,:1196-1200)。
6
+ // 解析失败 = DSH **下次启动直接起不来**(`CHANGELOG.md` 里记过这次事故)。三条写入路径
7
+ // (`profiles/<名>/cordis.patch.yml`、`~/.dsh/cordis.patch.yml`、bundle 的 `cordis.patch.yml`)
8
+ // 走的是同一份方言(前者 `loadOverlayPatches`、后者 `loadOptionalPatches` → `parsePatchList`;
9
+ // `userPatchesSchema === entryListSchema`,实测于 dsh-app-boot 0.1.5-rc.2)。
10
+ //
11
+ // 官方**没有**导出这份 schema(导出清单见同文件 :1575,无 `entryListSchema`),所以只能复刻。
12
+ // 复刻**必然会过期**(官方加方言 / 换 js-yaml 大版本),因此判定策略是**非对称**的:
13
+ // · 改前能解析、改后被我们改成不能解析 → 是我们的改动弄坏了它 → 拒绝写入(原文件与备份不动);
14
+ // · 改前本来就解析不过 → 说明复刻过期,不是我们的错 → 照写 + 上报,绝不拦;
15
+ // · 依赖(js-yaml)不在 → 跳过校验 + 上报。
16
+ // 一律拒绝会把「校验器过期」变成「MCP 写路径整体停摆」(含重启恢复写,见 request-gate.ts
17
+ // 里那处「恢复写失败会把服务器永久留在停用态」),那是拿能力换保守。
18
+ //
19
+ // `judgePatchText` / `decidePatchWrite` 是纯函数,契约测试直接钉上面几条分支;IO 只有
20
+ // `checkPatchWrite` 一处,它同时把结论记进运行时上报通道(兼容页据此出一行)与回执队列。
21
+ import { clearRuntimeNote, noteRuntime } from './runtime-notes.js';
22
+ /** 官方 `.js` 标签名(逐字取自 dsh-app-boot/lib/index.js:17)。 */
23
+ const JS_EXPR_TAG = 'tag:yaml.org,2002:js';
24
+ /** 官方 `isJsExpr`(cordis-plugin-loader/lib/index.js:302-304)的等价物。 */
25
+ const isJsExpr = (value) => value instanceof Object && '__jsExpr' in value;
26
+ function messageOf(error) {
27
+ return error instanceof Error ? error.message : String(error);
28
+ }
29
+ // 同一份 yaml 模块只 extend 一次(官方是在模块顶层 extend 一次的等价位)。
30
+ const schemas = new WeakMap();
31
+ function schemaOf(yaml) {
32
+ const cached = schemas.get(yaml);
33
+ if (cached !== undefined)
34
+ return cached;
35
+ // 与官方逐字等价的选项对象(dsh-app-boot/lib/index.js:17-23):load 只用到 resolve /
36
+ // construct;predicate / represent 属于 dump 方向,复刻它们是让这份定义与官方对齐。
37
+ const exprType = new yaml.Type(JS_EXPR_TAG, {
38
+ kind: 'scalar',
39
+ resolve: (data) => typeof data === 'string',
40
+ construct: (data) => ({ __jsExpr: data }),
41
+ predicate: isJsExpr,
42
+ represent: (data) => data.__jsExpr,
43
+ });
44
+ const schema = yaml.JSON_SCHEMA.extend(exprType);
45
+ schemas.set(yaml, schema);
46
+ return schema;
47
+ }
48
+ /**
49
+ * 判一份补丁文本官方解析得动吗(纯函数:不碰 IO、不记状态)。
50
+ *
51
+ * @param yaml - 注入的 yaml 模块(测试可传真模块;运行时是惰性 import 来的那份)。
52
+ * @param content - 补丁文件全文。
53
+ * @returns 判定结果;`unparseable` 的 detail 带官方会抛的那句错,直接可展示。
54
+ */
55
+ export function judgePatchText(yaml, content) {
56
+ let parsed;
57
+ try {
58
+ parsed = yaml.load(content, { schema: schemaOf(yaml) });
59
+ }
60
+ catch (error) {
61
+ return { status: 'unparseable', detail: messageOf(error) };
62
+ }
63
+ // 官方 parsePatchList 的两条显式断言:顶层数组(:1199)、每项是映射(:1200)。
64
+ // 空文件(含只有空白的文件)在这里是 undefined → 判「解析不过」—— 与官方一致:
65
+ // 文件**不存在**官方容忍(`ENOENT` → 没有这一层),文件**在但空**官方是抛错的。
66
+ if (!Array.isArray(parsed)) {
67
+ return { status: 'unparseable', detail: '顶层不是 YAML 数组(官方 parsePatchList 的显式断言)' };
68
+ }
69
+ const bad = parsed.findIndex((entry) => typeof entry !== 'object' || entry === null || Array.isArray(entry));
70
+ if (bad >= 0) {
71
+ return { status: 'unparseable', detail: `第 ${bad + 1} 项不是映射(官方 parsePatchList 的显式断言)` };
72
+ }
73
+ return { status: 'ok' };
74
+ }
75
+ /**
76
+ * 非对称策略本体(纯函数)。三个分支就是本模块存在的理由,契约测试逐个钉住。
77
+ *
78
+ * @param before - 改前内容的判定;`null` 表示**没有可用基线**(文件不存在或读不到内容)。
79
+ * @param after - 本次要写入内容的判定。
80
+ * @returns 是否放行 + 要不要上报 + 拒绝时的说明。
81
+ */
82
+ export function decidePatchWrite(before, after) {
83
+ if (after.status === 'ok')
84
+ return { allow: true, report: null };
85
+ if (after.status === 'no-dep')
86
+ return { allow: true, report: { kind: 'no-dep', detail: after.detail } };
87
+ // 改后解析不过:只有「本来就没有基线」或「改前是好的」才拦 —— 这两种都是我们引入的坏内容。
88
+ if (before !== null && before.status === 'unparseable') {
89
+ return {
90
+ allow: true,
91
+ report: {
92
+ kind: 'replica-outdated',
93
+ detail: `改前内容本来就解析不过(${before.detail}),本次写入按「复刻过期」放行`,
94
+ },
95
+ };
96
+ }
97
+ return {
98
+ allow: false,
99
+ report: null,
100
+ error: `写入被拒绝:新内容不是合法的补丁列表(${after.detail})。官方解析器会因此让 DSH 起不来,`
101
+ + '已保留原文件与备份。',
102
+ };
103
+ }
104
+ // ---------- 运行时依赖:惰性加载 + 结果缓存(`undefined` 未试过 / `null` 加载不到) ----------
105
+ let dialect;
106
+ let depError = '';
107
+ async function loadDialect() {
108
+ if (dialect !== undefined)
109
+ return dialect;
110
+ try {
111
+ // 与宿主实装同一份 js-yaml(本机实测 4.3.2)。只在写补丁时加载:插件加载期不碰它,
112
+ // 装不上也绝不因此让插件挂掉。
113
+ dialect = (await import('js-yaml'));
114
+ }
115
+ catch (error) {
116
+ dialect = null;
117
+ depError = messageOf(error);
118
+ }
119
+ return dialect;
120
+ }
121
+ // ---------- 状态:兼容页那一行(走运行时上报通道)+ 写入回执的 warning(消费式) ----------
122
+ /** 回执队列状态:`pendingWarnings` 只在「本次请求的写入没做成交验」时有货。 */
123
+ const pendingWarnings = [];
124
+ // 正常情况下每请求都会被取空;上限只是防止某个不取回的调用路径把它撑大。
125
+ const WARN_QUEUE_MAX = 8;
126
+ function record(report) {
127
+ if (report === null) {
128
+ clearRuntimeNote('patch-write-guard');
129
+ return;
130
+ }
131
+ noteRuntime({
132
+ id: 'patch-write-guard',
133
+ label: '补丁写入校验',
134
+ kind: 'write',
135
+ fallback: 'inform-only',
136
+ detail: (report.kind === 'no-dep' ? '校验依赖不可用' : '复刻可能已过期')
137
+ + `:${report.detail}(写入照常进行,仅少一道「把启动配置写坏」的拦截)`,
138
+ });
139
+ pendingWarnings.push(report.kind === 'no-dep'
140
+ ? '补丁写入未做解析校验(依赖不可用):' + report.detail
141
+ : '补丁写入的解析校验放行(可能是复刻过期):' + report.detail);
142
+ if (pendingWarnings.length > WARN_QUEUE_MAX)
143
+ pendingWarnings.splice(0, pendingWarnings.length - WARN_QUEUE_MAX);
144
+ }
145
+ /**
146
+ * 写入前的完整判定(唯一一处 IO/依赖入口)。
147
+ *
148
+ * 判完把结论记进 note / 回执队列:兼容页据此出「补丁校验不可用」一行,写入回执据此带 warning。
149
+ *
150
+ * @param previous - 改前全文(`''` 视为没有基线:文件不存在,或读不到内容)。
151
+ * @param next - 本次要写入的全文。
152
+ * @returns 判定;`allow=false` 时调用方必须中止写入。
153
+ */
154
+ export async function checkPatchWrite(previous, next) {
155
+ const yaml = await loadDialect();
156
+ if (yaml === null) {
157
+ const decision = {
158
+ allow: true,
159
+ report: { kind: 'no-dep', detail: `无法加载 js-yaml:${depError}` },
160
+ };
161
+ record(decision.report);
162
+ return decision;
163
+ }
164
+ const before = previous === '' ? null : judgePatchText(yaml, previous);
165
+ const after = judgePatchText(yaml, next);
166
+ const decision = decidePatchWrite(before, after);
167
+ record(decision.report);
168
+ return decision;
169
+ }
170
+ /** 写入回执取用:取走并清空本次累积的 warning(每次请求取一次)。 */
171
+ export function takePatchGuardWarnings() {
172
+ return pendingWarnings.splice(0, pendingWarnings.length);
173
+ }
@@ -56,6 +56,7 @@
56
56
  * exactly what its author asked for; the plugin's job is to say so out loud and
57
57
  * point at the switch that overrides it.
58
58
  */
59
+ import { MCP_CLIENT_MODULE } from '../host-names.js';
59
60
  /** Whether a preset declares "no extra text": persona `complete: true` or runtime context off. */
60
61
  export function isSuppressingPreset(facts) {
61
62
  return facts.personaComplete === true || facts.includeRuntimeContext === false;
@@ -74,16 +75,6 @@ export function injectionFactsOf(facts) {
74
75
  const PERSONA_MODULE = '@deepseek-ai/dsh-persona';
75
76
  const AGENT_INSTRUCTIONS_MODULE = '@deepseek-ai/dsh-agent-instructions';
76
77
  const TOOL_SKILL_MODULE = '@deepseek-ai/dsh-tool-skill';
77
- /**
78
- * An MCP client mounted INSIDE a composition. The shipped presets mount none,
79
- * so MCP tools normally come from the host plane (the `$DSH_HOME/cordis.patch.yml`
80
- * layer) and are callable under every preset; a user-authored preset that mounts
81
- * its own client only carries those tools under itself, and this scan is how the
82
- * page can tell the two apart. Tool reachability is not the column's answer,
83
- * though — the server list and the user's notes are a prompt section, which a
84
- * complete persona suppresses whichever client serves the tools.
85
- */
86
- const MCP_CLIENT_MODULE = '@deepseek-ai/dsh-mcp-client';
87
78
  const NAME_LINE = /^(\s*)name:\s*(['"]?)([^'"\s#]+)\2\s*(?:#.*)?$/;
88
79
  const ENTRY_LINE = /^(\s*)-\s/;
89
80
  const DISABLED_LINE = /^\s*disabled:\s*(.+?)\s*(?:#.*)?$/;
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * WHY THIS REPLACES THE TEXT-COMPARISON GATE
6
6
  * ------------------------------------------
7
- * The previous gate in `history/bridge.js` compared each host method against
7
+ * The previous gate in `sessions/bridge.ts` compared each host method against
8
8
  * this plugin's own copy of the official prototype with
9
9
  * `Function.prototype.toString()`. That only ever answered one question —
10
10
  * "is the host running the same release I was written against?" — and it
@@ -30,7 +30,7 @@
30
30
  * leave the rest working.
31
31
  */
32
32
  import { createRequire } from 'node:module';
33
- import { readFileSync, realpathSync } from 'node:fs';
33
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
34
34
  import { dirname, join } from 'node:path';
35
35
  /** Packages whose physical module identity matters to this plugin. */
36
36
  export const IDENTITY_PACKAGES = [
@@ -139,22 +139,88 @@ function prototypeOf(instance) {
139
139
  const proto = Object.getPrototypeOf(instance);
140
140
  return proto !== null && typeof proto === 'object' ? proto : undefined;
141
141
  }
142
- /** Official prototypes this plugin's adapters mirror, when importable. */
143
- function referencePrototypes() {
144
- const out = {};
142
+ // The official package + class each capability owner's members live on. This is
143
+ // the single answer to "which class defines this owner" — both the live
144
+ // reference-prototype lookup below and the exported static table read it, so a
145
+ // host drift is described in exactly one place.
146
+ const WORKSPACE_CLASS = { pkg: '@deepseek-ai/dsh-workspace', target: 'WorkspaceRegistry' };
147
+ const PROJECTION_CLASS = { pkg: '@deepseek-ai/dsh-session-projection-cache', target: 'SessionProjectionCache' };
148
+ // `sessions.*` members are host-private members, but they are still declared on
149
+ // an official class: `@deepseek-ai/dsh-session` is a peer this plugin declares,
150
+ // and `SessionStore` is where `flush`/`liveEntryFor`/`detachEntered`/`enter`/
151
+ // `announce` live. Without this entry the doctor could not see the two
152
+ // capabilities the delete route depends on.
153
+ const SESSIONS_CLASS = { pkg: '@deepseek-ai/dsh-session', target: 'SessionStore' };
154
+ const OWNER_CLASSES = {
155
+ workspace: WORKSPACE_CLASS,
156
+ projectionCache: PROJECTION_CLASS,
157
+ sessions: SESSIONS_CLASS,
158
+ };
159
+ /** The named export's prototype, or undefined when the package is absent/trimmed. */
160
+ function classPrototypeOf(ref) {
145
161
  try {
146
- // eslint-disable-next-line @typescript-eslint/no-var-requires
147
- const ws = createRequire(import.meta.url)('@deepseek-ai/dsh-workspace');
148
- if (ws.WorkspaceRegistry?.prototype !== undefined)
149
- out.workspace = ws.WorkspaceRegistry.prototype;
162
+ const mod = createRequire(import.meta.url)(ref.pkg);
163
+ const klass = mod[ref.target];
164
+ return klass?.prototype;
150
165
  }
151
- catch { /* optional in tests and in trimmed deployments */ }
166
+ catch {
167
+ return undefined; // optional in tests and in trimmed deployments
168
+ }
169
+ }
170
+ /**
171
+ * 哨兵值:不可能命中任何真实会话/工作区(会话 id 由宿主生成,不含此串)。
172
+ * 行为探针真调宿主方法时一律传它 —— 最坏情形是宿主受控拒绝(not found),
173
+ * 而「哨兵输入都能产生意外效果」本身就是要探出来的「不该路由信任」。
174
+ */
175
+ const PROBE_SENTINEL = '__dshm_probe_never_exists__';
176
+ /**
177
+ * 用哨兵实参**真调**一个宿主方法(2026-09-19 分层探针第二层),验证「存在且可调用」:
178
+ * - 同步抛 `TypeError` → 实现坏了/签名漂移,返回失败详情(shape-mismatch);
179
+ * - 受控同步拒绝(普通 `Error`,如 not found)或返回值(含 rejected Promise)→ 通过。
180
+ *
181
+ * 只判同步段:探针框架是同步的(`assessHost` 不能 await),异步结果分类留待
182
+ * 异步化改造(见审查 07 跟进项)。返回的 Promise 一律挂 `.catch` 吞掉,探针
183
+ * 不制造 unhandled rejection。调用必须以 `target` 为 receiver —— 私有方法全靠
184
+ * `this.*` 拿内部状态,脱离 receiver 调用会把好实现误判成 TypeError。
185
+ *
186
+ * 副作用口径(按官方源码逐个核过):liveEntryFor/announce/flush 哨兵输入在
187
+ * 查表处受控抛错,零副作用;detachEntered 传普通对象在未知 id 处早退;
188
+ * deleteSession/archiveSession 等原生入口对未知 id 是读路径校验后受控拒绝。
189
+ * `enter` 不在此列(会往 store 里写哨兵条目),它只做存在性 + 文本比对。
190
+ */
191
+ function callableWithSentinel(target, name, ...args) {
152
192
  try {
153
- const pc = createRequire(import.meta.url)('@deepseek-ai/dsh-session-projection-cache');
154
- if (pc.SessionProjectionCache?.prototype !== undefined)
155
- out.cache = pc.SessionProjectionCache.prototype;
193
+ const method = target[name];
194
+ if (!isFn(method))
195
+ return `${name} 不是函数(methods 检查应已拦下,此处兜底)`;
196
+ const returned = method.call(target, ...args);
197
+ if (returned !== null && typeof returned === 'object' && isFn(returned.then)) {
198
+ const settled = returned;
199
+ if (isFn(settled.catch))
200
+ settled.catch(() => { });
201
+ }
202
+ return undefined;
203
+ }
204
+ catch (error) {
205
+ if (error instanceof TypeError)
206
+ return `${name} 哨兵真调抛 TypeError:${String(error?.message ?? error)}`;
207
+ return undefined;
156
208
  }
157
- catch { /* optional in tests */ }
209
+ }
210
+ /** Official prototypes this plugin's adapters mirror, when importable. */
211
+ function referencePrototypes() {
212
+ const out = {};
213
+ const workspace = classPrototypeOf(WORKSPACE_CLASS);
214
+ if (workspace !== undefined)
215
+ out.workspace = workspace;
216
+ const cache = classPrototypeOf(PROJECTION_CLASS);
217
+ if (cache !== undefined)
218
+ out.cache = cache;
219
+ // sessions 的五个私有方法全声明在官方 SessionStore.prototype 上(本插件 peer 依赖
220
+ // 同包同版本),与 workspace/cache 同机制接入文本比对 —— 此前该域只有存在性检查。
221
+ const sessions = classPrototypeOf(SESSIONS_CLASS);
222
+ if (sessions !== undefined)
223
+ out.sessions = sessions;
158
224
  return out;
159
225
  }
160
226
  /**
@@ -170,7 +236,7 @@ const CAPABILITY_SPECS = [
170
236
  label: '读取工作区状态',
171
237
  kind: 'read',
172
238
  owner: 'workspace',
173
- fallback: 'degrade-read',
239
+ fallback: 'refuse-operation',
174
240
  methods: ['requireState'],
175
241
  probe: (t) => {
176
242
  try {
@@ -194,7 +260,7 @@ const CAPABILITY_SPECS = [
194
260
  label: '读取工作区表',
195
261
  kind: 'read',
196
262
  owner: 'workspace',
197
- fallback: 'degrade-read',
263
+ fallback: 'refuse-operation',
198
264
  methods: ['requireTable'],
199
265
  probe: (t) => {
200
266
  try {
@@ -213,7 +279,7 @@ const CAPABILITY_SPECS = [
213
279
  label: '工作区索引结构',
214
280
  kind: 'read',
215
281
  owner: 'workspace',
216
- fallback: 'degrade-read',
282
+ fallback: 'refuse-operation',
217
283
  fields: [
218
284
  { name: 'headers', instanceOf: 'Map' },
219
285
  { name: 'sessionPaths', instanceOf: 'Map' },
@@ -226,7 +292,7 @@ const CAPABILITY_SPECS = [
226
292
  label: '读取会话头部',
227
293
  kind: 'read',
228
294
  owner: 'workspace',
229
- fallback: 'degrade-read',
295
+ fallback: 'refuse-operation',
230
296
  methods: ['readSessionHeader'],
231
297
  },
232
298
  // ---- workspace registry: write side ------------------------------------
@@ -262,6 +328,9 @@ const CAPABILITY_SPECS = [
262
328
  fallback: 'native-entry',
263
329
  methods: ['archiveSession'],
264
330
  optional: true,
331
+ // 分层探针第二层(哨兵真调):官方实现对未知 id 走读路径校验后受控拒绝
332
+ //(WorkspaceUnknownSessionError);同步 TypeError 才是「不该路由信任」的信号。
333
+ probe: (t) => callableWithSentinel(t, 'archiveSession', PROBE_SENTINEL),
265
334
  },
266
335
  {
267
336
  id: 'workspace.unarchive-native',
@@ -271,6 +340,7 @@ const CAPABILITY_SPECS = [
271
340
  fallback: 'native-entry',
272
341
  methods: ['unarchiveSession'],
273
342
  optional: true,
343
+ probe: (t) => callableWithSentinel(t, 'unarchiveSession', PROBE_SENTINEL),
274
344
  },
275
345
  {
276
346
  id: 'workspace.batch-native',
@@ -280,6 +350,7 @@ const CAPABILITY_SPECS = [
280
350
  fallback: 'native-entry',
281
351
  methods: ['archiveWorkspaceSessions'],
282
352
  optional: true,
353
+ probe: (t) => callableWithSentinel(t, 'archiveWorkspaceSessions', [PROBE_SENTINEL]),
283
354
  },
284
355
  {
285
356
  id: 'workspace.delete-native',
@@ -292,6 +363,10 @@ const CAPABILITY_SPECS = [
292
363
  fallback: 'native-entry',
293
364
  methods: ['deleteSession'],
294
365
  optional: true,
366
+ // 删除路由的 native 判定此前只看方法存在(07 审查五档问题 2):宿主升级把同名
367
+ // 方法换成别的签名,路由仍会走 native 裸调。哨兵真调把口径提到「可调用」,
368
+ // 文本比对收紧(见 inspectCapability 的 delete 类分支)负责「实现漂移」那一层。
369
+ probe: (t) => callableWithSentinel(t, 'deleteSession', PROBE_SENTINEL),
295
370
  },
296
371
  // ---- sessions runtime (private members, used only on the live branch) ---
297
372
  {
@@ -301,6 +376,11 @@ const CAPABILITY_SPECS = [
301
376
  owner: 'sessions',
302
377
  fallback: 'disable-destructive',
303
378
  methods: ['flush', 'liveEntryFor', 'detachEntered'],
379
+ // 零副作用真调(按官方源码核对):liveEntryFor/flush 对哨兵在查表处受控抛错;
380
+ // detachEntered 传普通对象在未知 id 处早退(entry 形状 {id} 即可)。
381
+ probe: (t) => callableWithSentinel(t, 'liveEntryFor', PROBE_SENTINEL)
382
+ ?? callableWithSentinel(t, 'flush', PROBE_SENTINEL)
383
+ ?? callableWithSentinel(t, 'detachEntered', { id: PROBE_SENTINEL }),
304
384
  },
305
385
  {
306
386
  id: 'sessions.cold-announce',
@@ -309,6 +389,9 @@ const CAPABILITY_SPECS = [
309
389
  owner: 'sessions',
310
390
  fallback: 'disable-destructive',
311
391
  methods: ['enter', 'announce'],
392
+ // announce 哨兵真调:内部先 liveEntryFor 查表,哨兵输入在查表处受控抛错。
393
+ // **enter 不真调**(官方实现对任意输入都会往 store 写入条目),它只有存在性 + 文本比对。
394
+ probe: (t) => callableWithSentinel(t, 'announce', PROBE_SENTINEL),
312
395
  },
313
396
  // ---- projection cache ---------------------------------------------------
314
397
  {
@@ -319,12 +402,37 @@ const CAPABILITY_SPECS = [
319
402
  fallback: 'disable-destructive',
320
403
  methods: ['write', 'put', 'requireTable'],
321
404
  },
405
+ {
406
+ // B2:此前它只是 bridge 运行时那句拒绝里的**临时 id**(`acquireCacheGuard` 里现场拼的),
407
+ // 于是客户端压根不知道它 —— 宿主表不可删时,"路由说可用、点下去必拒"(V13)。提升为
408
+ // 能力表的正式条目后,路由判定与运行时前提同一份依据。
409
+ id: 'projection.table-delete',
410
+ label: '投影缓存行删除',
411
+ kind: 'delete',
412
+ owner: 'projectionCache',
413
+ fallback: 'disable-destructive',
414
+ // 探测内容就是运行时那句硬前提:`requireTable().delete` 在不在。
415
+ probe: (target) => {
416
+ if (typeof target?.requireTable !== 'function')
417
+ return '宿主投影缓存缺少 requireTable';
418
+ let table;
419
+ try {
420
+ table = target.requireTable();
421
+ }
422
+ catch (error) {
423
+ return `宿主投影缓存 requireTable() 抛错:${String(error)}`;
424
+ }
425
+ if (typeof table?.delete !== 'function')
426
+ return '宿主投影缓存存储不支持安全删除(table.delete 缺失)';
427
+ return undefined;
428
+ },
429
+ },
322
430
  {
323
431
  id: 'projection.delete-native',
324
432
  label: '投影缓存删除屏障',
325
433
  kind: 'delete',
326
434
  owner: 'projectionCache',
327
- // Absent on rc.2: `history/bridge.js` installs a checked write barrier
435
+ // Absent on rc.2: `sessions/bridge.ts` installs a checked write barrier
328
436
  // instead, so absence is a routing fact, not a failure. Only a cache whose
329
437
  // write path cannot be wrapped at all is a real problem.
330
438
  fallback: 'native-entry',
@@ -365,13 +473,17 @@ export const OPERATION_ROUTES = {
365
473
  native: ['workspace.delete-native'],
366
474
  // NOTE: `projection.delete-native` is deliberately NOT a route requirement.
367
475
  // A host cache without its own delete barrier is expected on rc.2; the
368
- // bridge wraps it (`workspace.js` / `history/bridge.js`) and reports a
476
+ // bridge wraps it (`workspace.js` / `sessions/bridge.ts`) and reports a
369
477
  // refusal itself when even that is impossible. Requiring the native barrier
370
478
  // here would disable deletion on exactly the host this plugin was verified
371
479
  // against.
372
480
  adapter: [
373
481
  'workspace.enqueue', 'workspace.set-state', 'workspace.index-header',
374
482
  'sessions.detach-live', 'sessions.cold-announce', 'projection.write',
483
+ // B2:运行时硬前提(`table.delete`)也算一条路由要求 —— 不算进来的话,宿主表不可删时
484
+ // 路由说可用、点下去必拒。batch 的 adapter 路由**有意**保持只有 enqueue/set-state:
485
+ // 它的逐条失败会在结果里按 sessionId 报出来,此处不放宽也不收紧。
486
+ 'projection.table-delete',
375
487
  ],
376
488
  },
377
489
  list: { native: [], adapter: ['workspace.read-state', 'workspace.read-table', 'workspace.index-shape'] },
@@ -437,6 +549,21 @@ function inspectCapability(spec, target, reference) {
437
549
  return { ...base, state: 'shape-mismatch', detail: failure, missing: [], ...(textMatch === undefined ? {} : { textMatch }) };
438
550
  }
439
551
  }
552
+ // 删除类收紧(2026-09-19,07 审查五档问题 2):textMatch === false 意味着宿主运行的
553
+ // 不是本插件适配并验证过的实现(参考副本与宿主同源时恒真 —— junction 同物理文件;
554
+ // 只有宿主升级/漂移才会 false)。读/写类维持「按能力使用」的宽口径,但删除不可逆:
555
+ // 漂移的 flush/detachEntered/announce/deleteSession 一律不盲调,路由降级 adapter 或
556
+ // 拒绝,恢复文案引导更新插件。bridge.js 曾因无害重构误报而移除过文本比对 —— 本次
557
+ // 只收紧 delete 类,且参考副本不可解析时 textMatch 为 undefined,不拦截(优雅回退)。
558
+ if (textMatch === false && spec.kind === 'delete') {
559
+ return {
560
+ ...base,
561
+ state: 'shape-mismatch',
562
+ detail: '成员齐备但实现文本与本插件适配的版本不同:删除类能力不盲调漂移实现',
563
+ missing: [],
564
+ textMatch,
565
+ };
566
+ }
440
567
  return {
441
568
  ...base,
442
569
  state: 'ok',
@@ -458,6 +585,31 @@ function inspectCapability(spec, target, reference) {
458
585
  export const SUBSTITUTED_CAPABILITIES = CAPABILITY_SPECS
459
586
  .filter((spec) => spec.optional === true)
460
587
  .map((spec) => spec.id);
588
+ /**
589
+ * The capability table as a *static* view: for each spec, the official class
590
+ * whose prototype must carry the required members. This exists so a CLI without
591
+ * a running host (the doctor) can still answer "can this build archive / delete /
592
+ * list?".
593
+ *
594
+ * Derived from {@link CAPABILITY_SPECS} — the previous hand-written second table
595
+ * drifted silently: it carried 9 of the 15 ids and missed every `sessions.*` and
596
+ * native-slot capability the routing actually needs.
597
+ */
598
+ export const CAPABILITY_STATIC = CAPABILITY_SPECS.map((spec) => {
599
+ const ref = OWNER_CLASSES[spec.owner];
600
+ const methods = spec.methods;
601
+ return {
602
+ id: spec.id,
603
+ label: spec.label,
604
+ kind: spec.kind,
605
+ optional: spec.optional === true,
606
+ // A `fields`-only spec (instance Maps) and a spec whose judgement lives in
607
+ // its `probe` have nothing a prototype can answer — they stay runtime-only.
608
+ check: ref !== undefined && methods !== undefined && methods.length > 0
609
+ ? { pkg: ref.pkg, target: ref.target, members: methods }
610
+ : null,
611
+ };
612
+ });
461
613
  /**
462
614
  * Inspect the live host behind one plugin context.
463
615
  *
@@ -478,6 +630,9 @@ export function assessHost(ctx) {
478
630
  }
479
631
  };
480
632
  // Cordis exposes traceable proxies; compare and inspect the original objects.
633
+ // `Symbol.for('cordis.original')` 与 cordis 导出的 `symbols.original` 是**同一个符号**
634
+ // (该包内即 `original: Symbol.for("cordis.original")`)。这里不 import cordis,是为了让
635
+ // 兼容探测在宿主包加载失败时仍能工作 —— 本文件对宿主零硬依赖(见文件头的 createRequire)。
481
636
  const unwrap = (value) => {
482
637
  if (value === null || typeof value !== 'object')
483
638
  return value;
@@ -496,7 +651,7 @@ export function assessHost(ctx) {
496
651
  const targets = {
497
652
  workspace: { target: registry, reference: references.workspace },
498
653
  projectionCache: { target: cache, reference: references.cache },
499
- sessions: { target: sessions, reference: undefined },
654
+ sessions: { target: sessions, reference: references.sessions },
500
655
  persistence: { target: unwrap(get('sessionPersistence')), reference: undefined },
501
656
  };
502
657
  const findings = CAPABILITY_SPECS.map((spec) => {
@@ -507,6 +662,7 @@ export function assessHost(ctx) {
507
662
  const modules = {};
508
663
  const sameAsHost = {};
509
664
  const blockers = [];
665
+ const unverified = [];
510
666
  const hostRoot = hostPackageRoot();
511
667
  for (const name of IDENTITY_PACKAGES) {
512
668
  const resolved = safeResolve(name);
@@ -518,6 +674,7 @@ export function assessHost(ctx) {
518
674
  }
519
675
  if (hostRoot === null) {
520
676
  sameAsHost[name] = null;
677
+ unverified.push(name);
521
678
  continue;
522
679
  }
523
680
  // Resolve the same name from the host installation's own anchor, then
@@ -534,6 +691,10 @@ export function assessHost(ctx) {
534
691
  sameAsHost[name] = same;
535
692
  if (same === false)
536
693
  blockers.push(`${name}:插件与宿主加载的是两份不同拷贝(运行 node scripts/host-deps.mjs --fix)`);
694
+ // 解析得到、却比不了:宿主锚点里找不到它。不能静默 —— 否则整块身份校验等于没做,
695
+ // 而页头仍报「全部可用」(pnpm 的 .pnpm 隔离目录就是这种情形,见 hostPackageRoot)。
696
+ if (same === null)
697
+ unverified.push(name);
537
698
  }
538
699
  // Degraded = something is genuinely unavailable. Optional slots are excluded:
539
700
  // their absence selects the adapter route and leaves the feature fully
@@ -552,6 +713,7 @@ export function assessHost(ctx) {
552
713
  modules,
553
714
  sameAsHost,
554
715
  blockers,
716
+ unverified,
555
717
  },
556
718
  findings,
557
719
  degraded,
@@ -566,22 +728,41 @@ export function assessHost(ctx) {
566
728
  * Anchoring on the resolved entry rather than on `require.resolve('@deepseek-ai/dsh')`
567
729
  * matters: the plugin never imports the `dsh` app package, so it need not be
568
730
  * resolvable from the plugin at all — only the shared libraries are.
731
+ *
732
+ * 注意 pnpm:这里在 `.pnpm/<name>@<ver>/node_modules/` 布局下会返回**该包的隔离目录**
733
+ * (那也是一个 `node_modules/@deepseek-ai`)。它本身不是问题 —— node 的解析会继续向上走到
734
+ * 顶层 `node_modules`,所以能解析出的包集合与顶层锚点相同(实测确认)。真正的风险在
735
+ * 调用方:从锚点解析不到的包会被判成 `same = null`,见 assessHost 里的 `unverified`。
569
736
  */
570
737
  function hostPackageRoot() {
738
+ // 与 doctor 的 `findHost` **同一套策略**(先看 `$DSH_HOME/profiles/node_modules/@deepseek-ai`,
739
+ // 再从插件自身位置逐级上溯,并确认那一层里真有 `dsh/package.json`)。此前运行时只按"插件
740
+ // 自己解析到的包"上溯,dev 布局下会把仓库里的副本当成宿主锚点、比出假的 `true` —— 于是
741
+ // 界面说 ok、doctor 说 SEPARATE COPY(V8)。两处口径分裂本身就是缺陷。
742
+ const home = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || '', '.dsh');
743
+ const candidates = [];
744
+ if (home !== '')
745
+ candidates.push(join(home, 'profiles', 'node_modules', '@deepseek-ai'));
571
746
  for (const anchor of IDENTITY_PACKAGES) {
572
747
  const resolved = realPathOf(safeResolve(anchor));
573
748
  if (resolved === null)
574
749
  continue;
575
750
  let dir = dirname(resolved);
576
- for (let i = 0; i < 3; i += 1) {
577
- if (dir.endsWith(join('node_modules', '@deepseek-ai')))
578
- return dir;
751
+ for (let i = 0; i < 4; i += 1) {
752
+ if (dir.endsWith(join('node_modules', '@deepseek-ai'))) {
753
+ candidates.push(dir);
754
+ break;
755
+ }
579
756
  const parent = dirname(dir);
580
757
  if (parent === dir)
581
758
  break;
582
759
  dir = parent;
583
760
  }
584
761
  }
762
+ for (const candidate of candidates) {
763
+ if (existsSync(join(candidate, 'dsh', 'package.json')))
764
+ return candidate;
765
+ }
585
766
  return null;
586
767
  }
587
768
  /** Human-readable summary line for logs and the settings page header. */
@@ -0,0 +1,25 @@
1
+ // 运行时降级上报通道 —— 「插件自己发现自己降级了」的统一出口。
2
+ //
3
+ // 为什么需要它:有一批降级**没有抛错可挂**:它们发生在装配期或探测期(第三方包装接管了本插件的
4
+ // 运行时适配、注入域装配失败、启动期表达式或服务名解析出问题),失败当下的表现只是"某个功能
5
+ // 少了一半"或者"静默失效"。此前这些点只落 console / logger,用户在界面上看不到,要等事故之后
6
+ // 才倒查(审查报告 §3 记了多处)。这里给它们一个共用出口:`noteRuntime` 记一条,
7
+ // `compat-status` 把全部条目并进 findings —— 兼容页因此成为"降级总账"。
8
+ //
9
+ // 与 `probe.ts` 的 CAPABILITY_SPECS 分工:那张表是**写死的宿主能力清单**(每次探测重算),
10
+ // 这里是**运行时事件**(谁在什么时候发现了什么),两者在 compat-status 里合并展示。
11
+ //
12
+ // 键是 `id`:同一主题重复上报只覆盖不堆积(它表达的是"现在的状态",不是日志)。
13
+ const notes = new Map();
14
+ /** 记一条(同 id 覆盖)。 */
15
+ export function noteRuntime(note) {
16
+ notes.set(note.id, { ...note, at: Date.now() });
17
+ }
18
+ /** 该主题已恢复正常 → 收掉这一行。 */
19
+ export function clearRuntimeNote(id) {
20
+ notes.delete(id);
21
+ }
22
+ /** 当前全部运行时降级(`compat-status` 取用)。 */
23
+ export function runtimeNotes() {
24
+ return [...notes.values()];
25
+ }