Plugin authoring standards
Conventions for naming, versioning, file layout, peer dependencies, license, and deprecation of plugins built against @pleach/core. Lint-enforced where mechanical, doc-recommended where judgment matters.
These standards are for plugin authors — what to do when
building a plugin against @pleach/core. They sit between the
contract surface (Plugin contract) and
the multi-file authoring layer (Plugin
bundles).
Standards split into three classes by enforcement:
| Class | Rules | Mechanism |
|---|---|---|
| Lint-enforceable | S1, S3 | @pleach/core/lint — doc-only at 1.0.0; warn at 1.1.0; error on novel at 1.2.0 |
| Wizard-enforced | S4, S6 | npx pleach init plugin scaffolds standards-compliant defaults |
| Doc-recommended | S2, S5, S7 | Convention; reviewers + authors apply judgment |
The retirement cadence (1.0.0 → 1.1.0 warn → 1.2.0 error)
matches audit:harness-plugin-deprecated-usage's
existing pattern, so existing plugins aren't surprised by
strict enforcement on a minor bump.
S1 — Identity discipline
Rule: name and version on every plugin construction
call MUST be string literals at construction time.
// ✓ Acceptable
const plugin = definePleachPlugin({
name: "corpus-safety",
version: "1.0.0",
capabilities: {/* ... */},
});
const composed = composePlugin(
{ name: "corpus-safety", version: "1.0.0" },
promptsFacet,
safetyFacet,
);// ✗ Rejected — runtime-computed name
import pkg from "../package.json";
const plugin = definePleachPlugin({
name: pkg.name, // not a string literal
version: pkg.version, // not a string literal
capabilities: {/* ... */},
});
// ✗ Rejected — env-derived name
const NAME = process.env.PLUGIN_NAME ?? "corpus-safety";
const plugin = definePleachPlugin({
name: NAME, // not a string literal
version: "1.0.0",
capabilities: {/* ... */},
});Why literals:
- Replay determinism — runtime-computed identity drifts across replay runs (different
process.env, differentpackage.jsonsnapshot) and silently breaks@pleach/replay's diff harness. - Tree-shaking — bundlers prove plugin identity from string literals; computed values defeat shaking.
- Tooling —
audit:plugin-contract-completenessand the lint rules below inspect literals at parse time; they have no view into runtime values.
Lint rule: @pleach/core/lint:plugin-identity-literal.
S2 — Semver discipline
Plugins follow the same semver as packages but with plugin-specific edge cases. Where this table conflicts with Versioning, this table wins for plugin authors.
| Change | Bump |
|---|---|
| Remove a hook contribution (consumers rely on it via runtime behavior) | MAJOR |
Rename plugin name field | MAJOR |
Bump required @pleach/core peer range floor | MAJOR |
| Drop a facet (e.g., previously safety + tools, now safety-only) | MAJOR |
| Add a hook contribution (additive at runtime, registration unaffected) | MINOR |
| Add a new facet (e.g., previously prompts-only, now adds safety) | MINOR |
| Add an optional field to a contribution shape | MINOR |
| Bug fix within existing hook return values | PATCH |
| Perf improvement inside existing contributions, no observable shape change | PATCH |
| Documentation-only changes | PATCH |
| First public release | 0.1.0 |
| Stable, contract-bound | 1.0.0 |
When in doubt, bump higher. A surprised consumer recovering from a too-high bump (lockfile update, version pin) is cheaper than a silent broken consumer from a too-low bump.
The package-level table in Versioning treats "added function" as MINOR. That reduces to the same intuition for plugins: anything additive that consumers can opt into is MINOR; anything that breaks an existing consumer is MAJOR.
S3 — Peer dependency on @pleach/core
Rule: declare @pleach/core as peerDependencies, not dependencies.
// ✓ Acceptable — package.json
{
"peerDependencies": {
"@pleach/core": "^1.0.0"
},
"devDependencies": {
"@pleach/core": "^1.0.0",
"typescript": "^5.0.0"
}
}// ✗ Rejected — package.json
{
"dependencies": {
"@pleach/core": "^1.0.0" // multi-version hazard
}
}Why peer: two @pleach/core instances in the same Node module
cache produce two PluginManager singletons. Registration silently
routes to whichever instance the consumer's SessionRuntime
imports — usually not the one your plugin imports. Debugging this
is hours of "but I registered it." Peer dependency lets the
consumer's package manager resolve to one shared instance.
Range follows Packages:
@pleach/core cut | Plugin's peer range |
|---|---|
0.1.0 (first-wave) | "@pleach/core": "0.1.0" (exact pin) |
Future 0.x releases | "@pleach/core": "0.x.y" (exact pin) |
1.x stable | "@pleach/core": "^1.0.0" |
Lint rule: @pleach/core/lint:plugin-peer-dep.
S4 — File layout
Choose by plugin size and growth trajectory. Both shapes
register identically with new SessionRuntime({ plugins: [...] }).
Single-facet plugin
src/
└── pleach.plugin.ts # definePleachPlugin({capabilities, _raw})For plugins with 1–5 hooks across one semantic domain. The
definePleachPlugin factory's flat capability menu is the
right tool here.
Multi-facet plugin
src/myPlugin/
├── index.ts # composePlugin(base, ...facets)
├── facets/
│ ├── prompts.ts # definePromptsPlugin({...})
│ ├── safety.ts # defineSafetyPlugin({...})
│ ├── tools.ts # defineToolsPlugin({...})
│ └── ... # one file per facet you contribute to
├── strategies/ # pure functions implementing contributions
│ ├── fabrication.ts
│ ├── policies.ts
│ └── observers.ts
└── package.json # if publishable as a standalone npm packageFor plugins with 10+ hooks across 3+ semantic domains. Each
facet file's import line is the surface area in scope; a
reviewer reading safety.ts doesn't need to context-switch.
See Plugin bundles for the
composePlugin mechanic and the facet sub-paths.
Choosing between them
The wizard npx pleach init plugin scaffolds the multi-facet
shape inline in a single file by default — the bundle pattern
without the multi-file cost. When the file grows past ~150
lines, move each defineXxxPlugin const into its own facet file
under facets/<name>.ts. Mechanical refactor, zero shape
change.
S5 — Naming convention
| Field | Convention |
|---|---|
| Package name | kebab-case (npm convention) |
| Scope | @<org>/<plugin-name> when published; bare when local |
Plugin name field | MUST match the package name when published; bare name when local-only |
Plugin version field | semver per S2 |
| File name (single-facet) | pleach.plugin.ts at root or src/pleach.plugin.ts |
| File name (multi-facet) | src/<plugin-name>/index.ts |
Reserved namespace: @pleach/* is for first-party plugins
shipped by the Pleach team (the SKUs documented in
Packages). Third-party plugins use their own
scope — @yourorg/yourplugin — and there is no required prefix
or suffix on the plugin name.
When a plugin is published to npm, the plugin name field
field MUST match the package name to avoid silent mismatch on
registration (a package.json saying @bar/baz but a plugin
literal saying name: "foo" is almost always a typo). A future
@pleach/core/lint:plugin-name-matches-package rule may catch
this; doc-only at 1.0.0.
S6 — License default
The npx pleach init plugin wizard defaults to
license: "Apache-2.0" for the plugin's own scaffolded
package.json. This is the consumer-scaffold default for the
plugin you author; the @pleach/* substrate itself ships under
FSL-1.1-Apache-2.0 (which auto-transitions to Apache 2.0). The
defaults are coherent because the FSL future-license target is
Apache 2.0 — plugin authors landing on Apache 2.0 today produce a
plugin ecosystem aligned with where @pleach/* converges.
You can choose any license. The wizard prompts and accepts overrides. There is no enforcement here — this standard is purely a sensible default.
If your plugin needs MIT or another SPDX identifier, set it explicitly at scaffold time:
npx pleach init plugin --name corpus-safety --license MITS7 — Deprecation breadcrumb
Rule: when a plugin removes a hook contribution it added in a prior minor (or PATCH) release, emit a one-time breadcrumb at registration time for the duration of the major version cycle the removal is part of.
// lib/plugins/corpusSafety.ts
// At plugin registration time, in the major cycle following removal:
import { definePleachPlugin } from "@pleach/core";
export const corpusSafety = definePleachPlugin("corpus-safety", {
_raw: {
version: "2.0.0",
// Emitted at registration; consumers can opt out.
prePlanPrimer: () => {
console.warn(JSON.stringify({
type: "Pleach:plugin-removed-hook",
plugin: "corpus-safety",
hook: "contributeRefClassValidators",
removedIn: "2.0.0",
lastSupportedIn: "1.9.7",
}));
return null;
},
},
});The breadcrumb gives consumers a one-cycle window to discover
the removal and update their integration. Mirrors the
audit:harness-plugin-deprecated-usage cadence for first-party
hooks.
Future shape (post 1.0): a typed deprecatedHooks?: ReadonlyArray<DeprecatedHookSpec> field on HarnessPlugin
would let the runtime emit the breadcrumb automatically. Tracked
for a future decision; not in scope at 1.0.0.
Where standards meet the wizard
npx pleach init plugin scaffolds standards-compliant defaults:
- S1 —
nameandversionliterals (the wizard generates literal strings; no runtime computation). - S3 —
peerDependenciesdeclaration with the right@pleach/corerange for the installed bucket. - S4 — multi-facet shape inline by default;
--facets-file splitfor the multi-file layout. - S5 — kebab-case
namematching the scaffoldedpackage.json. - S6 — Apache-2.0 default for the plugin author's own
package.json;--license <SPDX>override. (Coherent with the@pleach/*substrate's FSL-1.1-Apache-2.0 → Apache 2.0 conversion target.)
See CLI for the subcommand spec.
Related
- Plugin contract — the underlying
HarnessPlugininterface and thedefinePleachPluginfactory. - Plugin bundles — thematic facet
sub-paths +
composePlugin()for multi-file authoring. - Versioning — package-level semver policy.
- Packages — SKU matrix and pin guidance.
- CLI —
pleach init pluginsubcommand.
Plugin contract
The HarnessPlugin extension surface — what plugins can do, what they can't, and the structural invariants the substrate enforces against them.
Plugin bundles
Thematic facet sub-paths for plugins that span multiple semantic domains — barrel imports, define-helpers, and composePlugin. The second DX layer above HarnessPlugin, peer-equivalent to definePleachPlugin.