pleach
Architecture

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:

ClassRulesMechanism
Lint-enforceableS1, S3@pleach/core/lint — doc-only at 1.0.0; warn at 1.1.0; error on novel at 1.2.0
Wizard-enforcedS4, S6npx pleach init plugin scaffolds standards-compliant defaults
Doc-recommendedS2, S5, S7Convention; 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, different package.json snapshot) and silently breaks @pleach/replay's diff harness.
  • Tree-shaking — bundlers prove plugin identity from string literals; computed values defeat shaking.
  • Toolingaudit:plugin-contract-completeness and 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.

ChangeBump
Remove a hook contribution (consumers rely on it via runtime behavior)MAJOR
Rename plugin name fieldMAJOR
Bump required @pleach/core peer range floorMAJOR
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 shapeMINOR
Bug fix within existing hook return valuesPATCH
Perf improvement inside existing contributions, no observable shape changePATCH
Documentation-only changesPATCH
First public release0.1.0
Stable, contract-bound1.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 cutPlugin'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 package

For 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

FieldConvention
Package namekebab-case (npm convention)
Scope@<org>/<plugin-name> when published; bare when local
Plugin name fieldMUST match the package name when published; bare name when local-only
Plugin version fieldsemver 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 MIT

S7 — 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 — name and version literals (the wizard generates literal strings; no runtime computation).
  • S3 — peerDependencies declaration with the right @pleach/core range for the installed bucket.
  • S4 — multi-facet shape inline by default; --facets-file split for the multi-file layout.
  • S5 — kebab-case name matching the scaffolded package.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.

  • Plugin contract — the underlying HarnessPlugin interface and the definePleachPlugin factory.
  • Plugin bundles — thematic facet sub-paths + composePlugin() for multi-file authoring.
  • Versioning — package-level semver policy.
  • Packages — SKU matrix and pin guidance.
  • CLIpleach init plugin subcommand.

On this page