mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
feat(extensions): add owned reviewed skill root SDK
ctx.skills.registerRoot proposes bounded bundle-relative roots through the existing owned registration lifecycle; disposal withdraws the exact handle. The host only proposes Native-reviewed roots to Rust and never reads Skill bodies itself. Add a Native-only integration fixture and real-host admission/rollback cases. Source validation: Node 22.20.0 focused source tests 6 passed, 0 failed; Bun 1.4.0 focused source tests 6 passed, 0 failed; strict TypeScript typecheck and diff whitespace check passed. Host dist and real-host tests await the coordinated parent build. Rust admission/catalog consumer slice is in progress and not yet compiled; no release or parity claim.
This commit is contained in:
1 parent
c03bbf944a
commit
9bbd13814c
10 files changed
+281
-11
No files matched your search
@@ -159,7 +159,7 @@ export const SHAPES: { readonly [name: string]: Shape } = {
|
||||
},
|
||||
RegisterParams: {
|
||||
strict: true,
|
||||
required: { owner: { ref: 'OwnerRef' }, kind: { enum: ['tool', 'command', 'hook', 'prompt_section'] }, spec: { ref: 'RegisterSpecWire' } },
|
||||
required: { owner: { ref: 'OwnerRef' }, kind: { enum: ['tool', 'command', 'hook', 'prompt_section', 'skill_root'] }, spec: { ref: 'RegisterSpecWire' } },
|
||||
optional: {},
|
||||
},
|
||||
RegisterSpecWire: {
|
||||
@@ -312,7 +312,7 @@ export interface ProtocolRange {
|
||||
max: number
|
||||
}
|
||||
|
||||
export type RegisterKind = 'tool' | 'command' | 'hook' | 'prompt_section'
|
||||
export type RegisterKind = 'tool' | 'command' | 'hook' | 'prompt_section' | 'skill_root'
|
||||
|
||||
export interface RegisterParams {
|
||||
owner: OwnerRef
|
||||
|
||||
@@ -200,8 +200,8 @@ export function validateMessage(
|
||||
? 'a tool registration has no `spec.argument_hint`'
|
||||
: kind === 'command' && spec.input_schema != null
|
||||
? 'a command registration has no `spec.input_schema`'
|
||||
: (kind === 'hook' || kind === 'prompt_section') && (spec.input_schema != null || spec.argument_hint != null)
|
||||
? 'a hook or prompt registration has no input schema or argument hint'
|
||||
: (kind === 'hook' || kind === 'prompt_section' || kind === 'skill_root') && (spec.input_schema != null || spec.argument_hint != null)
|
||||
? 'a hook, prompt or skill root registration has no input schema or argument hint'
|
||||
: undefined
|
||||
if (reason !== undefined) throw new ProtocolError(`${method}: ${reason}`)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { OwnedRegistrations } from './shims/owned.ts'
|
||||
import { hookExecution, hookVerdict, type LocalHook } from './shims/hooks.ts'
|
||||
import { PromptSections, definePromptService, type LocalPromptSection } from './shims/prompt.ts'
|
||||
import { createStorage, type PluginStorage } from './shims/storage.ts'
|
||||
import { SkillRoots, defineSkillsService, type LocalSkillRoot } from './shims/skills.ts'
|
||||
import { ownerTier, type HostTier } from './tier.ts'
|
||||
import {
|
||||
commandSpec,
|
||||
@@ -70,7 +71,7 @@ export const REFUSED_SERVICES = new Set([
|
||||
])
|
||||
|
||||
/** Services the root provides; `inject` of anything else fails activation. */
|
||||
const PROVIDED_SERVICES = new Set(['tools', 'commands', 'prompt', 'storage', 'logger', 'events', 'reflect', 'registry'])
|
||||
const PROVIDED_SERVICES = new Set(['tools', 'commands', 'prompt', 'storage', 'skills', 'logger', 'events', 'reflect', 'registry'])
|
||||
|
||||
const ACTIVATE_DEADLINE_MS = 5_000
|
||||
const DISPOSE_DEADLINE_MS = 2_000
|
||||
@@ -86,6 +87,7 @@ export interface OwnerRecord {
|
||||
commands: Map<number, LocalCommand<OwnerRecord>>
|
||||
hooks: Map<number, LocalHook<OwnerRecord>>
|
||||
promptSections: Map<number, LocalPromptSection<OwnerRecord>>
|
||||
skillRoots: Map<number, LocalSkillRoot<OwnerRecord>>
|
||||
storage?: PluginStorage
|
||||
warnedAllow?: boolean
|
||||
/** Entry modules activated under this owner so far (a plugin may declare several). */
|
||||
@@ -127,6 +129,7 @@ export class HostRoot {
|
||||
private readonly commandRegistrations: OwnedRegistrations<OwnerRecord, LocalCommand<OwnerRecord>>
|
||||
private readonly hookRegistrations: OwnedRegistrations<OwnerRecord, LocalHook<OwnerRecord>>
|
||||
private readonly promptSections: PromptSections<OwnerRecord>
|
||||
private readonly skillRoots: SkillRoots<OwnerRecord>
|
||||
|
||||
constructor(
|
||||
private readonly rpc: RpcPeer,
|
||||
@@ -152,6 +155,8 @@ export class HostRoot {
|
||||
(message, owner) => this.log('warn', message, owner))
|
||||
this.promptSections = new PromptSections(rpc, (owner) => owner.promptSections,
|
||||
(message, owner) => this.log('warn', message, owner))
|
||||
this.skillRoots = new SkillRoots(rpc, (owner) => owner.skillRoots,
|
||||
(message, owner) => this.log('warn', message, owner))
|
||||
|
||||
// Cordis already owns listener effects and teardown. Intercept this one
|
||||
// event using its supported extension point instead of replacing ctx.on.
|
||||
@@ -224,6 +229,7 @@ export class HostRoot {
|
||||
ownerOf: (ctx) => ctx[OWNER],
|
||||
promptSections: this.promptSections,
|
||||
})
|
||||
const SkillsShim = defineSkillsService<OwnerRecord>({ ownerOf: (ctx) => ctx[OWNER], skillRoots: this.skillRoots })
|
||||
class StorageShim extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'storage') }
|
||||
private api(): PluginStorage {
|
||||
@@ -244,10 +250,12 @@ export class HostRoot {
|
||||
shimClasses.set('commands', CommandsShim)
|
||||
shimClasses.set('prompt', PromptShim)
|
||||
shimClasses.set('storage', StorageShim)
|
||||
shimClasses.set('skills', SkillsShim)
|
||||
root.plugin(ToolsShim)
|
||||
root.plugin(CommandsShim)
|
||||
root.plugin(PromptShim)
|
||||
root.plugin(StorageShim)
|
||||
root.plugin(SkillsShim)
|
||||
}
|
||||
|
||||
log(level: string, msg: string, owner?: OwnerRecord) {
|
||||
@@ -312,6 +320,7 @@ export class HostRoot {
|
||||
commands: new Map(),
|
||||
hooks: new Map(),
|
||||
promptSections: new Map(),
|
||||
skillRoots: new Map(),
|
||||
entries: new Set(),
|
||||
...(params.data_dir === undefined ? {} : { dataDir: params.data_dir }),
|
||||
state: 'activating',
|
||||
@@ -354,6 +363,7 @@ export class HostRoot {
|
||||
owner.state = 'failed'
|
||||
// All-or-nothing: dispose the partial fiber, rolling back every registration.
|
||||
await this.disposeOwner(owner).catch(() => undefined)
|
||||
this.skillRoots.forget(owner)
|
||||
this.owners.delete(key)
|
||||
return { status: 'failed', diagnostic: describeError(error) }
|
||||
}
|
||||
@@ -385,6 +395,7 @@ export class HostRoot {
|
||||
...[...owner.commands.values()].map((command) => `command:${command.name}`),
|
||||
...[...owner.hooks.values()].map((hook) => `hook:${hook.name}`),
|
||||
...[...owner.promptSections.values()].map((section) => `prompt_section:${section.name}`),
|
||||
...[...owner.skillRoots.values()].map((root) => `skill_root:${root.name}`),
|
||||
]
|
||||
for (const fiber of owner.fibers) {
|
||||
for (const effect of fiber.getEffects?.() ?? []) leaked.push(`effect:${effect.label}`)
|
||||
@@ -393,6 +404,7 @@ export class HostRoot {
|
||||
this.commandRegistrations.forget(owner)
|
||||
this.hookRegistrations.forget(owner)
|
||||
this.promptSections.forget(owner)
|
||||
this.skillRoots.forget(owner)
|
||||
this.owners.delete(ref.owner_token)
|
||||
return { disposed, leaked }
|
||||
}
|
||||
|
||||
@@ -40,13 +40,23 @@ export class OwnedRegistrations<O extends OwnerBase, T extends OwnedEntry<O>> {
|
||||
/** Admitted entries by core handle. */
|
||||
readonly byHandle = new Map<number, T>()
|
||||
|
||||
private readonly rpc: RpcPeer
|
||||
private readonly kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'skill_root'
|
||||
/** The owner's own index of this kind, for the leak report at deactivation. */
|
||||
private readonly ownedBy: (owner: O) => Map<number, T>
|
||||
private readonly warn: (message: string, owner: O) => void
|
||||
|
||||
constructor(
|
||||
private readonly rpc: RpcPeer,
|
||||
private readonly kind: 'tool' | 'command' | 'hook' | 'prompt_section',
|
||||
/** The owner's own index of this kind, for the leak report at deactivation. */
|
||||
private readonly ownedBy: (owner: O) => Map<number, T>,
|
||||
private readonly warn: (message: string, owner: O) => void,
|
||||
) {}
|
||||
rpc: RpcPeer,
|
||||
kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'skill_root',
|
||||
ownedBy: (owner: O) => Map<number, T>,
|
||||
warn: (message: string, owner: O) => void,
|
||||
) {
|
||||
this.rpc = rpc
|
||||
this.kind = kind
|
||||
this.ownedBy = ownedBy
|
||||
this.warn = warn
|
||||
}
|
||||
|
||||
/** Called inside the owner's effect; returns the effect's cleanup. */
|
||||
add(entry: T, spec: RegisterSpec): () => void {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Reviewed bundle roots contribute to Rust's existing skill catalog. */
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { RpcPeer } from '../rpc.ts'
|
||||
import { OwnedRegistrations, type OwnedEntry, type OwnerBase } from './owned.ts'
|
||||
|
||||
export const MAX_SKILL_ROOTS_PER_OWNER = 8
|
||||
export const MAX_SKILL_ROOTS_PER_HOST = 64
|
||||
export interface SkillRootDefinition { readonly path: string }
|
||||
export interface LocalSkillRoot<O extends OwnerBase = OwnerBase> extends OwnedEntry<O> {}
|
||||
|
||||
export function normalizeSkillRoot(value: unknown): SkillRootDefinition {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)
|
||||
|| Object.keys(value).some(key => key !== 'path')) throw new TypeError('skill root supports only path')
|
||||
const { path } = value as { path?: unknown }
|
||||
if (typeof path !== 'string' || !path || Buffer.byteLength(path, 'utf8') > 512
|
||||
|| /[\\:\u0000-\u001f\u007f-\u009f]/u.test(path)
|
||||
|| path.split('/').some(part => !part || part === '.' || part === '..')) {
|
||||
throw new TypeError('skill root path must be a bounded bundle-relative path with normal slash-separated components')
|
||||
}
|
||||
return Object.freeze({ path })
|
||||
}
|
||||
|
||||
export class SkillRoots<O extends OwnerBase> {
|
||||
private readonly registrations: OwnedRegistrations<O, LocalSkillRoot<O>>
|
||||
private readonly owners = new Map<O, Map<string, () => void>>()
|
||||
private count = 0
|
||||
constructor(rpc: RpcPeer, ownedBy: (owner: O) => Map<number, LocalSkillRoot<O>>, warn: (message: string, owner: O) => void) {
|
||||
this.registrations = new OwnedRegistrations(rpc, 'skill_root', ownedBy, warn)
|
||||
}
|
||||
register(owner: O, definition: SkillRootDefinition): () => void {
|
||||
if (owner.state !== 'activating' && owner.state !== 'active') throw new Error('skill owner is not live')
|
||||
const { path } = normalizeSkillRoot(definition)
|
||||
const roots = this.owners.get(owner) ?? new Map<string, () => void>()
|
||||
if (roots.has(path)) throw new Error('skill root is already registered; dispose it before registering it again')
|
||||
if (roots.size >= MAX_SKILL_ROOTS_PER_OWNER || this.count >= MAX_SKILL_ROOTS_PER_HOST) throw new RangeError('skill root owner or host registration limit reached')
|
||||
const undo = this.registrations.add({ owner, name: path, disposed: false }, { name: path, description: '' })
|
||||
const dispose = () => {
|
||||
if (roots.get(path) !== dispose) return
|
||||
roots.delete(path)
|
||||
this.count--
|
||||
if (!roots.size) this.owners.delete(owner)
|
||||
undo()
|
||||
}
|
||||
roots.set(path, dispose)
|
||||
this.owners.set(owner, roots)
|
||||
this.count++
|
||||
return dispose
|
||||
}
|
||||
forget(owner: O) {
|
||||
for (const dispose of [...(this.owners.get(owner)?.values() ?? [])]) dispose()
|
||||
this.registrations.forget(owner)
|
||||
}
|
||||
}
|
||||
|
||||
export function defineSkillsService<O extends OwnerBase>(host: { ownerOf(ctx: any): O | undefined, skillRoots: SkillRoots<O> }) {
|
||||
class SkillsShim extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'skills') }
|
||||
registerRoot(definition: SkillRootDefinition): () => void {
|
||||
const ctx: any = this.ctx
|
||||
const owner = host.ownerOf(ctx)
|
||||
if (!owner) throw new Error('skills.registerRoot called outside an extension owner')
|
||||
const root = normalizeSkillRoot(definition)
|
||||
return ctx.effect(() => host.skillRoots.register(owner, root), `skills.registerRoot(${JSON.stringify(root.path)})`)
|
||||
}
|
||||
}
|
||||
Object.freeze(SkillsShim.prototype)
|
||||
return SkillsShim
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { activate, startHost } from './harness.mjs'
|
||||
|
||||
function plugin(t, body) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'cw-skill-root-'))
|
||||
const entry = join(directory, 'index.mjs')
|
||||
writeFileSync(entry, `export const inject = ['skills']\nexport function apply(ctx) {\n${body}\n}\n`)
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }))
|
||||
return entry
|
||||
}
|
||||
|
||||
test('real host admits detached skill root proposals and disposes only their owner', async (t) => {
|
||||
const host = await startHost()
|
||||
t.after(() => host.stop())
|
||||
const entry = plugin(t, `const root = { path: 'profiles/review-skills' }; ctx.skills.registerRoot(root); root.path = '../changed'`)
|
||||
const a = await activate(host, 'skill-a', entry), b = await activate(host, 'skill-b', entry)
|
||||
assert.equal(a.result.status, 'ok'); assert.equal(b.result.status, 'ok')
|
||||
const registrations = host.registry.filter(item => item.op === 'register' && item.kind === 'skill_root')
|
||||
assert.equal(registrations.length, 2)
|
||||
assert.deepEqual(registrations[0].spec, { name: 'profiles/review-skills', description: '' })
|
||||
assert.deepEqual(await host.call('ext/deactivate', { owner: a.ref }), { disposed: true, leaked: [] })
|
||||
assert.ok(host.registry.some(item => item.op === 'unregister' && item.handle === registrations[0].handle))
|
||||
assert.ok(!host.registry.some(item => item.op === 'unregister' && item.handle === registrations[1].handle))
|
||||
})
|
||||
|
||||
test('invalid and duplicate skill roots fail activation and roll back proposals', async (t) => {
|
||||
const host = await startHost()
|
||||
t.after(() => host.stop())
|
||||
const cases = [
|
||||
[`ctx.skills.registerRoot({ path: '../outside' })`, /bundle-relative/],
|
||||
[`ctx.skills.registerRoot({ path: 'skills', watch: true })`, /only path/],
|
||||
[`ctx.skills.registerRoot({ path: 'skills' }); ctx.skills.registerRoot({ path: 'skills' })`, /already registered/],
|
||||
]
|
||||
for (const [index, [body, reason]] of cases.entries()) {
|
||||
const before = host.registry.length
|
||||
const { result } = await activate(host, `bad-root-${index}`, plugin(t, body))
|
||||
assert.equal(result.status, 'failed'); assert.match(result.diagnostic, reason)
|
||||
for (const item of host.registry.slice(before).filter(item => item.op === 'register')) {
|
||||
if (!host.registry.some(later => later.op === 'unregister' && later.handle === item.handle)) {
|
||||
await host.waitFor(message => message.method === 'registry/unregister' && message.params.handle === item.handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('real host preserves Rust root admission refusal in activation result', async (t) => {
|
||||
const host = await startHost({ admit: () => ({ refused: 'root is outside the reviewed inventory' }) })
|
||||
t.after(() => host.stop())
|
||||
const { result } = await activate(host, 'refused-root', plugin(t, `ctx.skills.registerRoot({ path: 'skills' })`))
|
||||
assert.equal(result.status, 'failed')
|
||||
assert.match(result.diagnostic, /outside the reviewed inventory/)
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { SkillRoots, normalizeSkillRoot } from '../src/shims/skills.ts'
|
||||
|
||||
function fixture({ deferred = false, refused } = {}) {
|
||||
const calls = [], pending = []
|
||||
let next = 0
|
||||
const rpc = { request(method, params) {
|
||||
calls.push({ method, params })
|
||||
if (method === 'registry/unregister') return Promise.resolve({})
|
||||
const result = refused ? { refused } : { handle: ++next }
|
||||
if (!deferred) return Promise.resolve(result)
|
||||
return new Promise(resolve => pending.push(() => resolve(result)))
|
||||
} }
|
||||
const roots = new SkillRoots(rpc, owner => owner.roots, () => {})
|
||||
const owner = (id) => ({ ref: { plugin_id: id, generation: 1, owner_token: id }, state: 'active', pendingRegistrations: new Set(), refusals: [], roots: new Map() })
|
||||
return { roots, calls, pending, owner }
|
||||
}
|
||||
const settled = async owner => Promise.all([...owner.pendingRegistrations])
|
||||
|
||||
test('skill roots snapshot only bounded clean bundle-relative paths', () => {
|
||||
const input = { path: 'profiles/review-skills' }
|
||||
const normalized = normalizeSkillRoot(input)
|
||||
input.path = '../outside'
|
||||
assert.deepEqual(normalized, { path: 'profiles/review-skills' })
|
||||
assert.ok(Object.isFrozen(normalized))
|
||||
for (const path of ['', '/absolute', '../outside', './same', 'a/../b', 'a//b', 'a/', 'C:/disk', 'a\\b', 'a\u001bb', '界'.repeat(171)]) {
|
||||
assert.throws(() => normalizeSkillRoot({ path }), /bounded bundle-relative/)
|
||||
}
|
||||
for (const value of [null, [], 'path', { path: 'good', priority: 1 }]) assert.throws(() => normalizeSkillRoot(value))
|
||||
})
|
||||
|
||||
test('owned skill roots dispose exact handles without crossing equal-path owners', async () => {
|
||||
const { roots, calls, owner } = fixture()
|
||||
const a = owner('a'), b = owner('b')
|
||||
const undo = roots.register(a, { path: 'profiles/skills' })
|
||||
roots.register(b, { path: 'profiles/skills' })
|
||||
await Promise.all([settled(a), settled(b)])
|
||||
assert.deepEqual(calls.filter(call => call.method === 'registry/register').map(call => call.params.spec), [
|
||||
{ name: 'profiles/skills', description: '' }, { name: 'profiles/skills', description: '' },
|
||||
])
|
||||
undo(); undo()
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1])
|
||||
assert.equal(b.roots.size, 1)
|
||||
roots.forget(b)
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1, 2])
|
||||
})
|
||||
|
||||
test('pending roots count toward limits and disposal before admission cannot remove replacement', async () => {
|
||||
const { roots, calls, pending, owner } = fixture({ deferred: true })
|
||||
const a = owner('a')
|
||||
const old = roots.register(a, { path: 'skills' })
|
||||
assert.throws(() => roots.register(a, { path: 'skills' }), /already registered/)
|
||||
old()
|
||||
roots.register(a, { path: 'skills' })
|
||||
for (let i = 0; i < 7; i++) roots.register(a, { path: `s${i}` })
|
||||
assert.throws(() => roots.register(a, { path: 'ninth' }), /limit/)
|
||||
pending.forEach(resolve => resolve())
|
||||
await settled(a)
|
||||
assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1))
|
||||
assert.ok(!calls.some(call => call.method === 'registry/unregister' && call.params.handle === 2))
|
||||
roots.forget(a)
|
||||
roots.register(a, { path: 'after-disposal' })
|
||||
pending.at(-1)()
|
||||
await settled(a)
|
||||
assert.equal(a.roots.size, 1)
|
||||
})
|
||||
|
||||
test('host root cap includes multiple owners and recovers after owner teardown', async () => {
|
||||
const { roots, owner } = fixture()
|
||||
const owners = Array.from({ length: 8 }, (_, i) => owner(String(i)))
|
||||
for (const current of owners) for (let i = 0; i < 8; i++) roots.register(current, { path: `s${i}` })
|
||||
assert.throws(() => roots.register(owner('overflow'), { path: 's' }), /limit/)
|
||||
await Promise.all(owners.map(settled))
|
||||
roots.forget(owners[0])
|
||||
const replacement = owner('replacement')
|
||||
roots.register(replacement, { path: 's' })
|
||||
await settled(replacement)
|
||||
assert.equal(replacement.roots.size, 1)
|
||||
})
|
||||
|
||||
test('revoked owner cannot register and a late admitted root is withdrawn', async () => {
|
||||
const { roots, calls, pending, owner } = fixture({ deferred: true })
|
||||
const a = owner('a')
|
||||
roots.register(a, { path: 'skills' })
|
||||
a.state = 'disposed'
|
||||
roots.forget(a)
|
||||
assert.throws(() => roots.register(a, { path: 'late' }), /not live/)
|
||||
pending.forEach(resolve => resolve())
|
||||
await settled(a)
|
||||
assert.equal(a.roots.size, 0)
|
||||
assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1))
|
||||
})
|
||||
|
||||
test('core refusal is preserved and local reservations retire with owner', async () => {
|
||||
const { roots, owner } = fixture({ refused: 'reviewed bundle was changed' })
|
||||
const a = owner('a')
|
||||
roots.register(a, { path: 'skills' })
|
||||
await settled(a)
|
||||
assert.match(a.refusals[0], /skill_root.*reviewed bundle was changed/)
|
||||
assert.equal(a.roots.size, 0)
|
||||
roots.forget(a)
|
||||
roots.register(a, { path: 'skills' })
|
||||
await settled(a)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export const inject = ['skills']
|
||||
export function apply(ctx: any) {
|
||||
ctx.skills.registerRoot({ path: 'profiles/review-skills' })
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/plugin.json",
|
||||
"name": "skills-root",
|
||||
"version": "0.1.0",
|
||||
"description": "Test fixture: Native-reviewed disposable Skill roots reuse the existing catalog.",
|
||||
"license": "MIT",
|
||||
"extensions": { "net.codewhale": { "native": { "path": "index.mts" } } }
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: quick-check
|
||||
description: Check one focused fact before making a claim
|
||||
---
|
||||
Inspect the exact source and report the verification boundary.
|
||||
Reference in new issue
Block a user