1.4 卸载、更新与重名裁决

技能的生命周期管理

技能是文件,所以管理手段都是文件操作加组合操作。把常见动作列全:

动作 本地技能 bundled 技能
安装 复制 / 克隆到技能根 在组合里加提供方行
更新 覆盖文件或 git pull 升级 dsh 版本
临时停用 改名加 .off 后缀 摘掉配置行
彻底卸载 删除目录 摘掉配置行
查看来源 目录里就有 ctx.skills 注册表摘要
验证 列表查看 / 金丝雀技能 挂载后列表查看
换层迁移 复制到目标层,旧层加 .off,验证后删除 调整组合配置行
审计 读摘要的 source / provider / path 同左

“临时停用”这个技巧值得单独说:把目录名改成 my-skill.off,名称立刻不合 kebab-case 规范,技能从目录消失,但文件还在。想恢复就把后缀去掉。比删了再找回来稳妥得多。它同时是个诊断手段——技能突然消失又回来,能帮你确认问题到底出在技能本身还是别处。

换层迁移:从用户级搬到项目级

技能放错层是高频问题(比如先在用户级试了一下,后来决定随项目走)。标准动作四步:

  1. 把目录复制到目标层(如 .dsh/skills/);
  2. 给旧层目录加 .off 后缀停用,先别删;
  3. 读一次摘要,确认 source 已变成目标层、path 指向新位置;
  4. 跑一次真实任务确认行为不变,然后删除旧层目录。

顺序不能反:先停后删、先验证后清理,任何一步没达到判据就停在上一步。

更新的三种节奏

本地技能的更新分三种节奏,按来源选:

来源 更新节奏 操作
手工维护的个人技能 想到就改 直接编辑,watcher 实时生效
克隆来的技能仓库 跟随上游 git pull --ff-only
项目内团队技能 随 PR 走 review 后合并,clone 即得

第三种最容易被忽略:团队技能也是普通文件,它的“发布”就是一次代码合并。这意味着技能变更可以、也应该走和代码变更同样的评审流程——description 改坏了,影响面比一个函数改坏了大得多,因为它直接决定模型还认不认这个技能。

重名裁决的完整规则

装了十几个技能后重名几乎必然发生。官方规则可以浓缩成两句话:

第一句:跨层看 scope 链,近者胜。 读取时把全局层与观察 scope 的链合并,最近层的条目直接赢得重名 skill。

~/.dsh/skills/code-review/          (rank 400,用户全局)
./.dsh/skills/code-review/          (rank 100,项目级)

→ 在仓库内工作时,项目级胜出
→ 在仓库外工作时,用户级生效

第二句:同层内比三样,顺序固定。 rank → 提供方顺序 → 本地顺序;摘要按名称排序。更细的层级结构与三个实例见 1.2。

这套规则的价值在于有意的分层覆盖:全局放通用版,项目放定制版,不需要任何条件判断语法。

一个反直觉的点:近者胜是“覆盖”而不是“合并”。项目级技能不会继承用户级技能的内容,模型只看到胜出的那一份。想让定制版包含通用版的全部指令,就把通用内容复制进去,或者把共享材料放进 references/、两个版本都引用它。

提供方级别的控制:signal 与 invalidate()

除了单技能变更,整个提供方目录也可能需要刷新。提供方工厂在注册作用域内收到两种控制能力,语义不同,别混用:

能力 语义
signal 注册失败或该提供方被 dispose 时中止。它是止损,不是刷新
invalidate() 仅在精确注册仍活动时清除已完成目录。它要求注册仍然有效,乱序调用不生效

一个是“出事时叫停”,一个是“还有效时清场”。区分这两者的意义在于:invalidate 不是万能刷新按钮,注册已经不准确时它什么都不做。

对使用者,这两种能力多数时候是隐形的,但在这些场景会派上用场:

场景 会发生什么
提供方注册失败 signal 中止该提供方,其他提供方不受影响
提供方被 dispose(摘配置行、卸载插件) signal 中止,其技能从后续读取中消失
需要强制刷新已完成目录 注册仍准确时 invalidate() 清场,下次读取重新发现

提供方代次变化时会发生什么

发现过程中提供方代次可能变化(远程提供方重连、目录被替换等)。官方的处理策略分两步:

  1. 重试一次——多数抖动一次就能过去;
  2. 再次变化——返回最新候选,标记该次观测为不完整,并且不缓存。

与之相关的还有两条观测规则:显式请求的不完整观测同样提供可用候选但不缓存;格式错误的候选则快速失败,不会静默跳过。

把观测的三条规则放在一起记:

规则 行为
提供方 list() 被拒 记录日志,从该次观测中省略其结果
显式的不完整观测 提供可用候选,但不缓存
格式错误的候选 快速失败,不静默跳过

对使用者的含义很直接:你不需要手动清缓存。遇到一次不完整的观测,重新读取一次通常就能拿到完整结果。

另外,发现缓存以解析后的 scope 链为键:重设 scope 父级(如空会话重组)无需注册表变更,即可被下一次读取看到——换 scope 不用重启。

怎么查“现在生效的是哪一个”

注册表返回的摘要带有来源与提供方字段,每个技能能看到:

字段 用途
source 来源桶,如 project-dsh、user-dsh、bundled
provider 拥有该技能正文的提供方名
path 指令文件的绝对路径(本地技能有,虚拟技能无)
invocation 模型 / 用户两个调用开关

实操方法:对可疑技能读一次摘要,看 path 指向哪——生效的就是哪份文件;source 告诉你它从哪一层来,provider 告诉你由谁供给。三者对不上,就是重名裁决或目录层级出了偏差。这是排查“我改了却没生效”最快的办法。

举个完整例子。你在用户级和项目级各放了一份 code-review,不确定仓库内生效的是哪份:

  1. 在仓库内会话读取技能摘要,找到 code-review 条目;
  2. 看 source:显示 project-dsh 说明项目级胜出,符合跨层近者胜的规则;
  3. 看 path:指向 .dsh/skills/code-review/SKILL.md,打开它确认是你改的那份;
  4. 若 path 指向用户级,说明项目根判断和你想的不一样——当前目录可能不在含 .git 的仓库内。

四步之内一定能定位,不需要读源码。

「装了没反应」六项排查清单

按命中率从高到低,每项都给了判据:

  1. 放错目录层。 判据:摘要里的 source 是否等于你期望的那一层;在项目内还要确认项目根真的是含 .git 的目录,而不是它的某个子目录。
  2. 名称不合规。 判据:对照 ^[a-z0-9]+(?:-[a-z0-9]+)*$——大写、下划线、点号都会让技能直接不出现。
  3. description 没写触发条件。 判据:拿你的提问和 description 对一遍,看触发场景是否写在明面上。这是“装了没反应”的头号原因。
  4. 改了没保存 / 保存到了副本。 判据:打开 path 指向的文件,确认内容是你刚改的版本。平白无故一类,但确实高频。
  5. 期望递归发现。 判据:技能是否平铺在技能根直属;嵌套子目录里的 SKILL.md 不会被发现。
  6. watcher 观测不完整。 判据:重载组合或重启后是否恢复。极少数情况,且不会隐藏本来可读的候选。

症状与第一嫌疑对照,排查时先看这张表:

症状 第一嫌疑
目录里根本没有 第 1、2 项
目录里有,模型不选 第 3 项
改了内容不生效 第 4、6 项
嵌套目录里的技能不出现 第 5 项

一个务实的管理建议

技能数量上去以后,建议在仓库里维护一份清单文件,例如 .dsh/skills/README.md:

# 本项目技能清单

| 技能 | 来源 | 负责人 | 用途 |
|---|---|---|---|
| code-review | 本仓库 | @张三 | 按公司规范评审 PR |
| migrate-runbook | 本仓库 | @李四 | 数据库迁移六步法 |

技能是纯文本,最大的风险不是坏掉,而是没人知道它为什么存在。一份清单能让技能从“个人技巧”变成“团队基础设施”。

清单要随技能一起演进:新增技能时加一行,停用技能时标注而不是删行,每隔一段集中过一次,把确认没人用的技能清掉。技能目录会撒谎——文件还在不代表还有人用,清单至少能逼出一次集体确认。

小结

  • 停用优先用改名加后缀,别急着删。
  • 重名两规则:跨层近者胜,同层比 rank → 提供方顺序 → 本地顺序。
  • signal 管中止,invalidate() 管清目录;代次连变两次会得到不完整且不缓存的观测。
  • 用摘要里的 source / provider / path 查生效来源。
  • “装了没反应”先查 description,再看 path。
  • 给项目技能配一份清单,别让技能变成孤儿。

延伸阅读