Use this skill when creating, reviewing, or refactoring a WrongStack plugin
in `packages/plugins/`. Covers the Plugin interface, tool registration, config
schema, the H1 audit pattern (teardown + health), PluginAPI extension for host
data, and the entry-point registration steps (package.json and index.ts).
Triggers: user says "new plugin", "add a plugin", "plugin teardown", "plugin
health", "register a tool", "PluginAPI extension".
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Use this skill when creating, reviewing, or refactoring a WrongStack plugin
in `packages/plugins/`. Covers the Plugin interface, tool registration, config
schema, the H1 audit pattern (teardown + health), PluginAPI extension for host
data, and the entry-point registration steps (package.json and index.ts).
Triggers: user says "new plugin", "add a plugin", "plugin teardown", "plugin
health", "register a tool", "PluginAPI extension".
Guides the creation and maintenance of first-party plugins in
packages/plugins/. A plugin is a TypeScript module that implements
the Plugin interface from @wrongstack/core, registering tools,
hooks, slash commands, or pipelines into the agent's runtime.
There are currently 21 official plugins in the suite:
Every plugin must implement teardown() and health(). Even
stateless plugins (shell-check, semver-bump) add both — the teardown
logs a completion line with per-session counters, and health()
reports ok: true + counters for /diag plugins. This is the H1
audit pattern (see below).
State at module scope, not in the setup() closure. The Plugin
interface does not thread state from setup() → teardown(). If
teardown needs to clean up a resource (timer, watcher, counter),
the reference must live at module scope. State in the setup
closure is unreachable from teardown and leaks on reload.
setup() is idempotent. It must zero/clear state before
re-initializing. Calling setup() twice (e.g. across a hot-reload)
leaves a clean slate, not accumulated state.
teardown() never deletes on-disk state. File-based plugins
(todo-tracker) leave the file in place — the user may return. Only
in-memory counters and resource handles (timers, watchers) are
cleaned up.
Tool names follow snake_case. Plugin-level tools registered via
api.tools.register() must be unique across the suite. The built-in
tools (bash, write, read, edit, fetch, search, json,
todo, git, etc.) are always present; don't collide.
apiVersion must satisfy ^0.1 (current kernel API = 0.1.10).
Bump only when the PluginAPI surface breaks. Additive changes (new
optional fields on PluginAPI) do NOT require a bump.
Config goes under config.extensions['<plugin-name>'], not
config.plugins. The loader's buildPluginOptions merges both
surfaces; plugins read from api.config.extensions.
The Plugin interface
importtype { Plugin } from'@wrongstack/core';
constplugin: Plugin = {
name: 'my-plugin',
version: '0.1.0',
description: 'One-line summary for wstack plugins list',
apiVersion: '^0.1.10',
capabilities: { tools: true }, // hints for /diag// Optional: JSON Schema for config validationdefaultConfig: { enabled: true },
configSchema: {
type: 'object',
properties: {
enabled: { type: 'boolean', default: true },
},
},
// Called by the host to activate the plugin.setup(api) { /* register tools, hooks, events */ },
// Called by the host during unload. Same api instance as setup.teardown(api) { /* clear state, release resources, log */ },
// Called by /diag plugins. Return ok + message + counters.asynchealth() {
return { ok: true, message: 'healthy', : };
},
};
plugin;
H1 audit pattern
After the 2026-06-03 audit found that several plugins leaked resources
on reload (timers, chokidar watchers, in-memory caches unreachable from
teardown), the following lifecycle pattern was formalized. All 10
plugins follow it.
1. State at module scope
// ✅ Module scope — teardown can reach thisconst state = {
invocationCount: 0,
timers: newMap<string, NodeJS.Timeout>(),
lastRun: nullasnull | { when: string; result: string },
};
// ❌ NEVER — setup closure, unreachable from teardownsetup(api) {
const timers = newMap(); // LEAKS on reload
}
2. Idempotent setup
setup(api) {
// Clear everything first, then re-init from config.
state.invocationCount = 0;
for (const t of state.timers.values()) clearTimeout(t);
state.timers.clear();
state.lastRun = null;
// Now register tools, apply config, subscribe to events.
api.tools.register({ /* ... */ });
}
3. Teardown releases resources
teardown(api) {
const count = state.invocationCount;
state.invocationCount = 0;
state.lastRun = null;
// Release every resource that was acquired in setup().for (const t of state.timers.values()) clearTimeout(t);
state.timers.clear();
api.log.info('my-plugin: teardown complete', { invocations: count });
}
4. Health reports per-session visibility
asynchealth() {
return {
ok: true,
message: state.lastRun === null
? 'my-plugin: no calls yet'
: `my-plugin: last call at ${state.lastRun.when}`,
invocationCount: state.invocationCount,
lastRun: state.lastRun,
};
}
Tool registration
api.tools.register({
name: 'my_tool',
description: 'What this tool does. Include when to use and what it returns.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path' },
},
required: ['path'],
},
permission: 'auto', // 'auto' | 'confirm'mutating: false, // does it change external state?category: 'Project',
asyncexecute(input: Record<string, unknown>) {
const path = input['path'] asstring;
// ... do the work ...return { ok: true, path, result: '...' };
},
});
Permission levels
Permission
When
auto
Safe operations (read, list, query). No user confirmation.
confirm
Destructive or side-effecting operations (write, commit, delete). User must approve.
If the plugin declares configSchema, the loader validates the
options section before calling setup and rejects the plugin with a
clear error on failure.
additionalContext: string — extra context folded back to the model
PluginAPI extension (cross-package host data)
When a plugin needs host data not yet on PluginAPI (e.g.
modelsRegistry, projectDir), extend the surface in three steps:
packages/core/src/types/plugin.ts — add the optional field to PluginAPI
packages/core/src/plugin/api.ts — add to PluginAPIInit + DefaultPluginAPI
packages/cli/src/wiring/plugins.ts — destructure + forward in setupPlugins
Precedent: commit 9bed619f added modelsRegistry?: ModelsRegistry for
cost-tracker's pricing hydration.
Entry-point registration
After writing src/<name>/index.ts, wire it into two package files. The
central scripts/build-package.mjs driver discovers plugin entry points from
the exports map automatically.
tests/<name>-exec.test.ts — integration tests (real filesystem,
real CLI tools if applicable)
For the H1 pattern, extend tests/plugin-teardown.test.ts with a
describe('<name>') block covering:
teardown logs a completion line and does not throw
health() reports ok + non-empty message
teardown zeros counters
Anti-patterns
State in setup closure — leaks on reload. Always module scope.
Missing teardown — /diag plugins shows a gap; reload leaks.
Missing health() — operator can't confirm the plugin is alive.
Tool name collision — read, write, bash, etc. are built-in.
Deleting on-disk state in teardown — the user may return.
Blocking setup with async hydration — use void (async () => { ... })()
for fire-and-forget; let the first call fall through to fallback if
the async hasn't completed yet.
Not lowercasing model/config keys — case-insensitive lookup is
the convention; model.toLowerCase() everywhere.
Workflow
Create the plugin directory: src/<name>/index.ts
Write the Plugin object: name, version, apiVersion, setup, teardown, health
Register tools/hooks in setup()
Add state + teardown + health following the H1 pattern
Run verification: pnpm --filter @wrongstack/plugins test + pnpm --filter @wrongstack/plugins typecheck + pnpm --filter @wrongstack/plugins build
Update src/index.ts doc comment — bump the plugin count
Out of scope
Don't put state in the setup() closure. State must live at module scope; the Plugin interface does not thread state from setup() to teardown(). Closure state leaks on reload.
Don't ship a plugin without teardown() and health(). Even stateless plugins add both. The H1 audit pattern is the floor; /diag plugins exposes the gap.
Don't make setup() non-idempotent. Calling setup() twice must leave a clean slate. Hot-reload must not accumulate state.
Don't delete on-disk state in teardown(). File-based plugins leave the file in place. Only in-memory counters and resource handles (timers, watchers) are cleaned.
Don't collide with built-in tool names.read, write, bash, edit, fetch, search, json, todo, git are reserved. Pick unique names; api.tools.register() enforces uniqueness.
Don't bump apiVersion for additive changes. New optional fields on PluginAPI don't break the contract. Bump only when the surface breaks.
Don't read config from config.plugins. Config options go under config.extensions['<plugin-name>']. The loader's buildPluginOptions merges both, but the convention is extensions.
Don't block setup() with async hydration. Use void (async () => { ... })() for fire-and-forget. The first call should fall through to fallback if the async hasn't completed.
Don't skip the test files.tests/<name>.test.ts (unit) and tests/<name>-exec.test.ts (integration) are required. Plugins without tests rot fast.
Don't lower-case-skip the model and config keys. Case-insensitive lookup is the convention; model.toLowerCase() everywhere.
Before returning
name, version, apiVersion: '^0.1.x' (current), description, capabilities set
Module-scope state, never in setup() closure
setup() idempotent: clears state before re-initializing
teardown() releases every resource acquired in setup(), never deletes on-disk state