dsh-skill-tag 0.1.13 → 0.1.16

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.
package/README.md CHANGED
@@ -67,9 +67,16 @@ office * plugin # 只要 office 包带来的、且是插件相关
67
67
  <img src="https://cdn.jsdelivr.net/gh/aawef/dsh-skill-manager-tags@main/images/workspace-filter.png" width="900" alt="工作区管理标签双列界面">
68
68
 
69
69
  左列是标签条件,下面实时显示**解析结果**和状态「有条件筛选(严格子集)」;
70
- 右列 `技能 (54)` 就是该工作区下发给模型的全部技能,每行可以「查看 tag」「禁用」,
71
- 📁 直接打开技能所在文件夹。列表顶部的**搜索框**只过滤显示(标题会变成 `技能 (匹配/总数)`),
72
- 不改动实际下发的技能集合。底部还能「+ 精细化添加技能」「查看被禁用的技能」。
70
+ 右列标题是 `技能 (54) · 模型可见 48`:前一个数是命中表达式的**全部技能**,
71
+ 后一个数是其中**真正下发给模型**的那些。两者不等时标题下方会多出橙色说明,
72
+ 列出差额的去向——`X 个只能 /名字 手动调用`(SKILL.md 里写了 `disable-model-invocation: true`)
73
+ 和 `Y 个宿主没注册`(扫描到了但宿主没装),对应行也会带 `仅手动` / `未注册` 徽标并置灰。
74
+ 还有第三种状态 `暂不可判定`:这一趟没拿到完整的宿主视角(比如该会话没在运行,
75
+ 宿主的 skill registry 只给了全局层),此时"没返回"不等于"没注册",这些技能带灰色虚线徽标、
76
+ **不置灰**,也不计入 `模型可见 N`。**这三种状态的区别与成因见下一节。**
77
+ 每行都可以「查看 tag」「禁用」,📁 直接打开技能所在文件夹。列表顶部的**搜索框**只过滤显示
78
+ (标题会变成 `技能 (匹配/总数)`),不改动实际下发的技能集合。底部还能「+ 精细化添加技能」
79
+ 「查看被禁用的技能」。
73
80
 
74
81
  ### 图 6 · 对话「管理标签(对话)」:可以有自己的一份
75
82
 
@@ -78,6 +85,56 @@ office * plugin # 只要 office 包带来的、且是插件相关
78
85
  同一个工作区下的某个对话想单独收窄,就在这里改:它会记下**继承来源**与时间
79
86
  (`改动不影响工作区`),左下角一键「继承工作区配置」覆盖回来。
80
87
 
88
+ ## 技能注册的三种状态:已注册 / 未注册 / 暂不可判定
89
+
90
+ 界面右列标题 `技能 (全部) · 模型可见 N` 里的 N,只数**已被宿主确认注册**的技能。
91
+ 而"未注册"和"暂不可判定"是两件完全不同的事,别把它们读成一句:
92
+
93
+ | 状态 | 判据 | 界面表现 | 是否计入 `模型可见 N` |
94
+ | --- | --- | --- | --- |
95
+ | **已注册** | 这一趟宿主快照里**返回了这个名字** | 正常显示 | ✅ 计入 |
96
+ | **未注册** | 宿主视角**完整**,但没返回这个名字 | 置灰 + 橙色「未注册」 | ❌ 不计入 |
97
+ | **暂不可判定** | 这次没取到完整的宿主视角 | 灰色虚线徽标,**不置灰** | ❌ 不计入 |
98
+
99
+ ### 「宿主快照」是什么
100
+
101
+ 插件判断一个技能"模型能不能看到",是去问宿主:"这条技能你注册了吗?" ——
102
+ 宿主对这个问题的一次完整回答,就是一次**宿主快照**(宿主 `ctx.skills.snapshot()`
103
+ 返回的技能清单)。**没取到快照**=这一问没有得到一个可信的完整回答,
104
+ 于是那批技能既不该标"已注册"(没证据),也不该标"未注册"(更没有证据)。
105
+
106
+ ### 为什么会取不到完整快照
107
+
108
+ 宿主的技能注册表是**分层**的:`global` 层 + 按 agent / preset 展开的层。
109
+ 不带 `scope` 去问时,宿主**只回 `global` 层**。而覆盖用户级技能根
110
+ (`~/.dsh/skills`、`~/.agents/skills`)的那些 provider 恰好注册在 agent 层里 ——
111
+ 一旦 `scope` 传不下去,这些名字就**整段不在回答里**,"没返回"于是被误当成"没注册"。
112
+
113
+ 这是 0.1.15 修掉的缺陷(issue #1):此前同一份本机文件,在有 live agent 的对话里是
114
+ "已注册",在没在跑的对话里就变成"宿主没注册,模型侧根本不存在"——仅仅换一个对话,
115
+ 结论就翻转。
116
+
117
+ 可能的触发原因,按实际出现频率排序:
118
+
119
+ 1. **该会话没有在运行**——历史对话、已关闭的标签页、或刚切过来还没起 agent。
120
+ `agents.list()` 里找不到它,`scope` 传不下去,宿主只给 global 层。这时列表顶部会
121
+ 标注「该会话未运行,以下为全局视角」。
122
+ 2. **会话 ID 对不上**——浮层传的是 `session-<uuid>`,宿主 agent 暴露的 id 若是另一个
123
+ 字符串(宿主侧依次取 `session.id` / `session.header.id` / `agent.id`),同样等于取不到 agent。
124
+ 3. **宿主自己报 `complete: false`**——宿主明确说这一趟没收集全(某个 provider 还在加载、
125
+ 某个根扫描失败)。插件直接采用宿主的结论,整批降级为不可判定。
126
+ 4. **快照抛错或并发守卫被占**——`snapshotSkills()` 返回 null,插件退回纯文件系统自扫描;
127
+ 这时它更没资格断言"宿主没注册"。
128
+ 5. **宿主确实没注册**——这种情况"暂不可判定"是保守过头了。但只要拿到完整视角,
129
+ 插件就会给出明确的「未注册」,不会一直挂着。
130
+
131
+ ### 想看确定结论怎么办
132
+
133
+ - 把对话切到**正在运行**的那个会话再看——它能拿到完整视角,用户级技能会恢复成"已注册"。
134
+ - 设置页那种不带 `conversationId` 的口径本身就是全局视角,出现"暂不可判定"是预期行为。
135
+ - 视角不完整时,`POST /skill-tag/api/skills/purge-stale` 会直接返回 `409 INCOMPLETE_VIEW`
136
+ 而拒绝执行——因为它要靠"未注册"这个结论去删关联记录,视角不可信时删了就是误删。
137
+
81
138
  ## 安装
82
139
 
83
140
  这是一个 DSH(DeepSeek Harness)**插件**,装进 profile 后随宿主启动加载。
@@ -177,7 +234,8 @@ node tools/auto-tag.mjs ... --domain ./my-domain-map.json --apply # 连领域轴
177
234
  - **对话**:会话行的「…」菜单(图 1),或聊天框左下角「+」→「管理对话标签」(图 2)
178
235
 
179
236
  左列写标签条件表达式,右列是命中结果(可手动添加 / 禁用 / 恢复)。改完立刻生效:
180
- 该对话下发给模型的技能列表就是右列那一份。两张双列界面见「图 5」「图 6」。
237
+ 该对话下发给模型的技能列表就是右列里**模型可见**的那一份(标题的第二段数字)。
238
+ 两张双列界面见「图 5」「图 6」。
181
239
 
182
240
  ### 3. 表达式语法
183
241
 
@@ -275,6 +333,7 @@ images/ README 用的界面截图(不进 npm 包)
275
333
  | [docs/对话管理标签.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/%E5%AF%B9%E8%AF%9D%E7%AE%A1%E7%90%86%E6%A0%87%E7%AD%BE.md) | 对话级配置与继承 |
276
334
  | [docs/技能发现与宿主对齐.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/%E6%8A%80%E8%83%BD%E5%8F%91%E7%8E%B0%E4%B8%8E%E5%AE%BF%E4%B8%BB%E5%AF%B9%E9%BD%90.md) | 为什么"插件看到的技能数"曾经少于宿主,以及怎么对齐 |
277
335
  | [docs/deny-shadow-覆盖率.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/deny-shadow-%E8%A6%86%E7%9B%96%E7%8E%87.md) | 为什么包带来的技能会**静默漏网**(`path` 为 null 的那批),以及宿主对影子候选的强校验、层/rank 语义 |
336
+ | [docs/修复指南.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/%E4%BF%AE%E5%A4%8D%E6%8C%87%E5%8D%97.md) | **「打了标签技能却没出现」的排查手册**:三层判定树、transcript 逐帧解压取证、预设关闭默认技能根(A 层)、已知假阳性、升级回归清单 |
278
337
  | [docs/skill联系tag与打开文件夹.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/skill%E8%81%94%E7%B3%BBtag%E4%B8%8E%E6%89%93%E5%BC%80%E6%96%87%E4%BB%B6%E5%A4%B9.md) | 两个方向编辑 + 📁 按钮 |
279
338
  | [CHANGELOG.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/CHANGELOG.md) | 按日期倒序的完整开发记录 |
280
339
 
@@ -0,0 +1,214 @@
1
+ # 修复指南:标签打对了,会话里却没有那个技能
2
+
3
+ > 面向维护者。写于 2026-11-02,对应 CHANGELOG 尚未收录的一次线上排查(v0.1.13)。
4
+ > 适用场景:**用户在某个会话上打了标签、或给某个工作区配了标签,但模型侧技能目录里
5
+ > 根本没有那些名字**。本文给出判定树、逐项取证命令、以及三种可比选的修法。
6
+
7
+ ## 1. 先分清三种"没有"
8
+
9
+ 同样是"看不见",根因完全不同。**在动手之前必须先定位到哪一层断的**,
10
+ 否则会去修一个没坏的地方(0.1.13 之后最常踩的坑)。
11
+
12
+ | 层 | 断了的典型表现 | 判定依据 |
13
+ | --- | --- | --- |
14
+ | **A. 发现层**(技能根本没被扫到) | 该技能不在会话的技能目录里,且也不在 `/skill-tag/api/skills` 里 | 该技能的任何标签规则都不可能命中 |
15
+ | **B. 匹配层**(扫到了,但被 deny) | 在 `/skills` 里出现,描述被加上 `[blocked by skill-tag]` | 标签条件没覆盖它,或被 `disabledSkills` 点了 |
16
+ | **C. 生效层**(匹配了,标签压根没写进去) | `skill_tag.json` 里对话 `own.config` 不含那个标签 | 用户打到了工作区,但对话是冻结快照 |
17
+
18
+ 历史上 0.1.x 修过的 bug 几乎全在 B(`deny-shadow-覆盖率.md`)和"对话冻结"(`对话管理标签.md` §继承)。
19
+ **A 这一层几乎没有文档**,而它恰恰是"我打了标签为什么没反应"最常见的答案。
20
+
21
+ ## 2. 判定树
22
+
23
+ ```
24
+ 模型侧技能目录里没有技能 X
25
+ ├─ X 在 GET /skill-tag/api/skills 里吗?
26
+ │ ├─ 不在 → A 发现层。可能:该会话的预设/插件根本没扫描 X 所在目录
27
+ │ └─ 在 → 继续
28
+ │ ├─ X 的 description 以 "[blocked by skill-tag]" 开头吗?
29
+ │ │ ├─ 是 → B 匹配层。看 effective.config 是否覆盖 X,看 disabledSkills
30
+ │ │ └─ 否 → C 生效层。看该会话 own 是否真的写了标签
31
+ ```
32
+
33
+ ## 3. 取证命令(只读,按顺序打)
34
+
35
+ 端口每台机器不同,**不要猜**:用运行时上下文里的 Web GUI URL,或
36
+ `Get-NetTCPConnection -State Listen | Where OwningProcess -eq <dsh pid>`。
37
+
38
+ ```powershell
39
+ $b = "http://127.0.0.1:<PORT>/skill-tag/api"
40
+ $cid = "session-xxxxxxxx-..." # 用户报的那个会话 id
41
+
42
+ # ① 全域技能表(不带 hint):本插件认为自己"能看见"的全部技能
43
+ curl.exe -s "$b/skills" > $env:TEMP\sk-all.json
44
+ # ② 带会话 hint:等同该会话代理看到的集合
45
+ curl.exe -s "$b/skills?conversationId=$cid" > $env:TEMP\sk-conv.json
46
+ # ③ 该会话的生效配置 + view + catalog
47
+ curl.exe -s "$b/conversations/config?conversationId=$cid" > $env:TEMP\cfg.json
48
+ # ④ 最终候选(走对话四区口径)
49
+ curl.exe -s "$b/conversations/candidates?conversationId=$cid"
50
+ ```
51
+
52
+ 读 ① / ② 里的 `description` 前缀判 B 层;读 ③ 的 `config.orTags` 判 C 层;
53
+ **① 和 ② 里都没有该名字**就是 A 层。
54
+
55
+ ### 3.1 模型侧到底看到了什么(唯一权威口供)
56
+
57
+ `~/.dsh/storages/skill_tag.json` 和 `/conversations/config` 都是**本插件的口径**,
58
+ 可能和模型真实收到的不一致(见 §6.1 的假阳性)。真正的口供在会话 transcript 里:
59
+
60
+ ```powershell
61
+ $f = "$env:USERPROFILE\.dsh\sessions\<编码后的cwd>\<sessionId>\session.v4.jsonl.zstd"
62
+ $out = "$env:TEMP\sess.jsonl"
63
+ node -e "const z=require('zlib'),fs=require('fs');
64
+ const b=fs.readFileSync(process.argv[1]);
65
+ const magic=Buffer.from([0x28,0xB5,0x2F,0xFD]); let idx=[],p=0;
66
+ while((p=b.indexOf(magic,p))!==-1){idx.push(p);p+=4;}
67
+ const parts=[]; for(let k=0;k<idx.length;k++){
68
+ const s=idx[k], e=(k+1<idx.length?idx[k+1]:b.length);
69
+ try{parts.push(z.zstdDecompressSync(b.subarray(s,e)).toString('utf8'))}catch{}
70
+ } fs.writeFileSync(process.argv[2], parts.join(''));" $f $out
71
+ ```
72
+
73
+ 然后 grep `<available_skills>`。那一条 user 消息就是模型收到的**完整技能目录** ——
74
+ 比对它的名字列表与 `/skills` 的差集,就能立刻分清"插件以为可见"和"模型真的可见"。
75
+
76
+ > transcript 是**分帧 zstd**(一条消息一帧),整文件 `zstdDecompressSync` 只会解出第一帧
77
+ > (通常只有 219 字节的 `{"type":"session"...}` header)。必须按 magic 逐帧解压,见上面的脚本。
78
+
79
+ 同一条 transcript 里还有 `agent-preset/selected` 事件,能看出该会话用的是哪个预设 ——
80
+ A 层问题九成出在这里。
81
+
82
+ ## 4. A 层:发现层(最常见,也最容易被误判成插件 bug)
83
+
84
+ ### 4.1 根因:预设可以整体关闭默认技能根
85
+
86
+ `@deepseek-ai/dsh-skill-filesystem` 的默认根(详见运行时文档 "Roots and priority"):
87
+
88
+ | Rank | Source | Path |
89
+ | --- | --- | --- |
90
+ | 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
91
+ | 200 | `project-agents` | `<projectRoot>/.agents/skills` |
92
+ | 300 | `custom` | `Config.customSkillDirs` |
93
+ | 400 | `user-dsh` | `<dshHome>/skills` |
94
+ | 500 | `user-agents` | `<agentsHome>/skills` |
95
+
96
+ `includeDefaultRoots: false` 会把 100/200/400/500 **整排删掉**,只剩 `customSkillDirs`。
97
+
98
+ 于是任何写了这行的预设(本机实测:StudyMate 的「学习模式」、`@tt-a1i/archify-dsh` 的隔离根)
99
+ 都把 `~/.dsh/skills` 挡在门外。用户在那类会话上打 `来自:dsh-home` 之类的标签,
100
+ **规则算得再对也没有对象可匹配** —— 因为早在发现阶段就没了。
101
+
102
+ 取证:
103
+
104
+ ```powershell
105
+ # 1) 找该会话用的预设
106
+ Select-String -Path "$env:USERPROFILE\.dsh\sessions\...\session.v4.jsonl" -Pattern "agent-preset/selected"
107
+ # 2) 看那个预设有没有关默认根
108
+ Select-String -Path "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\<pkg>\preset\**\agent.cordis.yml" -Pattern "includeDefaultRoots"
109
+ ```
110
+
111
+ ### 4.2 三种修法
112
+
113
+ | 方案 | 做法 | 代价 |
114
+ | --- | --- | --- |
115
+ | **A1** 全局放开 | 在 profile 的 `cordis.patch.yml` insert 一条独立 `dsh-skill-filesystem`(`providerName` 唯一、`includeDefaultRoots: true`) | 所有预设都吃到 `~/.dsh/skills`,预设原本的隔离设计被削弱 |
116
+ | **A2** 追加额外根 | 在**该预设**的 `customSkillDirs` 末尾追加一个独立目录 | 改的是包目录,`pnpm update` 会覆盖 |
117
+ | **A3** 换预设 | 让用户在该会话切到不隔离的预设 | 隔离带来的专属技能随之消失,可能整条流程跑不动 |
118
+
119
+ **推荐 A2**,并且把"额外根"做成**用户自己的目录 + junction**,而不是往别人包里塞文件:
120
+
121
+ ```powershell
122
+ # 1) 建一个自己的额外技能根,用 junction 指回真实技能(升级自动跟随,不会两份内容打架)
123
+ $extra = "$env:USERPROFILE\.dsh\studymate-extra-skills"
124
+ New-Item -ItemType Directory -Force -Path $extra | Out-Null
125
+ foreach ($n in @('obsidian-markdown','obsidian-bases','obsidian-cli','json-canvas','defuddle','knap')) {
126
+ cmd /c mklink /J "$extra\$n" "$env:USERPROFILE\.dsh\skills\$n" | Out-Null
127
+ }
128
+ ```
129
+
130
+ ```yaml
131
+ # 2) 在目标预设的 skill-filesystem 行追加一条(保留原有占位符那行)
132
+ - id: skill-filesystem
133
+ name: '@deepseek-ai/dsh-skill-filesystem'
134
+ config:
135
+ includeDefaultRoots: false
136
+ customSkillDirs:
137
+ - '__STUDYMATE_SKILLS__'
138
+ - 'C:\Users\ThinkPad\.dsh\studymate-extra-skills' # 本地追加(非官方)
139
+ ```
140
+
141
+ 要点:
142
+
143
+ - **路径用反斜杠**。YAML 单引号里 `\` 不转义,写正斜杠在 Windows 上也能被 Node 接受,
144
+ 但和包内既有写法保持一致更省排查成本。
145
+ - **预设是启动时装配的**,改完必须**重启桌面进程**(不是刷新页面)。
146
+ - 改的是 `node_modules` 里的文件:`pnpm update <pkg>` 会把它整个换掉,那行会消失。
147
+ **升级后必须复查**(见 §7 回归清单)。备份文件名建议带时间戳:
148
+ `agent.cordis.yml.bak-before-extra-skills-<ISO>`。
149
+
150
+ > 为什么 native 加载不会被启动流程覆盖:`bin/studymate.mjs` 的 `installPayload`
151
+ > 把 `preset/<id>` 拷到临时目录后**在副本上**替换 `__STUDYMATE_SKILLS__`,
152
+ > 包目录本身是只读材料(源码注释明确写了"原生加载也不写包目录")。
153
+ > 但它读的是包目录里的**当前内容**,所以手工改动会带进下次启动 —— 这点是好事,
154
+ > 也是 A2 可行的前提。
155
+
156
+ ## 5. B 层:匹配层(0.1.x 已修过多轮,改动前先读旧文档)
157
+
158
+ 先读 `deny-shadow-覆盖率.md` 与 `技能发现与宿主对齐.md`。这里只列**判据**:
159
+
160
+ - 运行时拒绝集 = `computeRuntimeDeniedSkillIds(effective, {universe, candidates, linkedSkillIds})`(`lib/index.js`);
161
+ - `effective` 只来自 **own**,永不回退工作区实时配置(2026-10-31 定的口径,见 `recomputeDenials` 注释);
162
+ - `own === null` = 从未配置 = 空条件 = 掌握全部技能。
163
+
164
+ 改这块之前先确认:**别把"own 为 null = 全部"这条改掉**。它是"看一眼就被冻结"那类老 bug 的解药。
165
+
166
+ ## 6. 已知缺陷
167
+
168
+ > 0.1.14 修掉的是另一类错位:「界面按标签算命中、模型按 invocation 算下发」,
169
+ > 现在右列标题会同时给出两个数,见 CHANGELOG 0.1.14。
170
+ > 0.1.15 修掉了下面 6.1(issue #1),本条保留作历史缺陷记录;6.2 仍未修。
171
+
172
+ ### 6.1 对话面板假阳性:没有活跃 agent 时退回全局技能表(**0.1.15 已修**)
173
+
174
+ `src/api.js` 的 `contextForConfig` 只把 `{conversationId}` 当 hint 传给
175
+ `createSkillDiscoverer`,而后者在 `agentFor(conversationId)` 返回 null 时**直接退化成
176
+ 不带 `scope` 的宿主快照**(`lib/index.js` 的 `snapshotSkills`):
177
+
178
+ ```js
179
+ const scope = agentFor(hint?.conversationId);
180
+ const snap = await hostRegistry.snapshot({ ...(cwd ? { cwd } : {}), ...(scope ? { scope } : {}) });
181
+ ```
182
+
183
+ 后果:会话没有在跑时打开「对话 → 管理标签」,面板展示的是**全局技能表**
184
+ (例如 128 条,含 14 条标签命中),而该会话真实只有 10 条。用户看到"面板里明明有、
185
+ 模型里明明没有",就是这个错位。
186
+
187
+ 修法(0.1.15 已实施):`agentFor` 查不到 agent 时不再静默去掉 `scope`,而是把这一趟
188
+ 快照标记成**视角不完整**(发现器数组上的 `VIEW_COMPLETE` Symbol + 逐条 `registered: null`),
189
+ 界面把那一批标成「暂不可判定」并给对话入口加一句「该会话未运行,以下为全局视角」,
190
+ 「模型可见 N」也不再把它们算进去。判据与取舍见 CHANGELOG 0.1.15。
191
+
192
+ ### 6.2 `/workspaces/skills` 与 `/conversations/skills` 不存在
193
+
194
+ 客户端 `manageScope()` 会拼出 `skillsPrefix = "/conversations/skills"`,
195
+ 但服务端只有 `/conversations/skills/{add,remove,disable,enable}`。
196
+ 拿到裸前缀会 404(`{"ok":false,"error":"not found: /conversations/skills"}`)。
197
+ 这是良性的(客户端不请求裸前缀),但排查时容易被误导 —— 用它做健康检查会误判成插件没起来。
198
+
199
+ ## 7. 升级 / 重启后的回归清单
200
+
201
+ `dsh-skill-tag` 或任何"提供技能根"的插件升级之后,按这个顺序跑一遍(5 分钟):
202
+
203
+ 1. `GET /skill-tag/api/skills` 条数与升级前对比,突变超过 ±10 就该查。
204
+ 2. 目标会话的 `GET /conversations/config?conversationId=...` —
205
+ `expression` 是否还在、`expressionIssue.ok` 是否为 true(`false` = 引用了已删除/改名的标签)。
206
+ 3. 自建额外技能根是否还在:`Test-Path ~/.dsh/studymate-extra-skills`,
207
+ 以及目标预设的 `customSkillDirs` 里那行是否被 update 冲掉。
208
+ 4. 拉一次目标会话 transcript,确认 `<available_skills>` 里有该有的名字(§3.1 脚本)。
209
+ 5. 重启桌面进程后重复 1-4(预设装配发生在启动时,不重启看不到变化)。
210
+
211
+ ## 8. 一句话总结
212
+
213
+ > 标签只决定"匹配",**不决定"发现"**。
214
+ > 打上标签仍然看不见,先问:这个会话的技能根里,到底有没有这个技能。
package/lib/client.js CHANGED
@@ -1149,14 +1149,18 @@ window.__ModuleLoader__.load({
1149
1149
 
1150
1150
  /**
1151
1151
  * 判断一份菜单是不是宿主的工作区行菜单。
1152
- * 宿主源码里它就是 [{id:"rename"},{id:"delete",danger:true}] 两项;
1153
- * 会话行菜单项更多,且不含"删除工作区"。
1152
+ * 宿主源码里原本是 [{id:"rename"},{id:"delete",danger:true}] 两项,但项数会变:
1153
+ * 0.2.x 起中间插了「在文件夹显示」,于是 `labels.length !== 2` 把这份菜单整个漏掉,
1154
+ * 「管理标签」入口跟着消失(用户报告的 bug)。
1155
+ * 所以改成语义匹配,不绑项数:首项是「重命名」,且后面存在一项含「删除」。
1156
+ * 会话行菜单首项是「置顶」、且只有「归档」没有「删除」,仍会被排除。
1154
1157
  * @param {string[]} labels 各 menuitem 的可见文本
1155
1158
  */
1156
1159
  function isWorkspaceMenu(labels) {
1157
- if (!Array.isArray(labels) || labels.length !== 2) return false;
1158
- const [first, second] = labels.map((s) => String(s ?? "").trim());
1159
- return /^(rename|重命名)$/i.test(first) && /(delete|删除)/i.test(second);
1160
+ if (!Array.isArray(labels) || labels.length < 2) return false;
1161
+ const first = String(labels[0] ?? "").trim();
1162
+ if (!/^(rename|重命名)$/i.test(first)) return false;
1163
+ return labels.slice(1).some((s) => /(delete|删除)/i.test(String(s ?? "")));
1160
1164
  }
1161
1165
 
1162
1166
  /** 「管理标签」菜单项按钮(内联样式模拟宿主菜单项,不依赖 CSS Module 类名) */
@@ -1504,7 +1508,7 @@ window.__ModuleLoader__.load({
1504
1508
  const tags = base?.tags ?? [];
1505
1509
  const links = base?.skillLinks ?? [];
1506
1510
  const templates = base?.templates ?? [];
1507
- const view = ws?.view ?? { hasFilter: false, visible: [], manual: [], disabled: [] };
1511
+ const view = ws?.view ?? { hasFilter: false, visible: [], manual: [], disabled: [], modelVisible: [], userOnly: [], unregistered: [], undeterminable: [] };
1508
1512
  const expr = ws?.expression ?? "";
1509
1513
  // 空表达式 = 无条件(不能说成 ∅,否则与"掌握全部技能"矛盾)
1510
1514
  const parsed = !view.hasFilter ? "无条件(掌握全部技能)" : configDisplay({ tags }, ws?.config);
@@ -1512,12 +1516,28 @@ window.__ModuleLoader__.load({
1512
1516
  const visibleSet = new Set(view.visible ?? []);
1513
1517
  const disabledSet = new Set(ws?.disabledSkills ?? []);
1514
1518
 
1519
+ // ── 两套口径(2026-11-02 用户报的 64 vs 48)─────────────────────────
1520
+ // visible = 标签选出来的(本插件自己的语义)
1521
+ // modelVisible = visible 里模型真能看到的 = 去掉「只能 /name 手动调用」
1522
+ // 与「宿主根本没注册」两类。右列标题只报这一份,
1523
+ // 因为用户问的永远是"模型到底拿到几条"。
1524
+ const modelVisible = view.modelVisible ?? view.visible ?? [];
1525
+ const userOnlyMap = new Map((view.userOnly ?? []).map((r) => [r.name, r.reason ?? "disable-model-invocation"]));
1526
+ const unregisteredSet = new Set(view.unregistered ?? []);
1527
+ // 2026-11-03(#1):宿主这一趟没拿到足够视角时,注册状态是**未知**。
1528
+ // 它既不是"已注册"也不是"没注册",界面上不能共用「未注册」那套断言措辞。
1529
+ const undeterminableSet = new Set(view.undeterminable ?? []);
1530
+
1515
1531
  /** 可添加的技能:排除已有(可见)与被禁用的 */
1516
1532
  const addable = (ws?.universe ?? []).filter((n) => !visibleSet.has(n) && !disabledSet.has(n));
1517
1533
 
1518
1534
  // 右列搜索:仅过滤显示,不影响「技能 (总)」这个语义数字
1519
1535
  const skillQuery = skillSearch.trim().toLowerCase();
1520
1536
  const shownSkills = (view.visible ?? []).filter((n) => !skillQuery || String(n).toLowerCase().includes(skillQuery));
1537
+ const modelVisibleCount = modelVisible.length;
1538
+ const userOnlyCount = userOnlyMap.size;
1539
+ const unregisteredCount = unregisteredSet.size;
1540
+ const undeterminableCount = undeterminableSet.size;
1521
1541
 
1522
1542
  // 技能名 → SKILL.md 路径(服务端把扫描结果的 path 一并带回来了)
1523
1543
  const catalog = ws?.catalog ?? [];
@@ -1587,8 +1607,25 @@ window.__ModuleLoader__.load({
1587
1607
  react.createElement("div", { key: "right", style: colStyle }, [
1588
1608
  react.createElement("div", { key: "h", style: { fontWeight: 600, marginBottom: "8px" } },
1589
1609
  skillQuery
1590
- ? `技能 (${shownSkills.length}/${(view.visible ?? []).length})`
1591
- : `技能 (${(view.visible ?? []).length})`),
1610
+ ? `技能 (${shownSkills.length}/${(view.visible ?? []).length}) · 模型可见 ${modelVisibleCount}`
1611
+ : `技能 (${(view.visible ?? []).length}) · 模型可见 ${modelVisibleCount}`),
1612
+ // 两套口径不一致时必须讲清差在哪,否则用户只会看到"数字对不上"。
1613
+ (userOnlyCount > 0 || unregisteredCount > 0 || undeterminableCount > 0) && react.createElement("div", {
1614
+ key: "gap",
1615
+ style: { color: "#e67e22", fontSize: "11px", marginBottom: "6px", lineHeight: "1.5" },
1616
+ }, [
1617
+ userOnlyCount > 0 && react.createElement("div", { key: "u" },
1618
+ `其中 ${userOnlyCount} 个只能 /名字 手动调用(disable-model-invocation),模型不会自己看到`),
1619
+ unregisteredCount > 0 && react.createElement("div", { key: "r" },
1620
+ `其中 ${unregisteredCount} 个本机有、但宿主没注册,模型侧根本不存在`),
1621
+ // 2026-11-03(#1):三种状态各说各话,绝不共用「宿主没注册」这句断言。
1622
+ undeterminableCount > 0 && react.createElement("div", {
1623
+ key: "ud", style: { color: "#888" },
1624
+ title: "这一趟没拿到宿主技能的完整快照(会话未运行 / 拿不到 agent 视角),注册状态未知",
1625
+ }, scope.isConv
1626
+ ? `该会话未运行,以下为全局视角:${undeterminableCount} 个本次未取到宿主快照,暂不可判定(不代表没注册)`
1627
+ : `其中 ${undeterminableCount} 个本次未取到宿主快照,暂不可判定(不代表没注册)`),
1628
+ ]),
1592
1629
  // 搜索框:放列表顶部,只过滤显示(不改变实际下发的技能集合)
1593
1630
  react.createElement("div", { key: "search", style: { display: "flex", gap: "6px", alignItems: "center", marginBottom: "6px" } }, [
1594
1631
  react.createElement("input", {
@@ -1605,9 +1642,31 @@ window.__ModuleLoader__.load({
1605
1642
  "没有技能。可在下方「+ 精细化添加技能」手动加入,或清空标签条件回到全部技能。"),
1606
1643
  (view.visible ?? []).length > 0 && shownSkills.length === 0 && react.createElement("div", { key: "nf", style: { color: "#888", fontSize: "12px" } },
1607
1644
  `没有匹配「${skillSearch.trim()}」的技能。`),
1608
- ...shownSkills.map((name) => react.createElement("div", { key: name, style: { ...listRow, flexWrap: "wrap" } }, [
1609
- react.createElement("span", { key: "n", style: { flex: 1 } }, name),
1645
+ ...shownSkills.map((name) => {
1646
+ const onlyManual = userOnlyMap.has(name);
1647
+ const notRegistered = unregisteredSet.has(name);
1648
+ // 未知 ≠ 未注册:不知道该不该置灰,就不置灰(2026-11-03 #1)。
1649
+ const undetermined = undeterminableSet.has(name);
1650
+ return react.createElement("div", { key: name, style: { ...listRow, flexWrap: "wrap" } }, [
1651
+ react.createElement("span", { key: "n", style: { flex: 1, color: (onlyManual || notRegistered) ? "#888" : "inherit" } }, name),
1652
+ // 逐行标注它为什么不在「模型可见」那一份里,避免用户以为插件少发了。
1653
+ notRegistered && react.createElement("span", {
1654
+ key: "nr",
1655
+ style: { color: "#e67e22", fontSize: "10px", border: "1px solid rgba(230,126,34,0.5)", borderRadius: "4px", padding: "0 4px" },
1656
+ title: "宿主 skills 注册表里没有这个名字:本机文件存在,但模型侧看不到(插件范围外)",
1657
+ }, "未注册"),
1658
+ !notRegistered && undetermined && react.createElement("span", {
1659
+ key: "ud",
1660
+ style: { color: "#888", fontSize: "10px", border: "1px dashed rgba(136,136,136,0.6)", borderRadius: "4px", padding: "0 4px" },
1661
+ title: "这一趟没拿到宿主技能的完整快照(会话未运行 / 拿不到 agent 视角),它到底注没注册还不知道",
1662
+ }, "暂不可判定"),
1663
+ !notRegistered && !undetermined && onlyManual && react.createElement("span", {
1664
+ key: "uo",
1665
+ style: { color: "#9b59b6", fontSize: "10px", border: "1px solid rgba(155,89,182,0.5)", borderRadius: "4px", padding: "0 4px" },
1666
+ title: "SKILL.md 写了 disable-model-invocation: true:模型不会自动看到,只能由你用 /名字 手动调用",
1667
+ }, "仅手动"),
1610
1668
  manualSet.has(name) && react.createElement("span", { key: "m", style: { color: "#3498db", fontSize: "11px" } }, "手动"),
1669
+ // 说明这一行到底进没进"下发给模型"那一份。
1611
1670
  react.createElement(RevealButton, { key: "rev", skillName: name, path: pathOf(name), label: "📁" }),
1612
1671
  react.createElement("button", {
1613
1672
  key: "t", style: { ...btn, padding: "1px 7px", fontSize: "11px" },
@@ -1619,7 +1678,8 @@ window.__ModuleLoader__.load({
1619
1678
  }, "禁用"),
1620
1679
  tagOfSkill === name && react.createElement("div", { key: "tags", style: { flexBasis: "100%", color: "#888", fontSize: "11px", padding: "2px 0 2px 8px" } },
1621
1680
  (tagsOf(name).length ? "tag: " + tagsOf(name).join(", ") : "该技能没有任何 tag")),
1622
- ])),
1681
+ ]);
1682
+ }),
1623
1683
  ]),
1624
1684
  react.createElement("div", { key: "f", style: { display: "flex", gap: "8px", flexWrap: "wrap", paddingTop: "8px", borderTop: "1px solid var(--theme-border,#222)" } }, [
1625
1685
  react.createElement("button", { key: "add", style: btn, disabled: busy, onClick: () => setPickOpen(true) }, "+ 精细化添加技能"),
package/lib/index.js CHANGED
@@ -18,7 +18,10 @@ import { skillTagDomain } from "../src/storage/schema.js";
18
18
  import { createDshRepository, serializeConversation, deserializeConversation } from "../src/storage/repository.js";
19
19
  import { createServices } from "../src/service.js";
20
20
  import { groupIdOf } from "../src/domain/model.js";
21
- import { workspaceDeniedSkills, workspaceSkillView, normalizeWorkspacePath, activeTagsOf } from "../src/domain/workspace.js";
21
+ import { workspaceDeniedSkills, workspaceSkillView, normalizeWorkspacePath, activeTagsOf, attachView, viewIsComplete, mergeRegistered } from "../src/domain/workspace.js";
22
+
23
+ // 供调用方/测试读「这一趟宿主视角可不可信」。实现落在 src/domain/workspace.js(纯函数可单测)。
24
+ export { attachView, viewIsComplete, mergeRegistered } from "../src/domain/workspace.js";
22
25
  import { candidatesFor } from "../src/domain/match.js";
23
26
  import { mountApi } from "../src/api.js";
24
27
  import { scanSkillRoots } from "../src/skill-scan.js";
@@ -451,7 +454,15 @@ export function createSkillDiscoverer({ hostRegistry, rootsScan, agents = null,
451
454
  ...(cwd ? { cwd } : {}),
452
455
  ...(scope ? { scope } : {}),
453
456
  });
454
- return Array.isArray(snap?.skills) ? snap.skills : [];
457
+ const skills = Array.isArray(snap?.skills) ? snap.skills : [];
458
+ // 视角是否可信(2026-11-03 #1):宿主只有拿到 scope 才会把 agent 层
459
+ // 并进来;没 scope 时它只给 global 层,而覆盖用户级技能根的 provider
460
+ // 如果注册在 agent 层,那些名字**整段不会出现**。另外宿主自己也会用
461
+ // complete:false 告诉我们这趟没收集全。两种情况下"没返回"≠"没注册"。
462
+ const scoped = Boolean(scope);
463
+ const complete = snap?.complete !== false;
464
+ attachView(skills, scoped && complete);
465
+ return skills;
455
466
  } catch {
456
467
  return null; // registry 未就绪 → 调用方退回自扫描
457
468
  } finally { nesting.depth -= 1; }
@@ -465,32 +476,54 @@ export function createSkillDiscoverer({ hostRegistry, rootsScan, agents = null,
465
476
  }
466
477
  return async function discoverSkills(cwd, hint) {
467
478
  const out = new Map();
468
- const put = (s) => {
479
+ /**
480
+ * registered 标记是本函数的核心诚实性(2026-11-02 缺陷 2):
481
+ * true = 这条来自宿主 skills registry,模型侧真有可能拿到它;
482
+ * false = 只有自扫描看得到,宿主压根没注册 —— 模型永远看不到。
483
+ * 前端据此把后者单独列成「本机有、宿主未注册」,不再混进「下发给模型 N」。
484
+ */
485
+ const put = (s, registered) => {
469
486
  const name = typeof s === "string" ? s : s?.name;
470
487
  if (!name) return;
488
+ const prev = out.get(name);
471
489
  out.set(name, {
472
490
  name,
473
491
  description: typeof s === "string" ? "" : (s?.description ?? ""),
474
492
  path: typeof s === "string" ? null : (s?.path ?? null),
475
493
  source: typeof s === "string" ? null : (s?.source ?? null),
494
+ // 宿主给的 invocation 策略(disable-model-invocation / user-invocable);
495
+ // 自扫描时由 skill-scan 从 frontmatter 解析,缺省视为可调用。
496
+ invocation: (typeof s === "string" ? null : s?.invocation) ?? prev?.invocation ?? null,
497
+ // 三态合并,优先级 true > null > false:
498
+ // 宿主这趟返回了 → true(唯一确凿的"模型侧确实有")
499
+ // 宿主这趟没返回、但视角不完整 → null(不知道,别说成没有)
500
+ // 宿主视角完整且没返回 → false(真·未注册)
501
+ registered: mergeRegistered(registered, prev?.registered),
476
502
  });
477
503
  };
478
504
  // 1) 宿主注册表(权威,与模型侧同源)
479
505
  const key = `${cwd ?? ""}::${hint?.conversationId ?? ""}`;
480
506
  const pending = inFlight.get(key) ?? snapshotSkills(cwd, hint, key);
481
507
  const hostSkills = await pending;
482
- if (Array.isArray(hostSkills)) for (const s of hostSkills) put(s);
508
+ // 这一趟宿主视角可信吗?
509
+ // 宿主压根没装 skills 服务 → 谁都没注册,"false" 就是字面事实,保持旧口径;
510
+ // 问了但抛错 / 守卫被占(null)→ 不知道,不可信;
511
+ // 拿到数组 → 听宿主自己的 complete 标记。
512
+ const complete = !hostRegistry
513
+ ? true
514
+ : (Array.isArray(hostSkills) && viewIsComplete(hostSkills));
515
+ if (Array.isArray(hostSkills)) for (const s of hostSkills) put(s, true);
483
516
  // 2) 自扫描补缺(不同名才补)
484
517
  if (rootsScan) {
485
518
  try {
486
519
  const scanned = rootsScan({ cwd }) ?? [];
487
520
  for (const s of scanned) {
488
521
  const name = typeof s === "string" ? s : s?.name;
489
- if (name && !out.has(name)) put(s);
522
+ if (name && !out.has(name)) put(s, complete ? false : null);
490
523
  }
491
524
  } catch { /* 扫不动就算了 */ }
492
525
  }
493
- return [...out.values()];
526
+ return attachView([...out.values()], complete);
494
527
  };
495
528
  }
496
529
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-tag",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "description": "给 DSH 的技能(skill)打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/api.js CHANGED
@@ -6,9 +6,29 @@
6
6
  // should not 404 on POST-only clients either way).
7
7
 
8
8
  import { candidatesFor } from "./domain/match.js";
9
- import { activeTagsOf } from "./domain/workspace.js";
9
+ import { activeTagsOf, viewIsComplete } from "./domain/workspace.js";
10
10
  import { revealInExplorer } from "./reveal.js";
11
11
 
12
+ /**
13
+ * 把 registered 归一成三态(2026-11-03 #1)。
14
+ *
15
+ * true = 宿主确认注册了
16
+ * false = 宿主确认没注册(只有自扫描看得见)
17
+ * null = 这趟没问到,状态未知
18
+ *
19
+ * 缺省(undefined)按 **true** 处理:没带这个字段的调用方(测试面 / 老版本
20
+ * discoverer / 宿主快照里的裸名字)说明它不掌握注册信息,不能因此把整个右列
21
+ * 误判成「宿主未注册」。这条缺省口径以前散在调用点的注释里,收进函数更安全。
22
+ *
23
+ * @param {boolean|null|undefined} value
24
+ * @returns {boolean|null}
25
+ */
26
+ function coerceRegistered(value) {
27
+ if (value === false) return false;
28
+ if (value === null) return null;
29
+ return true;
30
+ }
31
+
12
32
  /** 对外暴露的工作区形状(前端双列界面直接消费) */
13
33
  function wsShape(ws) {
14
34
  return {
@@ -92,8 +112,24 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
92
112
  listed = [];
93
113
  }
94
114
  }
115
+ // 2026-11-03(#1):这一趟宿主视角可不可信,决定 registered 能不能被当成
116
+ // "未注册"来读。发现器用 Symbol 挂在数组上(不进 JSON),listSkills 就是它。
117
+ const registryUnavailable = !viewIsComplete(listed);
118
+ // 2026-11-02(缺陷 1+2):catalog 必须带两样以前被丢掉的元数据,
119
+ // 否则前端无法区分「标签可见」与「模型可见」——
120
+ // invocation: 宿主/扫描解析出的 {modelInvocable, userInvocable}
121
+ // registered: 是否来自宿主 skills registry(自扫描兜底的 = false)
95
122
  const catalog = listed
96
- .map((s) => (typeof s === "string" ? { name: s, path: null, source: null } : { name: s?.name, path: s?.path ?? null, source: s?.source ?? null }))
123
+ .map((s) => (typeof s === "string"
124
+ ? { name: s, path: null, source: null, invocation: null, registered: false }
125
+ : {
126
+ name: s?.name,
127
+ path: s?.path ?? null,
128
+ source: s?.source ?? null,
129
+ invocation: s?.invocation ?? null,
130
+ // 三态透传,归一规则见 coerceRegistered。
131
+ registered: coerceRegistered(s?.registered),
132
+ }))
97
133
  .filter((s) => Boolean(s.name));
98
134
  const universe = catalog.map((s) => s.name);
99
135
  const links = [...services._caches.skillLinks.values()];
@@ -102,8 +138,9 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
102
138
  config: config ?? { orTags: [], andGroups: [] },
103
139
  active: activeTagsOf(config),
104
140
  });
105
- // catalog 让前端能把「打开技能文件夹」按钮直接接到 path(不必再扫一遍)
106
- return { universe, candidates, catalog };
141
+ // catalog 让前端能把「打开技能文件夹」按钮直接接到 path(不必再扫一遍);
142
+ // registryUnavailable 让视图层知道这趟的 registered 能不能当"没有"读。
143
+ return { universe, candidates, catalog, registryUnavailable };
107
144
  }
108
145
  async function skillContext(ws) {
109
146
  return contextForConfig(ws?.config, workspacePathOf(ws), {
@@ -267,16 +304,21 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
267
304
  // 为什么不在 GET 里顺手删:关联是用户手工打的标签,插件的临时缺席不等于
268
305
  // 用户放弃它。默认只标记,删除必须显式触发。
269
306
  if (path === "/skills/purge-stale" && method === "POST") {
270
- const live = new Set(
271
- ((listSkills ? await listSkills(q("cwd") ?? undefined, {
272
- conversationId: q("conversationId") ?? null,
273
- workspaceId: q("workspaceId") ?? null,
274
- }) : []) ?? []).map((s) => (typeof s === "string" ? s : s?.name)),
275
- );
307
+ const discovered = (listSkills ? await listSkills(q("cwd") ?? undefined, {
308
+ conversationId: q("conversationId") ?? null,
309
+ workspaceId: q("workspaceId") ?? null,
310
+ }) : []) ?? [];
311
+ const live = new Set(discovered.map((s) => (typeof s === "string" ? s : s?.name)));
276
312
  // 发现结果为空 = 宿主没就绪,此时"全是失效"是误判,绝不动数据。
277
313
  if (live.size === 0) {
278
314
  return json(res, 409, { ok: false, code: "NO_SKILLS", error: "当前发现到 0 个技能,拒绝清理(可能是宿主未就绪)" });
279
315
  }
316
+ // 2026-11-03(#1 的第二个出口):发现结果**不算全**时 live 偏小、stale 偏大,
317
+ // 删下去会误伤还活着的关联。同一根因:没拿到 agent scope 时宿主只给 global 层。
318
+ // 这里不下手,让用户换到能解析 scope 的入口再点。
319
+ if (!viewIsComplete(discovered)) {
320
+ return json(res, 409, { ok: false, code: "INCOMPLETE_VIEW", error: "这趟没有拿到完整的技能视角(可能该会话未运行),拒绝清理,以免误删仍然有效的关联" });
321
+ }
280
322
  const wanted = Array.isArray(body?.skillIds) ? new Set(body.skillIds.map(String)) : null;
281
323
  const removed = [];
282
324
  for (const link of [...services._caches.skillLinks.values()]) {
@@ -83,6 +83,118 @@ export function workspaceSkillView({ config, manualSkills, disabledSkills, candi
83
83
  return { hasFilter: filter, matched, manual: manualVisible, disabled, visible };
84
84
  }
85
85
 
86
+ /**
87
+ * 「这一趟宿主视角可不可信」的非枚举旁注。
88
+ *
89
+ * 为什么不塞进每个 skill 对象(2026-11-03 #1):发现器返回的是 set 出来的普通数组,
90
+ * 加一个 skills.undeterminable = true 这种裸属性会被 [...out] / JSON 序列化
91
+ * 悄悄丢掉,调用方永远读不到。用 Symbol 挂在数组上,既能随数组一起传下去,
92
+ * 又不会污染 JSON(REST 响应里每条技能的形状必须保持干净)。
93
+ */
94
+ export const VIEW_COMPLETE = Symbol.for("dsh-skill-tag.viewComplete");
95
+
96
+ /**
97
+ * 给技能数组打上「视角是否完整」的记号。
98
+ * @param {object[]} skills
99
+ * @param {boolean} complete true = 宿主确认这趟看得全;false = 没问到 / 只拿到 global 层
100
+ */
101
+ export function attachView(skills, complete) {
102
+ try {
103
+ Object.defineProperty(skills, VIEW_COMPLETE, { value: complete === true, enumerable: false, configurable: true });
104
+ } catch { /* 冻结数组之类打不上记号:退回默认口径(=完整),与没打过记号的数组一致 */ }
105
+ return skills;
106
+ }
107
+
108
+ /**
109
+ * 读 attachView 打的记号。没打过 = true(老调用方 / 测试面的默认口径)。
110
+ *
111
+ * ⚠️ 记号只在**同一个数组实例**上有效:发现器返回的数组被原样交给调用方即可,
112
+ * 一旦被 filter / slice / map 复制过,记号就丢了、退回默认的"完整"。
113
+ * 所以读它之前不要复制数组(`src/api.js` 里 listSkills 的返回值直接用)。
114
+ */
115
+ export function viewIsComplete(skills) {
116
+ return Array.isArray(skills) ? skills[VIEW_COMPLETE] !== false : false;
117
+ }
118
+
119
+ /**
120
+ * 三态合并:true > null > false。
121
+ * 一条技能可能被 put 多次(宿主命中一次、自扫描再补一次),取"最有信息量的那个"。
122
+ * @param {boolean|null|undefined} incoming
123
+ * @param {boolean|null|undefined} prev
124
+ * @returns {boolean|null}
125
+ */
126
+ export function mergeRegistered(incoming, prev) {
127
+ if (incoming === true || prev === true) return true;
128
+ if (incoming === null || prev === null) return null;
129
+ if (incoming === undefined) return prev ?? null;
130
+ return false;
131
+ }
132
+
133
+ /**
134
+ * 把「标签选出的可见集合」再按**宿主的下发口径**切一刀。
135
+ *
136
+ * 背景(2026-11-02 用户报告的 64 vs 48):右列长期只有一层「标签过滤」,
137
+ * 而模型侧拿到的是 `snapshot().skills.filter(isModelInvocable)` —— 两道闸:
138
+ * ​① frontmatter 的 `disable-model-invocation: true`(只能 `/name` 手动调用)
139
+ * ​② 宿主根本不认识的名字(插件自扫描补进来的,宿主 registry 里没有)
140
+ * 于是「右列 N 条」永远大于「模型实际 N 条」。这里把口径拆开,不再让前端
141
+ * 把两者混成一个数字。
142
+ *
143
+ * 注意:这**只影响展示口径**。deny-shadow 的分母仍是 world universe − visible,
144
+ * 不能拿 modelVisible 去算,否则那些只能手动调用的技能会重新变回可见。
145
+ *
146
+ * 第三种状态(2026-11-03,#1):「不可判定」。宿主快照这一趟**没拿到足够视角**
147
+ * 时(会话没有 live agent、agent scope 解析不到、registry 未就绪),宿主没返回的
148
+ * 名字只说明"这次没问到",不等于"宿主没注册"。此时既不能进 unregistered
149
+ * (那是在断言模型侧根本不存在),也不能算模型可见 —— 单列一类,由界面说清楚。
150
+ *
151
+ * @param {string[]} visible 标签口径下的可见技能名
152
+ * @param {Array<{name:string, invocation?:{modelInvocable?:boolean}, registered?:boolean|null}>} [catalog]
153
+ * 技能目录(宿主快照 ∪ 自扫描兜底的合并结果)。registered=null 表示不可判定
154
+ * @param {{registryUnavailable?:boolean}} [options]
155
+ * registryUnavailable=true 表示这一趟宿主快照**不完整**(没拿到 agent scope 等)。
156
+ * 它只让"宿主没返回"变得不可信:宿主这趟明确返回过的条目结论照旧,
157
+ * 其余条目(含自扫描补的 false)一律降级为不可判定。
158
+ * @returns {{modelVisible:string[], userOnly:{name:string,reason:string}[], unregistered:string[], undeterminable:string[]}}
159
+ */
160
+ export function splitModelVisible(visible, catalog = [], options = {}) {
161
+ const byName = new Map();
162
+ for (const s of catalog ?? []) {
163
+ const name = typeof s === "string" ? s : s?.name;
164
+ if (name) byName.set(name, typeof s === "string" ? { name } : s);
165
+ }
166
+ const registryUnavailable = options?.registryUnavailable === true;
167
+ const modelVisible = [];
168
+ const userOnly = [];
169
+ const unregistered = [];
170
+ const undeterminable = [];
171
+ for (const name of normalizeSkillIds(visible)) {
172
+ const entry = byName.get(name);
173
+ // 宿主这趟**明确返回过**这个名字 → 视角不全也不影响这条结论,照常判(2026-11-03 复核)。
174
+ // 视角不全只让"没返回"变得不可信,不能让已知的事实一起变成未知。
175
+ if (entry && entry.registered === true) {
176
+ if (entry?.invocation?.modelInvocable === false) {
177
+ userOnly.push({ name, reason: "disable-model-invocation" });
178
+ continue;
179
+ }
180
+ modelVisible.push(name);
181
+ continue;
182
+ }
183
+ // 自扫描兜底补进来的名字:宿主 registry 里没有 → 模型侧根本不存在。
184
+ // 但这只在"视角完整"时成立;视角不全时它可能是"没问到",降级为未知。
185
+ if (entry && entry.registered === false && !registryUnavailable) { unregistered.push(name); continue; }
186
+ // 显式未知,或视角不全导致"没问到":连"宿主认不认这个名字"都没问清,
187
+ // 谈不上模型可见或仅手动。
188
+ if (registryUnavailable || (entry && entry.registered === null)) { undeterminable.push(name); continue; }
189
+ if (entry?.invocation?.modelInvocable === false) {
190
+ userOnly.push({ name, reason: "disable-model-invocation" });
191
+ continue;
192
+ }
193
+ modelVisible.push(name);
194
+ }
195
+ return { modelVisible, userOnly, unregistered, undeterminable };
196
+ }
197
+
86
198
  /**
87
199
  * 该工作区应被 deny-shadow(真正屏蔽)的技能名集合。
88
200
  * denied = universe − visible,再并上 disabled(禁用的名字即使不在 universe 里也要屏蔽)。
package/src/service.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  detachFromWorkspace,
20
20
  } from "./domain/sync.js";
21
21
  import { candidatesFor } from "./domain/match.js";
22
- import { workspaceSkillView, workspaceDeniedSkills } from "./domain/workspace.js";
22
+ import { workspaceSkillView, workspaceDeniedSkills, splitModelVisible } from "./domain/workspace.js";
23
23
  import {
24
24
  validateExpression, evaluateDnf, configToExpression, renameTagInExpression,
25
25
  removeTagFromExpression,
@@ -565,13 +565,16 @@ export function createServices(repo, opts = {}) {
565
565
  view(workspaceId, ctx = {}) {
566
566
  const ws = workspaces.get(workspaceId);
567
567
  const config = ws?.config ?? emptyTagConfig();
568
- return workspaceSkillView({
568
+ const view = workspaceSkillView({
569
569
  config,
570
570
  manualSkills: ws?.manualSkills ?? [],
571
571
  disabledSkills: ws?.disabledSkills ?? [],
572
572
  candidates: ctx.candidates ?? [],
573
573
  universe: ctx.universe ?? [],
574
574
  });
575
+ // 口径拆分:visible 是「标签选出来的」,modelVisible 是「模型真能看到的」。
576
+ // 两者不等时(disable-model-invocation / 宿主不认识的技能),前端必须分开显示。
577
+ return { ...view, ...splitModelVisible(view.visible, ctx.catalog ?? [], { registryUnavailable: ctx.registryUnavailable === true }) };
575
578
  },
576
579
  /** 该工作区应被屏蔽的技能名集合(供 deny-shadow 使用) */
577
580
  denied(workspaceId, ctx = {}) {
@@ -847,13 +850,14 @@ export function createServices(repo, opts = {}) {
847
850
  /** 对话页右列数据源(与工作区共用同一份领域实现)。 */
848
851
  ownView(conversationId, ctx = {}) {
849
852
  const o = this.own(conversationId) ?? emptyOwnConfig();
850
- return workspaceSkillView({
853
+ const view = workspaceSkillView({
851
854
  config: o.config,
852
855
  manualSkills: o.manualSkills,
853
856
  disabledSkills: o.disabledSkills,
854
857
  candidates: ctx.candidates ?? [],
855
858
  universe: ctx.universe ?? [],
856
859
  });
860
+ return { ...view, ...splitModelVisible(view.visible, ctx.catalog ?? [], { registryUnavailable: ctx.registryUnavailable === true }) };
857
861
  },
858
862
  /** 该对话应被 deny-shadow 的技能名集合。 */
859
863
  ownDenied(conversationId, ctx = {}) {
package/src/skill-scan.js CHANGED
@@ -84,10 +84,38 @@ function pushSkill(out, mdPath, root, disabled, fallbackName) {
84
84
  description: fm?.description ?? "",
85
85
  path: mdPath,
86
86
  source: root,
87
+ invocation: invocationOf(fm),
87
88
  ...(disabled ? { disabled: true } : {}),
88
89
  });
89
90
  }
90
91
 
92
+ /**
93
+ * frontmatter → invocation 策略,与宿主 parseInvocationPolicy
94
+ * (dsh-skill-filesystem/lib/index.js:852-862) 同一口径:
95
+ * disable-model-invocation: true → modelInvocable = false
96
+ * user-invocable: false → userInvocable = false
97
+ * 模型看不到、只能用 /name 手动调用的技能,必须在扫描结果里就带上这个标记,
98
+ * 否则插件会把它们算进「下发给模型的技能」(2026-11-02 的 64 vs 48)。
99
+ *
100
+ * @param {Record<string,string>|null} fm
101
+ * @returns {{modelInvocable:boolean, userInvocable:boolean}}
102
+ */
103
+ export function invocationOf(fm) {
104
+ const disableModel = frontmatterFlag(fm, "disable-model-invocation");
105
+ const userInvocable = frontmatterFlag(fm, "user-invocable");
106
+ return { modelInvocable: disableModel !== true, userInvocable: userInvocable !== false };
107
+ }
108
+
109
+ /** frontmatter 布尔(宿主认 true/1/yes/on 与 false/0/no/off 两种写法) */
110
+ function frontmatterFlag(fm, key) {
111
+ const raw = fm?.[key];
112
+ if (raw === undefined || raw === null || raw === "") return undefined;
113
+ const v = String(raw).trim().toLowerCase();
114
+ if (v === "true" || v === "yes" || v === "on" || v === "1") return true;
115
+ if (v === "false" || v === "no" || v === "off" || v === "0") return false;
116
+ return undefined;
117
+ }
118
+
91
119
  function isFile(p) {
92
120
  try { return fs.statSync(p).isFile(); } catch { return false; }
93
121
  }