# Plugin authoring standards (/docs/plugin-authoring-standards)



These standards are for plugin **authors** — what to do when
building a plugin against `@pleach/core`. They sit between the
contract surface ([Plugin contract](/docs/plugin-contract)) and
the multi-file authoring layer ([Plugin
bundles](/docs/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 [#s1--identity-discipline]

**Rule**: `name` and `version` on every plugin construction
call MUST be string literals at construction time.

```typescript
// ✓ Acceptable
const plugin = definePleachPlugin({
  name: "corpus-safety",
  version: "1.0.0",
  capabilities: {/* ... */},
});

const composed = composePlugin(
  { name: "corpus-safety", version: "1.0.0" },
  promptsFacet,
  safetyFacet,
);
```

```typescript
// ✗ 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.
* **Tooling** — `audit: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 [#s2--semver-discipline]

Plugins follow the same semver as packages but with plugin-specific edge cases. Where this table conflicts with [Versioning](/docs/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](/docs/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` [#s3--peer-dependency-on-pleachcore]

**Rule**: declare `@pleach/core` as `peerDependencies`, not `dependencies`.

```jsonc
// ✓ Acceptable — package.json
{
  "peerDependencies": {
    "@pleach/core": "^1.0.0"
  },
  "devDependencies": {
    "@pleach/core": "^1.0.0",
    "typescript": "^5.0.0"
  }
}
```

```jsonc
// ✗ 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](/docs/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 [#s4--file-layout]

Choose by plugin size and growth trajectory. Both shapes
register identically with `new SessionRuntime({ plugins: [...] })`.

### Single-facet plugin [#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 [#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](/docs/plugin-bundles) for the
`composePlugin` mechanic and the facet sub-paths.

### Choosing between them [#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 [#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](/docs/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 [#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:

```bash
npx pleach init plugin --name corpus-safety --license MIT
```

## S7 — Deprecation breadcrumb [#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.

```typescript
// 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 [#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](/docs/cli#pleach-init-plugin) for the subcommand spec.

## Related [#related]

* [Plugin contract](/docs/plugin-contract) — the underlying
  `HarnessPlugin` interface and the `definePleachPlugin`
  factory.
* [Plugin bundles](/docs/plugin-bundles) — thematic facet
  sub-paths + `composePlugin()` for multi-file authoring.
* [Versioning](/docs/versioning) — package-level semver
  policy.
* [Packages](/docs/packages) — SKU matrix and pin guidance.
* [CLI](/docs/cli) — `pleach init plugin` subcommand.
