@restregular/dsh-skill-center 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-skill-center · 技能中心
1
+ # dsh-skill-center · 技能中心
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@restregular/dsh-skill-center?color=cb3837&logo=npm)](https://www.npmjs.com/package/@restregular/dsh-skill-center)
4
4
  [![license](https://img.shields.io/npm/l/@restregular/dsh-skill-center?color=blue)](./LICENSE)
@@ -35,12 +35,69 @@ Sidebar Main area
35
35
  | Browse | Lists every skill visible in the **current session scope**, including skills embedded in plugins, each with provider, source root and invocation policy |
36
36
  | Search / filter | Search by name or description; filter by provider source |
37
37
  | Read body | Renders the raw `SKILL.md` Markdown inside the panel (no right-hand resource pane needed) |
38
- | Invocation switches | Toggles `disable-model-invocation` / `user-invocable` by rewriting the frontmatter; the host pushes the refreshed catalog to the model immediately |
38
+ | Invocation switches | Flips `disable-model-invocation` / `user-invocable` locally — the switch moves at once, with no round trip — and writes them into the frontmatter when you press **Save** beside *Back to session* (the button reads 未修改 → 保存 → 已保存); the host pushes the refreshed catalog to the model immediately |
39
39
  | Create | Creates `<name>/SKILL.md` under the user Agents or user DSH skills root |
40
40
  | Delete | Moves the skill to `.trash/<timestamp>-<name>` in the same root — recoverable, never a hard delete |
41
41
  | Read-only guard | Skills embedded in plugins (e.g. `office-docx`) are labelled *embedded* and their switches/deletion are disabled |
42
+ | Bundled skill | Ships an embedded **`skill-authoring`** skill (guidance + `references/` + a starter template) that teaches the agent which root a skill belongs in, what the frontmatter contract is, and where a skill's resource files go |
43
+ | Model tool | Registers **`skill_create`**, which writes `<root>/<name>/SKILL.md` plus any resource files in one call — the path that works for skill roots outside the session workspace, where the file-write tools are sandboxed |
42
44
  | Security | Every write is confined to managed skill roots that really exist on disk; requests from non-loopback origins get `403` |
43
45
 
46
+ ## Bundled skill and model tool
47
+
48
+ Beyond the panel, the host half publishes its own guidance for agents and one tool that acts on it.
49
+
50
+ ### `skill-authoring` (embedded skill)
51
+
52
+ The package ships `skills/skill-authoring/` and registers it with `ctx.skills.registerProvider()`,
53
+ so it appears in the session catalog exactly like the built-in skills — the model can load it with
54
+ the `skill` tool, and the panel lists it as *embedded* (read-only, because the file belongs to the
55
+ package, not to a managed root).
56
+
57
+ ```
58
+ skills/skill-authoring/
59
+ SKILL.md routing + the five-step short path
60
+ references/roots.md the root table, ranks, precedence, project-root detection
61
+ references/frontmatter.md every field, the boolean spellings, why a skill silently does not load
62
+ references/resources.md where resource files go and how the model is told to find them
63
+ references/plugin-bundled.md shipping skill content inside a plugin package
64
+ templates/minimal/SKILL.md starter bundle
65
+ ```
66
+
67
+ Because the candidate carries `resourceBase: { kind: 'directory', path }`, the loaded skill arrives
68
+ with `Base directory for this skill: <abs path>` in its `<skill_resources>` block, so the agent can
69
+ read `references/*.md` with ordinary file tools. Rank 600 means any project or user skill named
70
+ `skill-authoring` shadows this one — override it by writing your own.
71
+
72
+ ### `skill_create` (model tool)
73
+
74
+ ```
75
+ skill_create({ name, description, whenToUse?, body?, root?, resources?, modelInvocable?, userInvocable?, overwrite? })
76
+ ```
77
+
78
+ | Argument | Meaning |
79
+ |---|---|
80
+ | `name` | kebab-case skill name; also the bundle directory name |
81
+ | `description` | frontmatter description — the only routing signal the catalog shows |
82
+ | `whenToUse` | optional `whenToUse` frontmatter field |
83
+ | `body` | the Markdown after the frontmatter; defaults to a `# <name>` heading |
84
+ | `root` | `project-dsh` \| `project-agents` \| `user-dsh` \| `user-agents`; defaults to `user-agents` |
85
+ | `resources` | `[{ path, content }]` written inside the same bundle directory (e.g. `references/roots.md`) |
86
+ | `modelInvocable` / `userInvocable` | `false` writes `disable-model-invocation: true` / `user-invocable: false` |
87
+ | `overwrite` | required to replace an existing skill; the default fails instead of clobbering |
88
+
89
+ It exists because of a real obstacle: the file-write tools are sandboxed to the session workspace,
90
+ while `$DSH_HOME/skills` and `$DSH_AGENTS_HOME/skills` are not in it, so an agent asked to "make me
91
+ a skill" could only fail there. The tool writes with the same confinement as the panel — only the
92
+ four managed roots, only inside `<root>/<name>/` — and rejects absolute resource paths, `..`
93
+ segments, a resource that would overwrite `SKILL.md`, and a name that collides with an embedded
94
+ skill. It registers through `ctx.inject(['tools'], …)`, so a composition without `ctx.tools` simply
95
+ loses the tool instead of failing to mount.
96
+
97
+ > [!NOTE]
98
+ > The tool is registered globally, so **every agent on the host sees `skill_create`** (one extra
99
+ > tool schema in the prompt). Removing the plugin removes it; there is no switch.
100
+
44
101
  ## Requirements
45
102
 
46
103
  - **DeepSeek Harness** desktop build that provides the `ctx.skills` registry and the loopback web server.
@@ -67,7 +124,7 @@ Then add the package to the profile's bundle list and restart:
67
124
  // <profile>/package.json
68
125
  {
69
126
  "dsh": { "profile": { "bundles": [/* … */ "@restregular/dsh-skill-center"] } },
70
- "dependencies": { "@restregular/dsh-skill-center": "^0.2.2" }
127
+ "dependencies": { "@restregular/dsh-skill-center": "^0.3.1" }
71
128
  }
72
129
  ```
73
130
 
@@ -80,7 +137,7 @@ because the client registers `sidebar.panellist` with `order: 1`.
80
137
  > (`@restregular/dsh-skill-center`). `@deepseek-ai/dsh-client-modules` locates a row's
81
138
  > `package.json` by that name and compares it strictly (`nearestPackage`). With a bare
82
139
  > `dsh-skill-center` the host half still mounts, but the client half is dropped **silently**:
83
- > no sidebar row, `/plugins/@restregular/dsh-skill-center/client.js` returns `404`, and nothing is
140
+ > no sidebar row, `op=diagnose` reports `client.inGraph: false` with no `client.url`, and nothing is
84
141
  > logged. 0.2.1 fixed exactly this; 0.2.0 shipped the bare name.
85
142
 
86
143
  > [!IMPORTANT]
@@ -112,8 +169,11 @@ Nothing appears in the sidebar after install + restart? Check in this order:
112
169
  2. **Did the client half reach the browser module graph?**
113
170
  `http://127.0.0.1:19387/dsh-skill-center/api?op=diagnose` — `client.inGraph` must be `true`.
114
171
  `false` while `host.mounted` is `true` is the loader-row-name / package-name mismatch above.
115
- 3. **Is the bundle served?** `http://127.0.0.1:19387/plugins/@restregular/dsh-skill-center/client.js`
116
- should return JavaScript, not `404`.
172
+ 3. **Is the bundle served?** The browser is only ever handed a **revisioned** URL
173
+ (`/plugins/@restregular/dsh-skill-center/client.js?rev=<12 hex>`), and the route deliberately rejects a
174
+ mismatched revision, so requesting the bare path answers `404` even on a healthy install. Read
175
+ `client.url` from `op=diagnose` (it now reports the row it matched in `client.entryId`) and fetch that
176
+ URL instead.
117
177
  4. **Re-run the offline composition check** (no host restart needed):
118
178
  `node verify-compose.mjs <profileDir>` — it now fails loudly on a loader row name that cannot be
119
179
  resolved back to the package.
@@ -125,7 +185,10 @@ Nothing appears in the sidebar after install + restart? Check in this order:
125
185
  1. Open a session (or stay on the home screen — see *Known limits*).
126
186
  2. Click **Skill Center** in the sidebar.
127
187
  3. Pick a skill on the left; the right pane shows provider, source, path, size and the two invocation switches.
128
- 4. Toggle a switch to rewrite the frontmatter, press **Read body** for the Markdown, or **Create** / **Delete** to manage files.
188
+ 4. Flip switches as you like: they move immediately and stay staged in the panel. The button to the left of
189
+ *Back to session* tracks the state — **未修改** (greyed, nothing staged) → **保存** (active, changes staged;
190
+ a count appears when several skills are staged) → **已保存** (greyed, written). Editing anything again puts
191
+ it back to 保存. Press **Read body** for the Markdown, or **Create** / **Delete** to manage files.
129
192
 
130
193
  ## HTTP API
131
194
 
@@ -138,7 +201,7 @@ The host half mounts a **loopback-only** route at `/dsh-skill-center/api`:
138
201
  | GET | `list` | `sessionId?` `cwd?` | skill catalog + read metadata |
139
202
  | POST | `read` | `name` | raw `SKILL.md` text |
140
203
  | POST | `setInvocation` | `name` `modelInvocable?` `userInvocable?` | rewrite invocation policy |
141
- | POST | `create` | `name` `description` `body?` `rootId?` | create a skill bundle |
204
+ | POST | `create` | `name` `description` `body?` `rootId?` `whenToUse?` `resources?` `overwrite?` `modelInvocable?` `userInvocable?` | create (or, with `overwrite`, replace) a skill bundle and its resource files; shares one implementation with `skill_create` |
142
205
  | POST | `delete` | `name` | move to `.trash` |
143
206
 
144
207
  Requests from anything other than `127.0.0.1` / `::1` are rejected with `403`.
@@ -174,7 +237,17 @@ Two halves, no shared imports:
174
237
  `<projectRoot>/.dsh/skills`, `<projectRoot>/.agents/skills`, `$DSH_HOME/skills`, `$DSH_AGENTS_HOME/skills`.
175
238
  - Frontmatter edits are string-level: only the invocation keys are touched, everything else is preserved
176
239
  byte for byte, including line-ending style.
240
+ - For skills inside a managed root, `list` takes the invocation policy **from the file on disk** instead of
241
+ the registry snapshot. The skill catalog is cached and the filesystem watcher invalidates it a moment
242
+ after a write, so a panel that trusted the snapshot would show its own stale value right after a save —
243
+ which is exactly what made a flipped switch snap back until a manual refresh.
177
244
  - Delete is a `rename` into `.trash`.
245
+ - Also registers an embedded skill provider (`ctx.skills.registerProvider`) for `skills/skill-authoring/`
246
+ and the `skill_create` tool (`ctx.inject(['tools'], …)` + `ctx.tools.register`). The tool schema is a
247
+ hand-written JSON Schema using only the subset `@deepseek-ai/dsh-tools` accepts, so the package keeps
248
+ zero runtime dependencies and needs no host-side module resolution.
249
+ - The three parts degrade independently: a missing `tools` or `webServer` service, or a bundled-skill
250
+ asset that fails to parse, only logs a warning and leaves the rest mounted.
178
251
 
179
252
  **Browser half** — [`client/client.js`](client/client.js)
180
253
 
@@ -195,6 +268,11 @@ Styles use host theme tokens only (`--dsw-alias-*`, `--dsw-radius-*`, `--dsw-foc
195
268
  `<style>` tag carries `data-plugin="dsh-skill-center"` so the host's ownerless-style sweep does not
196
269
  reclaim it.
197
270
 
271
+ Switch edits are staged in component state: the switch reads a local override first and the catalog
272
+ second, and the Save button's three states are derived from the staged set (`pendingEdits`) plus a
273
+ "saved once" flag. After a save the overrides are kept until a fresh catalog actually agrees with them
274
+ (`load()` drops the ones that match), so a lagging host snapshot can never bounce the UI back.
275
+
198
276
  ## Development
199
277
 
200
278
  ```bash
@@ -216,7 +294,8 @@ New-Item -ItemType Junction -Path "$env:USERPROFILE\.dsh\profiles\desktop\node_m
216
294
  -Target "D:\path\to\dsh-skill-center"
217
295
  ```
218
296
 
219
- Self-checks (both run against the local host route with a mini React shim):
297
+ Self-checks (the first two run against the local host route with a mini React shim; the third is
298
+ fully offline):
220
299
 
221
300
  ```powershell
222
301
  $env:ELECTRON_RUN_AS_NODE = 1
@@ -224,13 +303,42 @@ $exe = "C:\path\to\DeepSeek Harness.exe"
224
303
  $here = "D:\path\to\dsh-skill-center"
225
304
  $env:DSH_SMOKE_SESSION = "<session-id>"; & $exe "$here\client-smoke.mjs" # client half + real loopback route
226
305
  & $exe "$here\verify-compose.mjs" # client half accepted by the module graph
306
+ node "$here\verify-skill.mjs" # bundled skill + skill_create, offline
227
307
  ```
228
308
 
309
+ `verify-compose.mjs` must run through the Electron binary: it reads the shipped `@deepseek-ai`
310
+ packages out of `app.asar`, which plain `node` cannot open. `verify-skill.mjs` is the opposite — it
311
+ imports `lib/index.js` with a stub context and needs no host at all. It checks the bundled skill's
312
+ contract (directory name vs frontmatter, non-empty description, `resourceBase` directory, every
313
+ `references/`-style path cited in a table or link actually existing), the tool's JSON Schema against
314
+ the subset `@deepseek-ai/dsh-tools` accepts, and then **really runs** `skill_create` inside a temp
315
+ dir: create, resource placement, overwrite, duplicate names, illegal names, path traversal, flat
316
+ files, explicit project roots, and the three degraded mount paths. It also asserts that a managed-root
317
+ skill's invocation policy comes from the file on disk even when the registry snapshot disagrees.
318
+
319
+ `client-smoke.mjs` renders the real panel against the live loopback route through a mini React shim,
320
+ and then drives the staged-save flow: the switch flips with no write request, Save issues exactly one
321
+ `setInvocation` carrying both flags, the button steps 未修改 → 保存 → 已保存 (and back to 保存 on the
322
+ next edit), and a lagging catalog snapshot cannot bounce the switch back. It intercepts POSTs, so the
323
+ run never rewrites a real `SKILL.md`.
324
+
229
325
  > Renaming or relocating the package: the client module table caches package metadata by
230
326
  > `(loader baseUrl, loader line name)` — no file timestamps — so a running host keeps the old path.
231
327
  > The browser side uses the in-memory snapshot taken at activation and keeps working after a reload;
232
328
  > the new path is only resolved after a host restart.
233
329
 
330
+ ### Releasing
331
+
332
+ Bump `version`, commit, push, then `npm publish` from the repo root with a token that has publish
333
+ rights for the scope. The registry answers a successful publish with `202 {"success":true}` and then
334
+ holds the version in a **validating** state: until validation passes, both
335
+ `/@restregular/dsh-skill-center/<version>` and the tarball URL answer `404`, and `dist-tags` still
336
+ points at the previous release. That is the normal window, not a failure. Re-probe the version URL
337
+ with a cache-busting query (`?t=<ticks>`) rather than publishing again: a second attempt for the same
338
+ version fails with `E409 Cannot publish over previously staged version <version>` (and
339
+ `npm stage publish` with `E409 Cannot stage previously published version <version>`), while
340
+ `npm stage list` may show nothing at all.
341
+
234
342
  ## Known limits
235
343
 
236
344
  - While the panel occupies the main area, the right-hand `dsh-resource://` preview is unavailable
@@ -241,6 +349,11 @@ $env:DSH_SMOKE_SESSION = "<session-id>"; & $exe "$here\client-smoke.mjs" # cli
241
349
  embedded skills plus that cwd's project skills; the footer shows the effective cwd and scope tier.
242
350
  - Catalog replacement is all-or-nothing: one switch change makes the host resend the whole skill
243
351
  catalog message (a Harness design choice, not this plugin's).
352
+ - `skill_create` is registered host-wide, so every agent sees one more tool schema; it has no
353
+ per-agent switch, and only uninstalling the plugin removes it.
354
+ - The bundled `skill-authoring` skill is embedded and therefore read-only in the panel. Because it
355
+ sits at rank 600, a project or user skill with the same name replaces it entirely — that is the
356
+ intended way to customize it.
244
357
 
245
358
  ## License
246
359
 
@@ -264,12 +377,61 @@ $env:DSH_SMOKE_SESSION = "<session-id>"; & $exe "$here\client-smoke.mjs" # cli
264
377
  | 浏览 | 列出**当前会话作用域**下模型/用户能看到的所有技能(含插件内嵌技能),带提供方、来源根、调用策略 |
265
378
  | 搜索 / 筛选 | 按名称或描述搜索;按来源(provider source)筛选 |
266
379
  | 查看正文 | 面板内直接读 `SKILL.md` 原文(Markdown 原文,不经过右栏) |
267
- | 调用开关 | 切换 `disable-model-invocation` / `user-invocable`,直接改写 frontmatter;监听器会立刻把新目录推给模型 |
380
+ | 调用开关 | 开关先改本地状态(**点了立刻切换**,不发请求),再按「返回会话」左边的 **保存** 按钮写回 frontmatter(按钮三态:未修改 → 保存 → 已保存,多个技能待保存时显示「保存 (n)」);写盘后监听器立刻把新目录推给模型 |
268
381
  | 新建 | 在「用户 Agents 技能」或「用户 DSH 技能」根下创建 `<name>/SKILL.md` |
269
382
  | 删除 | 移动到同根目录的 `.trash/<时间戳>-<名字>`(可手工恢复,不硬删) |
270
383
  | 只读保护 | 插件内嵌技能(如 `office-docx`、`diagnose-windows-sandbox-acl`)标注「内嵌」,开关与删除按钮禁用 |
384
+ | 随包技能 | 附带一个内嵌的 **`skill-authoring`** 指导技能(正文 + `references/` + 起步模板):技能该放哪个根、frontmatter 契约是什么、资源文件放哪、怎么把技能打进插件包 |
385
+ | 模型工具 | 注册 **`skill_create`**:一次调用写出 `<root>/<name>/SKILL.md` 与全部资源文件 —— 技能根在工作区之外时(文件写入工具被沙箱挡住)这是唯一走得通的路径 |
271
386
  | 安全 | 写操作只落在**真实存在于磁盘**的受管技能根内;非回环来源一律 `403` |
272
387
 
388
+ ### 随包技能与模型工具
389
+
390
+ 除了面板,宿主半边还会给 Agent 发一份指导技能和一个真正能动手的工具。
391
+
392
+ **`skill-authoring`(内嵌技能)**:包内 `skills/skill-authoring/` 通过
393
+ `ctx.skills.registerProvider()` 注册,和第一方内嵌技能一样出现在会话技能目录里 ——
394
+ 模型可以用 `skill` 工具加载它,面板把它标成「内嵌」(只读,因为它属于包,不属于受管根)。
395
+
396
+ ```
397
+ skills/skill-authoring/
398
+ SKILL.md 路由 + 五步短路径
399
+ references/roots.md 根表、rank、优先级、项目根判定
400
+ references/frontmatter.md 每个字段、布尔写法、为什么技能会静默不加载
401
+ references/resources.md 资源文件放哪、模型怎么被告知去找
402
+ references/plugin-bundled.md 把技能打进插件包的两种注册方式
403
+ templates/minimal/SKILL.md 起步模板
404
+ ```
405
+
406
+ 候选带上 `resourceBase: { kind: 'directory', path }` 之后,加载出来的
407
+ `<skill_resources>` 里会写明 `Base directory for this skill: <绝对路径>`,
408
+ Agent 因此能用普通文件工具去读 `references/*.md`。rank 600 意味着任何项目级/用户级同名技能都能盖过它 ——
409
+ 想改写它就自己建一个同名技能。
410
+
411
+ **`skill_create`(模型工具)**:
412
+
413
+ | 参数 | 含义 |
414
+ |---|---|
415
+ | `name` | kebab-case 技能名,同时是目录名 |
416
+ | `description` | frontmatter 的 description:目录里唯一的路由信号 |
417
+ | `whenToUse` | 可选,写进 frontmatter |
418
+ | `body` | frontmatter 之后的 Markdown 正文;省略则写一个 `# <name>` 标题 |
419
+ | `root` | `project-dsh` / `project-agents` / `user-dsh` / `user-agents`,默认 `user-agents` |
420
+ | `resources` | `[{ path, content }]`,写进同一个技能目录(如 `references/roots.md`) |
421
+ | `modelInvocable` / `userInvocable` | 传 `false` 分别写 `disable-model-invocation: true` / `user-invocable: false` |
422
+ | `overwrite` | 覆盖已存在的技能必须显式传,默认宁可失败也不覆盖 |
423
+
424
+ 它存在的理由很具体:文件写入工具被沙箱限制在会话工作区内,而 `$DSH_HOME/skills`、
425
+ `$DSH_AGENTS_HOME/skills` 不在工作区里 —— 用户说「给我做个技能」时,Agent 在那里根本写不进去。
426
+ 这个工具用的是和面板同一套落盘实现:只写四个受管根、只写 `<root>/<name>/` 之内,
427
+ 并拒绝绝对资源路径、`..` 段、会覆盖 `SKILL.md` 的资源,以及与内嵌技能重名。
428
+ 它通过 `ctx.inject(['tools'], …)` 注册,所以没有 `ctx.tools` 的组合只是少这个工具,不会挂不上。
429
+
430
+ > [!NOTE]
431
+ > 工具是**宿主级全局注册**:同一宿主上每个 Agent 都会多看到 `skill_create`(多一份工具 schema)。
432
+ > 没有开关,卸载插件才会消失。
433
+
434
+
273
435
  ### 安装
274
436
 
275
437
  **市场安装**:侧栏「插件」→ 搜索 `@restregular/dsh-skill-center` → 安装 → 重启宿主。
@@ -286,7 +448,7 @@ pnpm add @restregular/dsh-skill-center
286
448
  // <profile>/package.json
287
449
  {
288
450
  "dsh": { "profile": { "bundles": [/* … */ "@restregular/dsh-skill-center"] } },
289
- "dependencies": { "@restregular/dsh-skill-center": "^0.2.2" }
451
+ "dependencies": { "@restregular/dsh-skill-center": "^0.3.1" }
290
452
  }
291
453
  ```
292
454
 
@@ -297,8 +459,8 @@ pnpm add @restregular/dsh-skill-center
297
459
  > patch 里那条 loader 行的 `name` **必须逐字等于包名**(`@restregular/dsh-skill-center`)。
298
460
  > 宿主 `@deepseek-ai/dsh-client-modules` 是按这个行名去定位该行的 `package.json` 并**严格比对**
299
461
  > `name` 的(`nearestPackage`)。写成裸名 `dsh-skill-center` 时:宿主半边照样挂得上,
300
- > 但浏览器半边会被**静默丢弃** —— 侧栏没有「技能中心」、`/plugins/@restregular/dsh-skill-center/client.js`
301
- > 返回 `404`,而且日志里一个字都没有。0.2.1 修的就是这个;0.2.0 发出去的是裸名。
462
+ > 但浏览器半边会被**静默丢弃** —— 侧栏没有「技能中心」、`op=diagnose` 里 `client.inGraph` 是 `false`
463
+ > 且没有 `client.url`,而且日志里一个字都没有。0.2.1 修的就是这个;0.2.0 发出去的是裸名。
302
464
 
303
465
  > [!IMPORTANT]
304
466
  > 客户端 bundle 还必须用**包名**注册:`__ModuleLoader__.load({ id: '@restregular/dsh-skill-center' })`,
@@ -325,8 +487,10 @@ pnpm add @restregular/dsh-skill-center
325
487
  profile 的 `node_modules`。
326
488
  2. **浏览器半边进模块图了吗**:`…/api?op=diagnose` 里的 `client.inGraph` 必须是 `true`。
327
489
  若 `host.mounted` 为 `true` 而它是 `false`,就是上面那个「行名 ≠ 包名」。
328
- 3. **bundle 有没有被服务**:`http://127.0.0.1:19387/plugins/@restregular/dsh-skill-center/client.js`
329
- 应返回 JavaScript 而不是 `404`。
490
+ 3. **bundle 有没有被服务**:浏览器只会拿到**带版本号**的地址
491
+ (`/plugins/@restregular/dsh-skill-center/client.js?rev=<12 位十六进制>`),而路由**故意拒绝**
492
+ 版本不匹配的请求 —— 所以即使一切正常,直接要裸路径也会回 `404`。请从 `op=diagnose` 的
493
+ `client.url` 取地址(现在它还会在 `client.entryId` 里告诉你命中哪一行),再取那个地址。
330
494
  4. **跑离线合成自检**(不用重启宿主):`node verify-compose.mjs <profileDir>`,
331
495
  现在它会因为「行名解析不回本包」直接 FAIL。
332
496
  5. `pnpm add` 必须**在 profile 目录里**执行;装到别处(全局 `npm i -g`、别的目录)profile 合成不到。
@@ -341,7 +505,16 @@ pnpm add @restregular/dsh-skill-center
341
505
  - 写操作只针对**落在受管技能根里、真实存在于磁盘**的文件:
342
506
  `<projectRoot>/.dsh/skills`、`<projectRoot>/.agents/skills`、`$DSH_HOME/skills`、`$DSH_AGENTS_HOME/skills`。
343
507
  - frontmatter 改写是字符串级编辑,只动与调用策略相关的键,其余逐字保留(含行尾风格)。
508
+ - 受管根内的技能,`list` 的调用策略**以磁盘上的文件为准**,不照抄注册表快照:技能目录是缓存的,
509
+ 而 watcher 的失效事件总在写盘之后一小会儿才到 —— 只信快照的面板会在刚保存完的瞬间把界面弹回
510
+ 旧值,这正是「拨完开关又自己跳回去、手动刷新才变」的根因。
344
511
  - 删除只 rename 进 `.trash`。
512
+ - 另注册一个内嵌技能提供方(`ctx.skills.registerProvider`,指向包内 `skills/skill-authoring/`)
513
+ 和 `skill_create` 工具(`ctx.inject(['tools'], …)` + `ctx.tools.register`)。
514
+ 工具 schema 是手写的原始 JSON Schema,只使用 `@deepseek-ai/dsh-tools` 接受的子集 ——
515
+ 本包因此保持零运行时依赖,也不需要宿主侧的模块解析。
516
+ - 三块各自独立降级:缺 `tools` / `webServer`,或者随包技能的资产解析失败,
517
+ 都只写一行日志,其余部分照常挂载。
345
518
 
346
519
  **浏览器半边**(`client/client.js`)注册两处槽位,不 require 任何 Harness Client 包:
347
520
 
@@ -359,6 +532,11 @@ ctx.slots.inject('main', () => ctx.slots.register({
359
532
  样式只用宿主主题令牌 `--dsw-alias-*` / `--dsw-radius-*` / `--dsw-focus-ring-*`,
360
533
  `<style>` 带 `data-plugin="dsh-skill-center"`(避免被宿主的无主样式回收误删)。
361
534
 
535
+ 开关的改动先落在组件状态里:开关的值取「本地覆盖 → 目录快照」的先后顺序,
536
+ 保存按钮的三态由「待保存集合 + 是否保存过」推出。保存之后本地覆盖会继续留着,
537
+ 直到某次 `list` 的快照真的与它一致(`load()` 只撤销已经追平的那些)——
538
+ 所以宿主的目录快照慢半拍也不会把界面弹回去。
539
+
362
540
  ### API 示例
363
541
 
364
542
  ```powershell
@@ -373,6 +551,9 @@ Invoke-RestMethod -Method POST -Uri "http://127.0.0.1:19387/dsh-skill-center/api
373
551
 
374
552
  非回环来源一律 403。
375
553
 
554
+ `create` 与模型工具 `skill_create` 共用一份实现:除 `name` / `description` / `body` / `rootId` 外,
555
+ 还接受 `whenToUse`、`resources: [{ path, content }]`、`overwrite`、`modelInvocable`、`userInvocable`。
556
+
376
557
  ### 开发
377
558
 
378
559
  | 改动 | 生效方式 |
@@ -390,12 +571,38 @@ $exe = "C:\path\to\DeepSeek Harness.exe"; $here = "D:\path\to\dsh-skill-center"
390
571
  $env:DSH_SMOKE_SESSION = "<session-id>"; & $exe "$here\client-smoke.mjs"
391
572
  # 客户端半侧是否会被浏览器模块图接受(复现 dsh-client-modules 的合成校验)
392
573
  & $exe "$here\verify-compose.mjs"
574
+ # 随包技能 + skill_create:纯离线,不碰宿主也不碰真实用户技能根
575
+ node "$here\verify-skill.mjs"
393
576
  ```
394
577
 
578
+ `verify-compose.mjs` 必须用 Electron 可执行文件跑:它要读 app.asar 里随发行版交付的
579
+ `@deepseek-ai` 包,普通 `node` 打不开 asar。`verify-skill.mjs` 正好相反 ——
580
+ 它用桩 ctx 直接 import `lib/index.js`,完全不需要宿主:检查随包技能契约
581
+ (目录名 vs frontmatter、description 非空、`resourceBase` 目录真实存在、正文表格/链接里点到的
582
+ `references/` 等路径是否真的存在)、检查工具 schema 是否落在 `@deepseek-ai/dsh-tools` 支持的子集内,
583
+ 然后**真的跑一遍** `skill_create`:新建、资源落盘、覆盖、重名、非法名、路径穿越、平坦文件、
584
+ 显式项目根,以及三条降级路径;另外还断言:受管根技能的调用策略取自磁盘文件,即使注册表快照
585
+ 给的是旧值。
586
+
587
+ `client-smoke.mjs` 用迷你 React 把真实面板渲染在真宿主回环路由上,再驱动「暂存 + 保存」全流程:
588
+ 拨开关不发任何写请求、点保存只发一次带两个策略字段的 `setInvocation`、按钮按
589
+ 未修改 → 保存 → 已保存 走(再改一次回到保存)、以及宿主目录快照慢半拍也弹不回开关。
590
+ 它拦下了所有 POST,所以跑一遍不会改动任何真实 `SKILL.md`。
591
+
395
592
  > 搬迁/改名注意:客户端模块表按 `(loader baseUrl, loader 行名)` 缓存包元信息,不含文件时间戳。
396
593
  > 把包挪到别处后,运行中的宿主仍握着旧路径;浏览器端用的是激活时读进内存的快照,
397
594
  > 所以刷新页面照常可用,**下次重启宿主**才会解析新路径。
398
595
 
596
+ ### 发版
597
+
598
+ 改 `version` → 提交 → 推送 → 在仓库根用有该 scope 发布权限的 token 执行 `npm publish`。
599
+ 注册表对成功的发布回 `202 {"success":true}`,随后该版本进入 **validating** 阶段:验证通过之前,
600
+ `/@restregular/dsh-skill-center/<版本>` 与 tarball 地址都回 `404`,`dist-tags` 仍指向上一个版本。
601
+ 这是正常窗口,不是失败。**用带时间戳参数的版本地址重新探测**(`?t=<ticks>`)即可,
602
+ 不要重复发布:同一版本再发一次会回
603
+ `E409 Cannot publish over previously staged version <版本>`(`npm stage publish` 则回
604
+ `E409 Cannot stage previously published version <版本>`),而 `npm stage list` 可能什么都不显示。
605
+
399
606
  ### 已知边界
400
607
 
401
608
  - 面板占中央区域时,右栏的 `dsh-resource://` 预览不可用(宿主规定:无会话面时 `sidebarRight.openResource` 抛错)。
@@ -404,6 +611,10 @@ $env:DSH_SMOKE_SESSION = "<session-id>"; & $exe "$here\client-smoke.mjs"
404
611
  - 没有选中会话时退化为「按工作目录读取」,此时只能看到内嵌技能 + 该 cwd 的项目技能;
405
612
  面板底栏会显示实际使用的 cwd 与作用域级别。
406
613
  - 目录整体替换制:一次开关改动会让模型的技能目录消息整条重发(这是 Harness 自身的设计)。
614
+ - `skill_create` 是宿主级全局注册:每个 Agent 都会多看到一份工具 schema,没有按 Agent 的开关,
615
+ 只有卸载插件才会消失。
616
+ - 随包 `skill-authoring` 是内嵌技能,面板里只读。它排在 rank 600,所以同名项目级/用户级技能会
617
+ 整体盖过它 —— 这正是定制它的正道。
407
618
 
408
619
  ### 许可
409
620
 
package/client/client.js CHANGED
@@ -83,6 +83,9 @@ window.__ModuleLoader__.load({
83
83
  noBody: '(正文为空)',
84
84
  created: '已创建',
85
85
  saved: '已保存',
86
+ unmodified: '未修改',
87
+ save: '保存',
88
+ saveHint: '把改动写回 SKILL.md 的 frontmatter',
86
89
  back: '返回会话',
87
90
  retry: '重试',
88
91
  modelOff: '模型关闭',
@@ -132,6 +135,9 @@ window.__ModuleLoader__.load({
132
135
  noBody: '(empty body)',
133
136
  created: 'Created',
134
137
  saved: 'Saved',
138
+ unmodified: 'Unmodified',
139
+ save: 'Save',
140
+ saveHint: 'Write the changes back into the SKILL.md frontmatter',
135
141
  back: 'Back to session',
136
142
  retry: 'Retry',
137
143
  modelOff: 'model off',
@@ -389,6 +395,17 @@ window.__ModuleLoader__.load({
389
395
  const [body, setBody] = React.useState(undefined);
390
396
  const [pendingDelete, setPendingDelete] = React.useState(false);
391
397
  const [creating, setCreating] = React.useState(false);
398
+ /** 已点开关、还没点"保存"的改动:技能名 → 部分调用策略。 */
399
+ const [pendingEdits, setPendingEdits] = React.useState({});
400
+ /**
401
+ * 面板显示值(技能名 → 部分调用策略),覆盖目录快照里的同名字段。
402
+ * 为什么要本地留存:宿主的技能目录是**缓存**的,写盘之后 chokidar 失效
403
+ * 事件到达前那次 `list` 仍是旧值 —— 只信快照的话,刚点完开关的界面会
404
+ * 自己弹回去(这正是"点了 toggle 不切换、手动刷新才切换"的原因)。
405
+ */
406
+ const [shownEdits, setShownEdits] = React.useState({});
407
+ /** 本次进入面板后是否保存过:决定按钮显示"未修改"还是"已保存"。 */
408
+ const [savedOnce, setSavedOnce] = React.useState(false);
392
409
 
393
410
  const load = React.useCallback(async () => {
394
411
  setBusy(true);
@@ -397,6 +414,29 @@ window.__ModuleLoader__.load({
397
414
  const payload = await apiGet('list', pickScope(sessions, scopeCache));
398
415
  setCatalog(payload);
399
416
  setSelected((previous) => previous ?? payload.skills[0]?.name);
417
+ // 快照已经追平本地面板值的字段就地撤销覆盖,覆盖集合只留"宿主还没追上"的部分。
418
+ setShownEdits((previous) => {
419
+ const next = {};
420
+ let changed = false;
421
+ for (const [name, patch] of Object.entries(previous)) {
422
+ const skill = payload.skills.find((candidate) => candidate.name === name);
423
+ if (skill === undefined) { changed = true; continue; }
424
+ const fresh = {
425
+ modelInvocable: skill.modelInvocable !== false,
426
+ userInvocable: skill.userInvocable !== false,
427
+ };
428
+ const keep = {};
429
+ if (patch.modelInvocable !== undefined && patch.modelInvocable !== fresh.modelInvocable) {
430
+ keep.modelInvocable = patch.modelInvocable;
431
+ }
432
+ if (patch.userInvocable !== undefined && patch.userInvocable !== fresh.userInvocable) {
433
+ keep.userInvocable = patch.userInvocable;
434
+ }
435
+ if (Object.keys(keep).length > 0) next[name] = keep;
436
+ else changed = true;
437
+ }
438
+ return changed ? next : previous;
439
+ });
400
440
  } catch (cause) {
401
441
  setError(String(cause?.message ?? cause));
402
442
  } finally {
@@ -423,7 +463,7 @@ window.__ModuleLoader__.load({
423
463
  /**
424
464
  * 跑一个写操作,然后重读目录。
425
465
  * @param action - 返回 Promise 的动作。
426
- * @param okMessage - 成功提示。
466
+ * @param okMessage - 成功提示;不传就不显示绿色提示条。
427
467
  */
428
468
  const mutate = async (action, okMessage) => {
429
469
  setBusy(true);
@@ -440,16 +480,77 @@ window.__ModuleLoader__.load({
440
480
  }
441
481
  };
442
482
 
443
- const setInvocation = (skill, patch) => mutate(
444
- () => apiPost({
445
- op: 'setInvocation',
446
- ...pickScope(sessions, scopeCache),
447
- name: skill.name,
448
- modelInvocable: patch.modelInvocable ?? skill.modelInvocable,
449
- userInvocable: patch.userInvocable ?? skill.userInvocable,
450
- }),
451
- t('saved'),
452
- );
483
+ /**
484
+ * 一个技能此刻该显示的调用策略:本地改动优先,其次目录快照。
485
+ * @param skill - 目录条目。
486
+ * @returns `{ modelInvocable, userInvocable }`。
487
+ */
488
+ const shownInvocation = (skill) => {
489
+ const local = shownEdits[skill.name] ?? {};
490
+ return {
491
+ modelInvocable: local.modelInvocable ?? (skill.modelInvocable !== false),
492
+ userInvocable: local.userInvocable ?? (skill.userInvocable !== false),
493
+ };
494
+ };
495
+
496
+ /**
497
+ * 拨一个开关:只改本地状态,界面立刻反映,写盘交给"保存"。
498
+ * @param skill - 目录条目。
499
+ * @param patch - 本次改动的字段。
500
+ */
501
+ const editInvocation = (skill, patch) => {
502
+ setShownEdits((previous) => ({
503
+ ...previous,
504
+ [skill.name]: { ...(previous[skill.name] ?? {}), ...patch },
505
+ }));
506
+ setPendingEdits((previous) => ({
507
+ ...previous,
508
+ [skill.name]: { ...(previous[skill.name] ?? {}), ...patch },
509
+ }));
510
+ setSavedOnce(false);
511
+ setError(undefined);
512
+ setNotice(undefined);
513
+ };
514
+
515
+ /**
516
+ * 保存全部未保存的调用策略改动:逐个 POST,然后重读目录。
517
+ * 重读拿到的可能仍是旧快照(目录缓存),所以显示值继续由 shownEdits 兜住,
518
+ * 直到快照追平 —— 撤销覆盖的逻辑在 load() 里。
519
+ */
520
+ const saveEdits = async () => {
521
+ const entries = Object.entries(pendingEdits);
522
+ if (entries.length === 0) return;
523
+ setBusy(true);
524
+ setError(undefined);
525
+ setNotice(undefined);
526
+ try {
527
+ for (const [name, patch] of entries) {
528
+ const skill = skills.find((candidate) => candidate.name === name);
529
+ if (skill === undefined) continue;
530
+ const target = { ...shownInvocation(skill), ...patch };
531
+ await apiPost({
532
+ op: 'setInvocation',
533
+ ...pickScope(sessions, scopeCache),
534
+ name,
535
+ modelInvocable: target.modelInvocable,
536
+ userInvocable: target.userInvocable,
537
+ });
538
+ }
539
+ setPendingEdits({});
540
+ setSavedOnce(true);
541
+ await load();
542
+ } catch (cause) {
543
+ setError(String(cause?.message ?? cause));
544
+ } finally {
545
+ setBusy(false);
546
+ }
547
+ };
548
+
549
+ /** 有多少个技能带着未保存的改动。 */
550
+ const pendingCount = Object.keys(pendingEdits).length;
551
+ const saveLabel = pendingCount === 0
552
+ ? (savedOnce ? t('saved') : t('unmodified'))
553
+ : pendingCount === 1 ? t('save') : `${t('save')} (${pendingCount})`;
453
554
 
454
555
  const removeSkill = (skill) => mutate(
455
556
  async () => {
@@ -457,6 +558,18 @@ window.__ModuleLoader__.load({
457
558
  setSelected(undefined);
458
559
  setBody(undefined);
459
560
  setPendingDelete(false);
561
+ setPendingEdits((previous) => {
562
+ if (!(skill.name in previous)) return previous;
563
+ const next = { ...previous };
564
+ delete next[skill.name];
565
+ return next;
566
+ });
567
+ setShownEdits((previous) => {
568
+ if (!(skill.name in previous)) return previous;
569
+ const next = { ...previous };
570
+ delete next[skill.name];
571
+ return next;
572
+ });
460
573
  },
461
574
  t('deleted'),
462
575
  );
@@ -481,6 +594,14 @@ window.__ModuleLoader__.load({
481
594
  ? t('subtitle')
482
595
  : `${t('total', { n: skills.length })} · ${scopeText}`),
483
596
  h('span', { className: 'dsh-sc-spacer', key: 'sp' }),
597
+ h('button', {
598
+ key: 'save',
599
+ type: 'button',
600
+ className: `dsh-sc-btn${pendingCount > 0 ? ' dsh-sc-btn-primary' : ''}`,
601
+ disabled: busy || pendingCount === 0,
602
+ title: t('saveHint'),
603
+ onClick: saveEdits,
604
+ }, saveLabel),
484
605
  h('button', {
485
606
  key: 'back',
486
607
  type: 'button',
@@ -579,19 +700,19 @@ window.__ModuleLoader__.load({
579
700
  h('div', { key: 'sw', className: 'dsh-sc-section' }, [
580
701
  h(Switch, {
581
702
  key: 'model',
582
- checked: current.modelInvocable !== false,
703
+ checked: shownInvocation(current).modelInvocable,
583
704
  disabled: busy || current.writable !== true,
584
705
  label: t('model'),
585
706
  hint: t('modelHint'),
586
- onChange: (value) => setInvocation(current, { modelInvocable: value }),
707
+ onChange: (value) => editInvocation(current, { modelInvocable: value }),
587
708
  }),
588
709
  h(Switch, {
589
710
  key: 'user',
590
- checked: current.userInvocable !== false,
711
+ checked: shownInvocation(current).userInvocable,
591
712
  disabled: busy || current.writable !== true,
592
713
  label: t('user'),
593
714
  hint: t('userHint'),
594
- onChange: (value) => setInvocation(current, { userInvocable: value }),
715
+ onChange: (value) => editInvocation(current, { userInvocable: value }),
595
716
  }),
596
717
  ]),
597
718
  current.writable !== true