Independent model and user skill invocation policy
模型与用户彼此独立的 skill(技能)调用策略
The skill registry originally treated discovery as a model catalog: `ctx.skills.list()` removed model-disabled skills, while `ctx.skills.get()` remained an unfiltered trusted loader. That was enough for model-initiated loading, but it could not represent Claude-compatible skills that are advertised only to a person, only to a model, to both, or to neither. The TUI compounded the mismatch by deriving user autocomplete
English
Problem
The skill registry originally treated discovery as a model catalog: ctx.skills.list() removed model-disabled skills, while ctx.skills.get() remained an unfiltered trusted loader. That was enough for model-initiated loading, but it could not represent Claude-compatible skills that are advertised only to a person, only to a model, to both, or to neither. The TUI compounded the mismatch by deriving user autocomplete from the model-filtered list and allowing every exact name through get().
The local parser also exposed an internal camel-case spelling as frontmatter. Supporting the established negative disable-model-invocation and positive user-invocable fields requires a durable, symmetric domain representation without turning every possible YAML key into an untyped cross-package contract.
Decision
SkillSummary carries a required typed invocation: SkillInvocationPolicy object whose modelInvocable: boolean and userInvocable: boolean fields are positive and symmetric. Omission exists only at explicit input boundaries: a runtime SkillRegistration without a policy and local frontmatter without either invocation key resolve to { modelInvocable: true, userInvocable: true } before producing candidates or definitions. Future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist; the local provider still parses frontmatter as an open Record<string, unknown>, then projects only recognized fields and their defaults into the normalized typed policy.
ctx.skills.list() returns every winning summary and no longer chooses an invocation surface. isModelInvocable(skill) and isUserInvocable(skill) read the matching positive field directly. ctx.skills.get() remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill. The model tool and TUI check the invocation-neutral summary before calling get(), then recheck the loaded definition so a denied name never reaches definition loading and a policy change between discovery and load cannot expose its body.
The local provider accepts the exact kebab-case frontmatter keys disable-model-invocation and user-invocable. It accepts YAML booleans plus case-insensitive true/false, yes/no, on/off, and 1/0, matching the practical boolean forms accepted by Claude skills. It maps disable-model-invocation to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional whenToUse and metadata values are omitted because they do not decide invocation.
The model-facing dsh-tool-skill catalog and loader enforce isModelInvocable. The TUI /skill: autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided dsh migrate and dsh upgrade sessions follows this same TUI path and must remain user-invocable. The browser skill.list RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
These rules permit all four combinations:
| Policy | Model invocation | User invocation |
|---|---|---|
{ modelInvocable: true, userInvocable: true } | included | included |
{ modelInvocable: true, userInvocable: false } | included | excluded |
{ modelInvocable: false, userInvocable: true } | excluded | included |
{ modelInvocable: false, userInvocable: false } | excluded | excluded |
This decision extends the skill system and supersedes the invocation-policy limitation recorded by the archived TUI skill slash command.
Alternatives considered
Store all frontmatter in a generic Map and read string keys in isModelInvocable / isUserInvocable. Rejected because misspelled keys, non-boolean values, and consumer-specific coercion would cross package boundaries without type checking. The parser boundary remains open; the domain model is deliberately typed and narrow.
Keep ctx.skills.list() model-filtered and add a second user list. Rejected because discovery, duplicate resolution, caching, and ordering are surface-neutral work. One complete catalog plus explicit predicates prevents those mechanisms from drifting while making each consumer's policy visible at its boundary.
Enforce invocation policy inside ctx.skills.get(). Rejected because get() cannot know whether its caller is a model tool, a human command, or trusted orchestration. Filtering there would also make the both-disabled quadrant impossible to inspect or administer.
Treat camel-case frontmatter as an alias. Rejected because the external format is the kebab-case Claude skills contract and the repository has no released compatibility obligation. Failing loud avoids silently preserving a nonstandard spelling.
Add a browser-side direct skill invocation RPC. Rejected for this change because the existing browser flow inserts a model reference rather than a loaded instruction body. Its correct policy is therefore the intersection; a direct user-loading surface needs its own wire and logging design.
Consequences
Providers and runtime registrations expose a small typed invocation contract, while local YAML remains extensible. Every new discovery consumer must consciously choose the model predicate, the user predicate, their intersection, or trusted unfiltered access; forgetting that choice is now review-visible rather than hidden in registry behavior.
The changed model catalog is pinned by the keyless ACP snapshot, which includes a model-only skill and excludes a user-only skill. The assembled keyless TUI snapshot discovers and loads a user-only skill by exact name, then rejects a model-only skill before loading its body; the real Loader/PTY smoke proves the same user-only path through the shipped terminal process. The real-host Chromium snapshot pins the browser intersection across all four policy quadrants. TUI unit coverage exercises those quadrants plus disposal races, while registry, local-parser, model-tool, and API-proxy tests cover defaults, supported boolean forms, malformed values, legacy-key rejection, exact-load enforcement, and the browser intersection.
中文
问题
skill 注册表最初将发现操作视为模型目录:ctx.skills.list() 会移除禁止模型调用的 skill,而 ctx.skills.get() 仍是不过滤内容的可信 loader。该设计足以支持由模型发起的加载,却无法表示与 Claude 兼容的四类 skill:仅向用户公开、仅向模型公开、同时向两者公开,或者两者均不公开。TUI 从面向模型过滤后的列表中生成用户自动补全,并允许通过 get() 加载任意精确名称,这进一步放大了两类调用策略不匹配的问题。
本地解析器还将一种内部驼峰式拼写暴露为 frontmatter。若要支持既有的负向字段 disable-model-invocation 和正向字段 user-invocable,需要建立持久且对称的领域表示,同时避免把所有可能出现的 YAML 键都变成跨包的无类型约定。
决策
SkillSummary 包含一个必填且类型明确的 invocation: SkillInvocationPolicy 对象,其 modelInvocable: boolean 和 userInvocable: boolean 字段为正向且对称。只有显式输入边界可以省略它:未提供策略的运行时 SkillRegistration,以及两个调用键均未提供的本地 frontmatter,都会在生成候选项或定义前解析为 { modelInvocable: true, userInvocable: true }。未来的 frontmatter 键只有在具备消费方和执行约定后,才会进入领域模型;本地提供方仍将 frontmatter 解析为开放的 Record<string, unknown>,然后只把已识别字段及其默认值投影到规范化的类型化策略中。
ctx.skills.list() 返回所有胜出的摘要,不再替任何调用接口选择策略。isModelInvocable(skill) 和 isUserInvocable(skill) 分别直接读取对应的正向字段。ctx.skills.get() 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。模型工具和 TUI 会在调用 get() 前检查与调用策略无关的摘要,随后再次检查已加载的定义:被拒绝的名称绝不会进入定义加载流程,发现与加载之间发生策略变更也无法暴露该 skill 的正文。
本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 disable-model-invocation 和 user-invocable。它接受 YAML 布尔值,以及不区分大小写的 true/false、yes/no、on/off 和 1/0,与 Claude skills 实际支持的布尔写法一致。它将 disable-model-invocation 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 whenToUse 和 metadata 值会被省略,因为它们不参与调用判定。
面向模型的 dsh-tool-skill 目录和 loader 执行 isModelInvocable。TUI 的 /skill: 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill 对等依赖(peer dependency)变成运行时导入。由 launcher 预置、供引导式 dsh migrate 和 dsh upgrade 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 skill.list RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。
这些规则允许以下四种组合:
| 策略 | 模型调用 | 用户调用 |
|---|---|---|
{ modelInvocable: true, userInvocable: true } | 包含 | 包含 |
{ modelInvocable: true, userInvocable: false } | 包含 | 排除 |
{ modelInvocable: false, userInvocable: true } | 排除 | 包含 |
{ modelInvocable: false, userInvocable: false } | 排除 | 排除 |
该决策扩展了 skill 系统,并取代已归档的 TUI skill 斜杠命令中记录的调用策略限制。
曾考虑的替代方案
将所有 frontmatter 存入通用 Map,并在 isModelInvocable / isUserInvocable 中读取字符串键。 不予采纳,因为拼写错误的键、非布尔值以及各消费方自行采用的类型转换都会越过包边界,且无法获得类型检查。解析器边界仍保持开放;领域模型则有意采用类型明确的窄接口。
保持 ctx.skills.list() 仅返回允许模型调用的 skill,并另增一份用户列表。 不予采纳,因为发现、重复项解析、缓存和排序都是与调用接口无关的工作。采用一份完整目录和显式判定函数,可以避免这些机制逐渐分化,并在各消费方边界清楚呈现其策略。
在 ctx.skills.get() 内执行调用策略。 不予采纳,因为 get() 无法判断调用方是模型工具、人类命令还是可信编排逻辑。在此处过滤还会使两个接口均禁止调用的组合无法被检查或管理。
将驼峰式 frontmatter 作为别名处理。 不予采纳,因为外部格式遵循采用 kebab-case 的 Claude skills 约定,而本仓库尚未发布,无需承担兼容义务。显式失败可以避免暗中保留不符合标准的拼写。
增加由浏览器端直接调用 skill 的 RPC。 本次改动不予采纳,因为现有浏览器流程插入的是模型引用,而非已经加载的指令正文。因此,该流程应当取模型与用户调用策略的交集;直接由用户加载的接口需要单独设计协议与日志记录方式。
后果
提供方与运行时注册对外提供小而类型明确的调用约定,同时本地 YAML 仍可扩展。每个新的发现消费方都必须明确选择模型判定函数、用户判定函数、两者的交集,或可信且不过滤的访问方式;如果遗漏这项选择,评审时可以直接看出问题,而不会再被注册表行为掩盖。
无密钥 ACP(Agent Client Protocol)快照固定了模型目录的变更:其中包含仅允许模型调用的 skill,并排除仅允许用户调用的 skill。组装后的无密钥 TUI 快照按精确名称发现并加载一个仅允许用户调用的 skill,随后在加载正文前拒绝一个仅允许模型调用的 skill;真实 Loader/PTY 冒烟测试通过随产品交付的终端进程证明了同一条仅允许用户调用的路径。真实宿主上的 Chromium 快照固定了浏览器在全部四种策略组合下的交集行为。TUI 单元测试覆盖这些组合以及 dispose(资源释放)竞态;注册表、本地解析器、模型工具和 API 代理测试则覆盖默认值、支持的布尔写法、格式错误的值、旧键拒绝、精确名称加载时的策略执行,以及浏览器侧的策略交集。