Merge pull request #1543 from deepseek-harness/stack/agent-profiles-8-authoring

feat(web): author agent presets from a settings page
This commit is contained in:
Yichen Jiang
2026-08-10 11:55:58 +08:00
committed by GitHub
331 changed files with 16605 additions and 558 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/skill/README.md
README.md: 3dc2bcfa5775736717bdebcb92329d5655198234
README.zh.md: e57e389f9080cfc763916111cf80ea97980f01e7
README.md: 9c27a271f03f33d2b53984a6a5c18082ccc6169a
README.zh.md: 085dec3e342c2f42a39d28b995dcb4e2cf38f440

View File

@@ -6,15 +6,17 @@ Pure agent skill provider registry.
This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local).
The registry is host+per-scope layered over [`@deepseek-ai/dsh-scope`](../../core/scope), the shape the tools registry established: a registration files into the layer of its calling context's scope — host rows and repository plugins land in the global layer, a plugin mounted by an agent preset's standing composition lands in that preset's layer — and a read merges the global layer with the viewing scope's chain, the nearest layer winning a duplicate name outright while rank decides duplicates only within one layer.
## Service: `SkillService` (ctx key: `skills`)
### Public API
- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
- `ctx.skills.snapshot({ cwd?, signal? })` Returns the invocation-neutral `{ skills, complete }` observation. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached.
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy.
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by `provider.name`, unique within the calling context's layer. Duplicate names in one layer throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
- `ctx.skills.snapshot({ cwd?, signal?, scope? })` Returns the invocation-neutral `{ skills, complete }` observation for the viewing scope's merged layers. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached.
- `ctx.skills.list({ cwd?, signal?, scope? })` Borrows the readonly view options, then returns every winning summary for the current workspace, merged across the global layer and the viewing scope's chain and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
- `ctx.skills.get(name, { cwd?, signal?, scope? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy.
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill into the calling context's layer, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations in one layer are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
### Events
@@ -49,7 +51,7 @@ A provider factory runs synchronously and receives one registration-scoped contr
The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract.
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Within one layer, duplicate names resolve by rank, provider registration order, then provider-local order; across layers the nearest scope's entry wins the name. Summaries are sorted by skill name.
Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog.
@@ -74,4 +76,4 @@ No direct prompt effect. The named consumer owns the durable initial catalog and
- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism.
- **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running.
- **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics.
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.
- **Duplicate resolution is first-wins** — later lower-priority candidates within a layer are logged and hidden, and a nearer layer shadows a farther one silently; there is no API to inspect all shadowed definitions.

View File

@@ -6,15 +6,17 @@
该包负责 `ctx.skills` 接口。它不知道 skill 来自本地文件、嵌入式插件数据、HTTP 还是其他后端;提供方通过 `ctx.skills.registerProvider(...)` 注册这些来源。已发布的本地实现是 [`@deepseek-ai/dsh-skill-local`](../skill-local)。
注册表基于 [`@deepseek-ai/dsh-scope`](../../core/scope) 采用宿主 + 按 scope 的分层结构,即工具注册表确立的形态:注册落入调用方上下文 scope 对应的层——宿主行与 repository 插件落入全局层,由 agent preset 常驻组合挂载的插件落入该 preset 的层——读取时将全局层与观察 scope 的链合并最近层直接赢得重名rank 只在单层内裁决重名。
## 服务:`SkillService`ctx 键:`skills`
### 公开 API
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
- `ctx.skills.snapshot({ cwd?, signal? })` 返回与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false该次观测提供的候选项仍保留在此结果中但该结果绝不缓存。
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)``isUserInvocable(skill)`
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer以供有序组合拆卸。
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后以在调用方上下文所在层内唯一 `provider.name` 注册其只读结果。同层重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
- `ctx.skills.snapshot({ cwd?, signal?, scope? })` 返回观察 scope 各层合并后、与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false该次观测提供的候选项仍保留在此结果中但该结果绝不缓存。
- `ctx.skills.list({ cwd?, signal?, scope? })` 借用只读视图选项,然后返回当前工作区中的全部胜出摘要;这些摘要在全局层与观察 scope 链之间合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)``isUserInvocable(skill)`
- `ctx.skills.get(name, { cwd?, signal?, scope? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。
- `ctx.skills.register(skill): () => void` 只读运行时嵌入式 skill 注册进调用方上下文所在层,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同层同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer以供有序组合拆卸。
### 事件
@@ -49,7 +51,7 @@
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读约定。
违反约定时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()``get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。
违反约定时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()``get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。单层内重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突;跨层则由最近 scope 的条目赢得名称。摘要按 skill 名称排序。
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。
@@ -74,4 +76,4 @@
- **失效由提供方驱动**:注册表没有 TTL无法推断任意远程来源是否已发生变化每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。
- **提供方依次查询**:一个响应取消但速度缓慢的提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不响应取消的提供方持续运行的工作。
- **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。
- **重复解析使用先到先得**:系统会记录并隐藏层内较晚出现的低优先级候选项,较近的层会静默遮蔽较远的层;不提供检查全部被遮蔽定义的 API。

View File

@@ -27,6 +27,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -35,6 +36,7 @@
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -12,6 +12,8 @@
import { Context, Service } from 'cordis'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { NamedEntries, ScopedLayers, scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
import z from 'schemastery'
import type Schema from 'schemastery'
@@ -106,6 +108,17 @@ export interface SkillLookupOptions {
readonly signal?: AbortSignal | undefined
}
/**
* Registry read options: provider lookup context plus the viewing scope.
* The registry consumes `scope` to select layers; providers receive the same
* borrowed options object and read only their {@link SkillLookupOptions}
* contract from it.
*/
export interface SkillViewOptions extends SkillLookupOptions {
/** Viewing scope (the calling agent); omitted reads the global layer alone. */
readonly scope?: ScopeKey | undefined
}
/**
* Return whether a skill may be advertised to and loaded by a model.
* @param skill - skill metadata carrying resolved invocation controls.
@@ -290,17 +303,56 @@ interface IndexedCandidate {
provider: SkillProvider
providerOrder: number
localOrder: number
/** Owning layer, so a stale-definition invalidation can verify the exact registration is still live. */
layer: SkillLayer
}
interface CollectResult {
/** One provider registration retained by its layer. */
interface RegisteredProvider {
provider: SkillProvider
/** Service-wide monotonic registration order, the within-layer rank tiebreak. */
order: number
}
interface LayerCollectResult {
entries: IndexedCandidate[]
cacheable: boolean
}
interface CollectResult {
entries: Map<string, IndexedCandidate>
cacheable: boolean
}
/** One scope's complete skill-registry contribution. */
class SkillLayer implements ScopeLayer {
/** Providers registered through contexts carrying this scope, insertion-ordered. */
readonly providers: NamedEntries<RegisteredProvider>
/** Runtime skills registered through contexts carrying this scope. */
readonly runtime = new Map<string, SkillDefinition>()
constructor(scope: ScopeKey | undefined) {
this.providers = new NamedEntries(name => new Error(scope === undefined
? `a skill provider named "${name}" is already registered`
: `a skill provider named "${name}" is already registered in this scope`))
}
/** Whether every contribution table in this aggregate layer is empty. */
isEmpty(): boolean {
return this.providers.isEmpty() && this.runtime.size === 0
}
}
/**
* Registry of skill providers. It merges provider catalogs with stable
* first-wins duplicate handling, exposes sorted invocation-neutral summaries, and
* loads full skill bodies on demand.
* Layered registry of skill providers, the host+per-scope shape the tools
* registry established. A registration files into the layer of its calling
* context's scope ({@link scopeOf}): host rows and repository plugins land in
* the global layer, while a plugin mounted by an agent preset's standing
* composition lands in that preset's layer. A read merges the global layer
* with the viewing scope's chain — the nearest layer's entry wins a duplicate
* name outright, and the rank order decides duplicates only within one layer.
* It exposes sorted invocation-neutral summaries and loads full skill bodies
* on demand.
*/
export class SkillService extends Service {
static Config: Schema<Config> = z.object({
@@ -308,12 +360,16 @@ export class SkillService extends Service {
})
private readonly collectCacheMaxEntries: number
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
private readonly runtime = new Map<string, SkillDefinition>()
private readonly collectCache = new Map<string, IndexedCandidate[]>()
private providerRevision = 0
private readonly layers = new ScopedLayers<SkillLayer>(
scope => new SkillLayer(scope),
() => { this.invalidateCache() },
)
private readonly collectCache = new Map<string, Map<string, IndexedCandidate>>()
private revision = 0
private nextProviderOrder = 0
private runtimeRevision = 0
/** Stable identities for cache keys; scope keys are opaque identity-compared objects. */
private readonly scopeIds = new WeakMap<ScopeKey, number>()
private nextScopeId = 1
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'skills')
@@ -322,21 +378,27 @@ export class SkillService extends Service {
}
/**
* Register a borrowed same-process provider synchronously during plugin apply. Duplicate and
* reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters
* the provider and invalidates catalog caches.
* Register a borrowed same-process provider synchronously during plugin
* apply, into the calling context's layer: a scoped context (an agent
* preset's standing mount) registers for that scope alone, an unscoped
* context registers globally. Duplicate names within one layer and reserved
* names throw; remote initialization belongs in `list()`. Fiber disposal
* unregisters the provider and invalidates catalog caches.
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
* @returns the exact Cordis effect disposer that unregisters this provider;
* composite effects may yield it directly to preserve teardown ordering.
*/
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {
const lifecycle = new AbortController()
let active = false
let registration: { layer: SkillLayer; name: string } | undefined
let provider: SkillProvider
const control: SkillProviderControl = {
signal: lifecycle.signal,
invalidate: () => {
if (active) this.invalidateProvider(provider)
const active = registration
if (active !== undefined && active.layer.providers.get(active.name)?.provider === provider) {
this.invalidateCache()
}
},
}
try {
@@ -345,26 +407,21 @@ export class SkillService extends Service {
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = this.ctx.effect(function* () {
active = true
providers.set(name, { provider, order })
invalidateCache()
yield () => {
active = false
providers.delete(name)
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
invalidateCache()
}
}, 'skills.registerProvider()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
return dispose
return this.layers.effect(
this.ctx,
(layer) => {
const undo = layer.providers.insert(name, { provider, order })
registration = { layer, name }
return () => {
registration = undefined
undo()
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
}
},
{ label: 'skills.registerProvider()' },
)
} catch (error) {
lifecycle.abort(error)
throw error
@@ -372,16 +429,19 @@ export class SkillService extends Service {
}
/**
* Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which
* outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and
* receives a no-op disposer so it cannot remove the winner.
* Register a borrowed readonly runtime skill into the calling context's
* layer. Project entries outrank runtime entries, which outrank user
* entries, within one layer. Same-name runtime entries in one layer are
* first-wins; a duplicate logs a warning and receives a no-op disposer so
* it cannot remove the winner.
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
*/
register(skill: SkillRegistration): () => void {
validateRuntimeSkill(skill)
const existing = this.runtime.get(skill.name)
if (existing !== undefined) {
const scope = scopeOf(this.ctx)
const existingLayer = scope === undefined ? this.layers.global : this.layers.peek(scope)
if (existingLayer !== undefined && existingLayer.runtime.has(skill.name)) {
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`)
return () => {}
}
@@ -390,21 +450,14 @@ export class SkillService extends Service {
invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true },
provider: skill.provider ?? RUNTIME_PROVIDER,
}
const runtime = this.runtime
const updateRevision = (): void => { this.runtimeRevision += 1 }
const invalidateCache = (): void => { this.invalidateCache() }
const dispose = this.ctx.effect(function* () {
runtime.set(definition.name, definition)
updateRevision()
invalidateCache()
yield () => {
runtime.delete(definition.name)
updateRevision()
invalidateCache()
}
}, 'skills.register()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
(layer) => {
layer.runtime.set(definition.name, definition)
return () => { layer.runtime.delete(definition.name) }
},
{ label: 'skills.register()' },
)
}
/**
@@ -412,10 +465,10 @@ export class SkillService extends Service {
* model or user invocation policy at their operational boundary. Lookup
* options and provider candidates are readonly same-process values borrowed
* throughout discovery.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.
* @returns all sorted winning summaries.
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
async list(options: SkillViewOptions = {}): Promise<SkillSummary[]> {
return (await this.snapshot(options)).skills
}
@@ -423,15 +476,14 @@ export class SkillService extends Service {
* Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.
* Incomplete observations are never cached, allowing consumers to retain last-good state and
* retry on their next request boundary.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.
* @returns sorted summaries plus discovery-completeness state.
*/
async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> {
async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot> {
const collected = await this.collect(options)
return {
skills: collected.entries
.map(entry => entry.candidate)
.map(toSummary)
skills: [...collected.entries.values()]
.map(entry => toSummary(entry.candidate))
.sort(compareSkillSummary),
complete: collected.cacheable,
}
@@ -442,14 +494,15 @@ export class SkillService extends Service {
* provider. Cancellation is rechecked after selection, including cache hits, and raced against
* loading so an uncooperative provider cannot hang the caller.
* @param name - kebab-case skill name.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @param options - view options; `scope` selects the viewing agent's layers,
* `cwd` selects workspace-sensitive skills, and `signal` cancels work.
* @returns the full skill, including body content, or `undefined`.
*/
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined> {
if (!isSkillName(name)) return undefined
const collected = await this.collect(options)
throwIfAborted(options.signal)
const match = collected.entries.find(entry => entry.candidate.name === name)
const match = collected.entries.get(name)
if (match === undefined) return undefined
const definition = await waitWithAbort(
match.provider.get(match.candidate, options),
@@ -458,25 +511,27 @@ export class SkillService extends Service {
if (definition === undefined) return undefined
validateDefinition(definition)
if (definition.name !== match.candidate.name) {
this.invalidateProvider(match.provider)
this.invalidateEntry(match)
return undefined
}
return definition
}
private async collect(options: SkillLookupOptions): Promise<CollectResult> {
private async collect(options: SkillViewOptions): Promise<CollectResult> {
throwIfAborted(options.signal)
let attempt = 1
while (true) {
const providerRevision = this.providerRevision
const runtimeRevision = this.runtimeRevision
const key = collectCacheKey(options, providerRevision, runtimeRevision)
const revision = this.revision
// The chain is part of the key rather than assumed stable: a blank-session
// recompose re-parents an existing scope without touching this registry,
// and only a chain-bearing key makes the next read see the new preset.
const key = this.collectCacheKey(options.cwd, scopeChainOf(options.scope), revision)
const cached = this.collectCache.get(key)
if (cached !== undefined) return { entries: cached, cacheable: true }
const result = await this.collectFresh(options)
throwIfAborted(options.signal)
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) {
if (revision !== this.revision) {
if (attempt < MAX_COLLECT_ATTEMPTS) {
attempt += 1
continue
@@ -494,8 +549,24 @@ export class SkillService extends Service {
}
}
private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
const collected = await this.listAllCandidates(options)
private async collectFresh(options: SkillViewOptions): Promise<CollectResult> {
// Global first, then existing chain overlays farthest ancestor first and
// the exact scope last, so the nearest layer's same-name entry replaces
// the farther ones — the tools registry's shadowing rule. Rank decides
// duplicates only within one layer.
const layers = [this.layers.global, ...this.layers.chainLayers(options.scope)]
const merged = new Map<string, IndexedCandidate>()
let cacheable = true
for (const layer of layers) {
const collected = await this.collectLayer(layer, options)
if (!collected.cacheable) cacheable = false
for (const entry of collected.entries) merged.set(entry.candidate.name, entry)
}
return { entries: merged, cacheable }
}
private async collectLayer(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> {
const collected = await this.listLayerCandidates(layer, options)
collected.entries.sort(compareIndexedCandidates)
const seen = new Set<string>()
const result: IndexedCandidate[] = []
@@ -511,21 +582,22 @@ export class SkillService extends Service {
return { entries: result, cacheable: collected.cacheable }
}
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
private async listLayerCandidates(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> {
throwIfAborted(options.signal)
const candidates: IndexedCandidate[] = []
let cacheable = true
let runtimeOrder = 0
for (const skill of [...this.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
for (const skill of [...layer.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
candidates.push({
candidate: runtimeCandidate(skill),
provider: RUNTIME_SKILL_PROVIDER,
providerOrder: -1,
localOrder: runtimeOrder,
layer,
})
runtimeOrder += 1
}
for (const { provider, order } of [...this.providers.values()]) {
for (const { provider, order } of [...layer.providers.values()]) {
let localOrder = 0
let output: unknown
try {
@@ -540,7 +612,7 @@ export class SkillService extends Service {
if (!observation.complete) cacheable = false
for (const candidate of observation.candidates) {
validateCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder })
candidates.push({ candidate, provider, providerOrder: order, localOrder, layer })
localOrder += 1
}
}
@@ -548,14 +620,29 @@ export class SkillService extends Service {
}
private invalidateCache(): void {
this.providerRevision += 1
this.revision += 1
this.collectCache.clear()
this.notifyChange()
}
private invalidateProvider(provider: SkillProvider): void {
/** Invalidate after a stale definition load, only while the exact registration that produced the entry is still live. */
private invalidateEntry(entry: IndexedCandidate): void {
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache()
if (entry.layer.providers.get(entry.provider.name)?.provider === entry.provider) this.invalidateCache()
}
private scopeId(key: ScopeKey): number {
let id = this.scopeIds.get(key)
if (id === undefined) {
id = this.nextScopeId
this.nextScopeId += 1
this.scopeIds.set(key, id)
}
return id
}
private collectCacheKey(cwd: string | undefined, chain: ScopeKey[], revision: number): string {
return JSON.stringify({ cwd, scopes: chain.map(key => this.scopeId(key)), revision })
}
/** Notify catalog observers without making their refresh work load-bearing. */
@@ -729,10 +816,6 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void {
}
}
function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
}
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return promise
throwIfAborted(signal)

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import SkillService, {
isModelInvocable,
isUserInvocable,
@@ -49,6 +50,13 @@ function registerProvider(ctx: Context, provider: SkillProvider): () => void {
return ctx.skills.registerProvider(() => provider)
}
/** The skills service as a scoped caller resolves it (scope contexts declare no inject). */
function scopedSkills(ctx: Context): SkillService {
const skills = ctx.get('skills')
if (skills === undefined) throw new Error('skills service missing')
return skills
}
describe('SkillService registry', () => {
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
const ctx = new Context()
@@ -894,6 +902,26 @@ describe('SkillService registry', () => {
await expect(ctx.skills.get('vanished-skill')).resolves.toBeUndefined()
})
it('propagates a load failure raced against an armed abort signal', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
registerProvider(ctx, {
name: 'failing-loader',
list: () => Promise.resolve([{
name: 'failing-skill',
description: 'Failing',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'failing-loader',
source: 'test',
rank: 10,
locator: 'failing',
}]),
get: () => Promise.reject(new Error('load failed')),
})
const controller = new AbortController()
await expect(ctx.skills.get('failing-skill', { signal: controller.signal })).rejects.toThrow('load failed')
})
it('contains a provider rejection whose string coercion throws', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
@@ -1076,3 +1104,168 @@ describe('renderSkillContent', () => {
expect(text).toContain('Keep </skill_content> and <tags> as-is.')
})
})
describe('SkillService scoped layers', () => {
it('files a scoped provider into its layer and merges it into that scope view only', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
registerProvider(ctx, new MemoryProvider([memorySkill('global-skill', 'Global', 100)]))
const preset = createScope(ctx, { preset: 'a' })
const presetProvider: SkillProvider = {
name: 'preset-local',
async list() {
return [{
name: 'preset-skill',
description: 'Preset',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'preset-local',
source: 'preset',
rank: 300,
locator: { content: 'Preset body.' },
}]
},
async get(candidate) {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
scopedSkills(preset.ctx).registerProvider(() => presetProvider)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['global-skill'])
const scoped = await ctx.skills.list({ scope: scopeOf(preset.ctx) })
expect(scoped.map(skill => skill.name)).toEqual(['global-skill', 'preset-skill'])
expect((await ctx.skills.get('preset-skill', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset body.')
expect(await ctx.skills.get('preset-skill')).toBeUndefined()
await preset.dispose()
})
it('lets the nearest layer win a duplicate name regardless of rank', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
registerProvider(ctx, new MemoryProvider([memorySkill('shared-name', 'Global wins ranks', 10)]))
const preset = createScope(ctx, { preset: 'shadow' })
scopedSkills(preset.ctx).registerProvider(() => ({
name: 'preset-local',
async list() {
return [{
name: 'shared-name',
description: 'Preset shadow',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'preset-local',
source: 'preset',
rank: 900,
locator: { content: 'Preset shadow body.' },
}]
},
async get(candidate: SkillCandidate) {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}))
const scoped = await ctx.skills.list({ scope: scopeOf(preset.ctx) })
expect(scoped).toHaveLength(1)
expect(scoped[0]?.description).toBe('Preset shadow')
expect((await ctx.skills.get('shared-name', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset shadow body.')
expect((await ctx.skills.list())[0]?.description).toBe('Global wins ranks')
await preset.dispose()
})
it('resolves the scope chain so an agent key inherits its preset layer and recompose follows the new parent', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const presetA = createScope(ctx, { preset: 'a' })
const presetB = createScope(ctx, { preset: 'b' })
for (const [scope, label] of [[presetA, 'a'], [presetB, 'b']] as const) {
scopedSkills(scope.ctx).register({
name: `skill-${label}`,
description: `Skill ${label}`,
source: 'preset',
content: `Body ${label}.`,
})
}
const agentKey = {}
const binding = bindScopeParent(agentKey, scopeOf(presetA.ctx) as object)
expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-a'])
// A blank-session recompose re-links the same key through its binding
// without any registry write.
binding.rebind(scopeOf(presetB.ctx) as object)
expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-b'])
await presetA.dispose()
await presetB.dispose()
})
it('scopes provider-name uniqueness per layer and reports scoped duplicates distinctly', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
registerProvider(ctx, new MemoryProvider([]))
const presetA = createScope(ctx, { preset: 'a' })
const presetB = createScope(ctx, { preset: 'b' })
scopedSkills(presetA.ctx).registerProvider(() => new MemoryProvider([memorySkill('a-only', 'A', 100)]))
scopedSkills(presetB.ctx).registerProvider(() => new MemoryProvider([memorySkill('b-only', 'B', 100)]))
expect(() => scopedSkills(presetA.ctx).registerProvider(() => new MemoryProvider([])))
.toThrow('a skill provider named "memory" is already registered in this scope')
expect((await ctx.skills.list({ scope: scopeOf(presetA.ctx) })).map(skill => skill.name)).toEqual(['a-only'])
expect((await ctx.skills.list({ scope: scopeOf(presetB.ctx) })).map(skill => skill.name)).toEqual(['b-only'])
await presetA.dispose()
await presetB.dispose()
})
it('keeps runtime duplicate handling per layer and shadows a global runtime name', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const warn = vi.fn()
ctx.logger.warn = warn as never
ctx.skills.register({ name: 'told-twice', description: 'Global runtime', source: 'runtime', content: 'Global body.' })
const preset = createScope(ctx, { preset: 'runtime' })
const disposeShadow = scopedSkills(preset.ctx).register({
name: 'told-twice',
description: 'Preset runtime',
source: 'preset',
content: 'Preset body.',
})
expect(warn).not.toHaveBeenCalled()
scopedSkills(preset.ctx).register({ name: 'told-twice', description: 'Ignored', source: 'preset', content: 'Ignored.' })
expect(warn).toHaveBeenCalledWith('runtime skill "told-twice" ignored because it is already registered')
expect((await ctx.skills.get('told-twice', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset body.')
expect((await ctx.skills.get('told-twice'))?.content).toBe('Global body.')
disposeShadow()
expect((await ctx.skills.get('told-twice', { scope: scopeOf(preset.ctx) }))?.content).toBe('Global body.')
await preset.dispose()
})
it('drops a disposed scoped registration from its scope view and notifies change', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const changes = vi.fn()
ctx.on('skills/change', changes)
const preset = createScope(ctx, { preset: 'hmr' })
const provider = new MemoryProvider([memorySkill('scoped-skill', 'Scoped', 100)])
scopedSkills(preset.ctx).registerProvider(() => provider)
expect((await ctx.skills.list({ scope: scopeOf(preset.ctx) })).map(skill => skill.name)).toEqual(['scoped-skill'])
const notified = changes.mock.calls.length
await preset.dispose()
expect(changes.mock.calls.length).toBeGreaterThan(notified)
expect(await ctx.skills.list({ scope: scopeOf(preset.ctx) })).toEqual([])
})
it('invalidates through a scoped provider control only while its exact registration is live', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const preset = createScope(ctx, { preset: 'invalidate' })
const provider = new MemoryProvider([memorySkill('watched', 'Watched', 100)])
let control: { invalidate: () => void } | undefined
const dispose = scopedSkills(preset.ctx).registerProvider((given) => {
control = given
return provider
})
const scope = scopeOf(preset.ctx)
expect((await ctx.skills.list({ scope })).map(skill => skill.name)).toEqual(['watched'])
provider.replace([memorySkill('replaced', 'Replaced', 100)])
control?.invalidate()
expect((await ctx.skills.list({ scope })).map(skill => skill.name)).toEqual(['replaced'])
dispose()
provider.replace([memorySkill('ignored', 'Ignored', 100)])
control?.invalidate()
expect(await ctx.skills.list({ scope })).toEqual([])
await preset.dispose()
})
})

View File

@@ -15,6 +15,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/scope"
},
{
"path": "../../llm/llm"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
README.md: fd2cb2dc00994a79856ad605ce126387c27a9f65
README.zh.md: 3fb76e079033ef39e475e6795cf28407e52a83cc
README.md: 704eb7eb1f611c20f76ce79190326296ff63da42
README.zh.md: 09fb5b954ea595b962f7670ce09db1ae22dce29c

View File

@@ -12,7 +12,7 @@ At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for
Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `<system-reminder>` framing cannot decide whether a republish is needed and consumers never re-parse the `<available_skills>` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; unreadable and foreign records are skipped. When the digest changes, the downstream `enter` decision receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned.
The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Identity is compared against the definition this plugin registered rather than a lookup of its own name, so the plugin works mounted globally or inside one agent's composition, where `register()` files into that agent's layer alone. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned.
`catalogDescriptionMaxLength` controls normalized catalog descriptions; rendering XML-escapes them. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle.

View File

@@ -12,7 +12,7 @@
每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name``description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `<system-reminder>` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `<available_skills>` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线不可读和外来的记录都会跳过。digest 变化时,下游 `enter` 决策会收到一条包含完整替换目录的持久用户角色消息空替换会显式停用较早的名称。如果没有目录仍然可见但历史中存在可识别目录则说明压缩compaction已将其遮蔽下一次完整观察会重新建立当前目录。提供方快照不完整时插件不会发送任何内容并会保留最后一次完整的模型视图在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
如果最初没有模型可调用 skill则省略目录如果该 agent智能体的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
如果最初没有模型可调用 skill则省略目录如果该 agent智能体的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。身份比对针对本插件所注册的那个定义,而非按自身名字回查,因此本插件既可全局挂载,也可挂在单个 agent 的组装内——在后者中 `register()` 只归档进该 agent 的分层。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
`catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。

View File

@@ -128,7 +128,9 @@ export function apply(ctx: Context, config: Config = {}): void {
if (!isSkillName(args.name)) {
throw new Error(`invalid skill name "${args.name}"`)
}
const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal }
// The agent is its own scope key, so the lookup resolves the layered
// registry exactly as this agent's composition sees it.
const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal, scope: exec.agent }
const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name)
if (!summary) {
throw new Error(`skill "${args.name}" is unknown or no longer available`)
@@ -157,11 +159,6 @@ export function apply(ctx: Context, config: Config = {}): void {
},
})
ctx.tools.register(skillTool)
const registeredSkillTool = ctx.tools.get(skillTool.name)
/* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */
if (registeredSkillTool === undefined) {
throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry')
}
// User-explicit skill invocation: a claimed user message whose first line
// starts with `/<name>` naming a user-invocable skill is a deterministic
@@ -186,7 +183,7 @@ export function apply(ctx: Context, config: Config = {}): void {
const names = invokedSkillNames(messages)
if (names.length === 0) return decision
signal.throwIfAborted()
const lookup = { cwd: agent.session.header.cwd, signal }
const lookup = { cwd: agent.session.header.cwd, signal, scope: agent }
const injections: UserMessage[] = []
for (const name of names) {
const skill = await ctx.skills.get(name, lookup)
@@ -208,6 +205,11 @@ export function apply(ctx: Context, config: Config = {}): void {
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
//
// The comparison is against the definition this plugin registered, not against
// a lookup of its own name: `register()` files into the CALLING context's
// scope, so a plugin mounted inside an agent preset registers for that agent
// alone and an unscoped lookup correctly finds nothing.
ctx.on('agent/pre-step', async (
{ agent, signal },
next,
@@ -215,9 +217,9 @@ export function apply(ctx: Context, config: Config = {}): void {
const decision = await next()
if (decision.kind === 'reject') return decision
signal.throwIfAborted()
const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool
const toolVisible = ctx.tools.get(skillTool.name, agent) === skillTool
const snapshot = toolVisible
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal })
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal, scope: agent })
: { skills: [], complete: true }
signal.throwIfAborted()
if (!snapshot.complete) return decision

View File

@@ -640,6 +640,43 @@ describe('dsh-tool-skill', () => {
expect(JSON.stringify(result.content)).not.toContain('First body.')
})
it('resolves the layered registry as the calling agent sees it', async () => {
const home = await tempDir('tool-scoped-layer')
const ctx = await setup(home)
const { agent, scope } = await mintAgentScope(ctx, '/workspace/scoped')
const scopedSkills = scope.ctx.get('skills')
if (scopedSkills === undefined) throw new Error('skills service missing')
scopedSkills.register({
name: 'preset-only-skill',
description: 'Visible to the scoped agent alone',
source: 'preset',
content: 'Preset-only body.',
})
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('preset-only-skill')
expect(JSON.stringify(await composePrefix(ctx, '/workspace/other'))).not.toContain('preset-only-skill')
const scoped = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('scoped-load'),
name: 'skill',
arguments: { name: 'preset-only-skill' },
agent,
})
expect(scoped.isError).toBe(false)
expect(JSON.stringify(scoped.content)).toContain('Preset-only body.')
const foreign = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('foreign-load'),
name: 'skill',
arguments: { name: 'preset-only-skill' },
agent: agentForCwd('/workspace/other'),
})
expect(foreign.isError).toBe(true)
await scope.dispose()
})
it('retains the last-good catalog while any provider discovery is incomplete', async () => {
const home = await tempDir('tool-incomplete-catalog')
const ctx = await setup(home)